Compare commits

...
Author SHA1 Message Date
dni ⚡ f091ca343a rc4 2024-10-14 13:14:55 +02:00
Vlad Stan 1874a10342 fix: init DB 2024-10-14 13:59:28 +03:00
dni ⚡ c77cb07915 remove update_payment_status/extra/details 2024-10-14 12:34:46 +02:00
dni ⚡ 62d9470010 add dbversion model 2024-10-14 11:19:14 +02:00
dni ⚡ 1c451674cf make get_user better 2024-10-14 10:30:44 +02:00
dni ⚡ 137e716bb8 clean init admin settings 2024-10-14 10:19:21 +02:00
dni ⚡ 75e07b21c7 vlad 2024-10-14 10:06:13 +02:00
dni ⚡ b72a81aa4c fix qrscanner 2024-10-11 13:10:55 +02:00
dni ⚡ 22a16d27f1 fix dynamic chips 2024-10-10 16:06:24 +02:00
dni ⚡ 8843726ae4 qrcode 2024-10-10 13:31:21 +02:00
dni ⚡ fa57b0de3f fix extra null 2024-10-10 13:20:05 +02:00
dni ⚡ 6989d9ab34 fix where 2024-10-10 13:05:56 +02:00
dni ⚡ 63d316381e rc3 2024-10-10 09:14:35 +02:00
Tiago Vasconcelosanddni ⚡ 0e9b1b44b2 added thi one also DNI
remove it if not needed
2024-10-10 09:09:10 +02:00
Tiago Vasconcelosanddni ⚡ 9fa0ef97c1 attempt to make chip component in settings dynamic fields
it's not updating data
2024-10-10 09:09:09 +02:00
Tiago Vasconcelosanddni ⚡ 2e8128b85d screwed up and made more css pushes to this branch 2024-10-10 09:09:07 +02:00
Tiago Vasconcelosanddni ⚡ fd0fa75e11 remove shadow on cards in dark theme 2024-10-10 09:09:06 +02:00
dni ⚡ ff12a76b42 fix dynamic fields and ext db migration 2024-10-10 09:09:05 +02:00
Vlad Stananddni ⚡ 8a023471a9 fix: component name 2024-10-10 09:09:02 +02:00
Vlad Stananddni ⚡ 0b7c33c060 chore: code format 2024-10-10 09:09:01 +02:00
Vlad Stananddni ⚡ 4d2438cf49 fix: payment extra 2024-10-10 09:09:00 +02:00
Vlad Stananddni ⚡ f590efd719 fix: payment extra 2024-10-10 09:08:58 +02:00
Vlad Stananddni ⚡ d5ea1af518 chore: code format 2024-10-10 09:08:57 +02:00
Vlad Stananddni ⚡ f227be2ea5 fix: payment extra and payment hash 2024-10-10 09:08:56 +02:00
Vlad Stananddni ⚡ 3ccdc3e249 chore: log message fix 2024-10-10 09:08:54 +02:00
dni ⚡ 7ca0a35b3a vue3 @input migration 2024-10-10 09:08:53 +02:00
dni ⚡ 0286906bd9 extension toggle vue3 2024-10-10 09:08:52 +02:00
dni ⚡ 63b499d2c9 payments 2024-10-10 09:08:51 +02:00
Vlad Stananddni ⚡ 620325b399 fix: lint 2024-10-10 09:08:49 +02:00
Vlad Stananddni ⚡ 3cfbae3b28 chore: code format 2024-10-10 09:08:48 +02:00
Vlad Stananddni ⚡ a94f42368d feat: add JSON custom serializer 2024-10-10 09:08:47 +02:00
Vlad Stananddni ⚡ 74b24eb609 fix: cast balance to int 2024-10-10 09:08:45 +02:00
Vlad Stananddni ⚡ c6ec329328 fix: time column 2024-10-10 09:08:44 +02:00
Vlad Stananddni ⚡ cd549315f1 fix: empty currency 2024-10-10 09:08:43 +02:00
dni ⚡ 28897ca120 fixup! 2024-10-10 09:08:42 +02:00
dni ⚡ e8d10abee3 add wallet balance 2024-10-10 09:08:40 +02:00
dni ⚡ 7715e9c343 use db url 2024-10-10 09:08:39 +02:00
dni ⚡ de1a780f6d escape user 2024-10-10 09:08:38 +02:00
Vlad Stananddni ⚡ e2eb07285f chore: remove verbose key names 2024-10-10 09:08:36 +02:00
Vlad Stananddni ⚡ 343e7847df chore: format 2024-10-10 09:08:35 +02:00
Vlad Stananddni ⚡ 5cb361fe43 refactor: make settings a fixture 2024-10-10 09:08:34 +02:00
Vlad Stananddni ⚡ 4906bdcc4e fix: settings 2024-10-10 09:08:33 +02:00
Vlad Stananddni ⚡ 8725e1a2b7 fix: api tests 2024-10-10 09:08:31 +02:00
Vlad Stananddni ⚡ b0e2861aef fix: add back create_user_account 2024-10-10 09:08:30 +02:00
Vlad Stananddni ⚡ 388f9946e3 fix: some tests 2024-10-10 09:08:29 +02:00
dni ⚡ ed81ca55d9 parse date from dict 2024-10-10 09:08:27 +02:00
dni ⚡ e217e58bb6 add migration for view 2024-10-10 09:08:26 +02:00
dni ⚡ 7843a00edf fixes 2024-10-10 09:08:25 +02:00
dni ⚡ 7f3abcbd07 fix 2024-10-10 09:08:24 +02:00
Vlad Stananddni ⚡ f54c78a58d fix: account insert 2024-10-10 09:08:22 +02:00
dni ⚡ f923ecdefd fixup! 2024-10-10 09:08:20 +02:00
dni ⚡ cf03d94d4d fixup! 2024-10-10 09:08:19 +02:00
dni ⚡ 5f0068bc6d fixup! 2024-10-10 09:08:17 +02:00
dni ⚡ f85fb96063 fixup migration 2024-10-10 09:08:15 +02:00
dni ⚡ 84d9551706 fixup! 2024-10-10 09:08:13 +02:00
dni ⚡ 9ae475d4a0 fixup! 2024-10-10 09:08:12 +02:00
dni ⚡ 69e67d7d64 fixup! 2024-10-10 09:08:11 +02:00
dni ⚡ 382154b04a fix some tests 2024-10-10 09:08:10 +02:00
dni ⚡ f06b29dd82 fixup! 2024-10-10 09:08:08 +02:00
dni ⚡ 6e1b746056 fixup! 2024-10-10 09:08:07 +02:00
dni ⚡ 0b9e328363 fixup! 2024-10-10 09:08:06 +02:00
dni ⚡ f341537de2 fixup! 2024-10-10 09:08:04 +02:00
dni ⚡ 3551ee14a4 backwards compatible 2024-10-10 09:08:02 +02:00
dni ⚡ 0779535a9c fix account overview 2024-10-10 09:08:01 +02:00
dni ⚡ b0701fdb65 fix error 2024-10-10 09:07:59 +02:00
dni ⚡ 955a345900 fixup! 2024-10-10 09:07:58 +02:00
dni ⚡ aa236d99ce fixup-qrcode 2024-10-10 09:07:57 +02:00
dni ⚡ 5acddb75f5 some wallet ui fixes 2024-10-10 09:07:56 +02:00
dni ⚡ 2a17d5a8ce fixup! 2024-10-10 09:07:54 +02:00
dni ⚡ 4d75545994 revert 2024-10-10 09:07:53 +02:00
dni ⚡ cc8776ac74 fixup! 2024-10-10 09:07:52 +02:00
dni ⚡ 933c494836 more fixes 2024-10-10 09:07:50 +02:00
dni ⚡ ba843b3df4 fixup! 2024-10-10 09:07:49 +02:00
dni ⚡ 03b1a4f729 extension manager 2024-10-10 09:07:48 +02:00
dni ⚡ 0c456941e5 last fixes 2024-10-10 09:07:47 +02:00
dni ⚡ 200f5c52a7 big fx part2 2024-10-10 09:07:45 +02:00
dni ⚡ 383f6ec721 fix part1 2024-10-10 09:07:44 +02:00
Vlad Stananddni ⚡ 2410c271a6 chore: code clean-up 2024-10-10 09:07:43 +02:00
dni ⚡ 85b41c4aab remove where from argument 2024-10-10 09:07:42 +02:00
dni ⚡ cfdd81d37e fixup! 2024-10-10 09:07:40 +02:00
dni ⚡ c0db3468af more docs 2024-10-10 09:07:39 +02:00
dni ⚡ e1fb82cea5 fixup! 2024-10-10 09:07:38 +02:00
dni ⚡ 06bdd61c8e fixup! 2024-10-10 09:07:36 +02:00
dni ⚡ d813544bf4 fixup! 2024-10-10 09:07:35 +02:00
dni ⚡ cae81b61a3 fixup! 2024-10-10 09:07:34 +02:00
dni ⚡ 219e8746c5 feat: add shortcuts for insert_query and update_query into Database
example: await db.insert("table_name", base_model)
2024-10-10 09:07:29 +02:00
53 changed files with 1775 additions and 1766 deletions
+14 -11
View File
@@ -6,7 +6,7 @@ import shutil
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Callable, List, Optional
from typing import Callable, Optional
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -17,12 +17,10 @@ from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import (
add_installed_extension,
get_dbversions,
get_db_version,
get_installed_extensions,
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.tasks import ( # watchdog_task
@@ -47,7 +45,7 @@ from lnbits.wallets import get_funding_source, set_funding_source
from .commands import migrate_databases
from .core import init_core_routers
from .core.db import core_app_extra
from .core.extensions.models import Extension, InstallableExtension
from .core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from .core.services import check_admin_settings, check_webpush_settings
from .middleware import (
CustomGZipMiddleware,
@@ -240,7 +238,8 @@ async def check_installed_extensions(app: FastAPI):
)
except Exception as e:
logger.warning(e)
await deactivate_extension(ext.id)
settings.deactivate_extension_paths(ext.id)
await update_installed_extension_state(ext_id=ext.id, active=False)
logger.warning(
f"Failed to re-install extension: {ext.id} ({ext.installed_version})"
)
@@ -252,7 +251,7 @@ async def check_installed_extensions(app: FastAPI):
async def build_all_installed_extensions_list(
include_deactivated: Optional[bool] = True,
) -> List[InstallableExtension]:
) -> list[InstallableExtension]:
"""
Returns a list of all the installed extensions plus the extensions that
MUST be installed by default (see LNBITS_EXTENSIONS_DEFAULT_INSTALL).
@@ -272,8 +271,13 @@ async def build_all_installed_extensions_list(
release = next((e for e in ext_releases if e.is_version_compatible), None)
if release:
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=ext_id, name=ext_id, installed_release=release, icon=release.icon
id=ext_id,
name=ext_id,
version=release.version,
icon=release.icon,
meta=ext_meta,
)
installed_extensions.append(ext_info)
@@ -304,14 +308,13 @@ async def check_installed_extension_files(ext: InstallableExtension) -> bool:
async def restore_installed_extension(app: FastAPI, ext: InstallableExtension):
await add_installed_extension(ext)
await update_installed_extension_state(ext_id=ext.id, active=True)
extension = Extension.from_installable_ext(ext)
register_ext_routes(app, extension)
current_version = (await get_dbversions()).get(ext.id, 0)
await migrate_extension_database(extension, current_version)
current_version = await get_db_version(ext.id)
await migrate_extension_database(ext, current_version)
# mount routes for the new version
core_app_extra.register_new_ext_routes(extension)
+18 -14
View File
@@ -3,7 +3,7 @@ import importlib
import time
from functools import wraps
from pathlib import Path
from typing import List, Optional, Tuple
from typing import Optional
import click
import httpx
@@ -17,12 +17,13 @@ from lnbits.core.crud import (
delete_unused_wallets,
delete_wallet_by_id,
delete_wallet_payment,
get_dbversions,
get_db_versions,
get_installed_extension,
get_installed_extensions,
get_payment,
get_payments,
remove_deleted_wallets,
update_payment_status,
update_payment,
)
from lnbits.core.extensions.models import (
CreateExtension,
@@ -122,7 +123,7 @@ def database_migrate():
async def db_versions():
"""Show current database versions"""
async with core_db.connect() as conn:
click.echo(await get_dbversions(conn))
click.echo(await get_db_versions(conn))
@db.command("cleanup-wallets")
@@ -172,9 +173,10 @@ async def database_delete_wallet_payment(wallet: str, checking_id: str):
async def database_revert_payment(checking_id: str):
"""Mark payment as pending"""
async with core_db.connect() as conn:
await update_payment_status(
status=PaymentState.PENDING, checking_id=checking_id, conn=conn
)
payment = await get_payment(checking_id=checking_id, conn=conn)
payment.status = PaymentState.PENDING
await update_payment(payment, conn)
click.echo(f"Payment '{checking_id}' marked as pending.")
@db.command("cleanup-accounts")
@@ -231,7 +233,7 @@ async def check_invalid_payments(
click.echo("Funding source: " + str(funding_source))
# payments that are settled in the DB, but not at the Funding source level
invalid_payments: List[Payment] = []
invalid_payments: list[Payment] = []
invalid_wallets = {}
for db_payment in settled_db_payments:
if verbose:
@@ -277,8 +279,10 @@ async def extensions_list():
from lnbits.app import build_all_installed_extensions_list
for ext in await build_all_installed_extensions_list():
assert ext.installed_release, f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.installed_release.version})")
assert (
ext.meta and ext.meta.installed_release
), f"Extension {ext.id} has no installed_release"
click.echo(f" - {ext.id} ({ext.meta.installed_release.version})")
@extensions.command("update")
@@ -461,7 +465,7 @@ async def install_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
release = await _select_release(extension, repo_index, source_repo)
if not release:
@@ -490,7 +494,7 @@ async def update_extension(
source_repo: Optional[str] = None,
url: Optional[str] = None,
admin_user: Optional[str] = None,
) -> Tuple[bool, str]:
) -> tuple[bool, str]:
try:
click.echo(f"Updating '{extension}' extension.")
installed_ext = await get_installed_extension(extension)
@@ -503,7 +507,7 @@ async def update_extension(
click.echo(f"Current '{extension}' version: {installed_ext.installed_version}.")
assert (
installed_ext.installed_release
installed_ext.meta and installed_ext.meta.installed_release
), "Cannot find previously installed release. Please uninstall first."
release = await _select_release(extension, repo_index, source_repo)
@@ -511,7 +515,7 @@ async def update_extension(
return False, "No release selected."
if (
release.version == installed_ext.installed_version
and release.source_repo == installed_ext.installed_release.source_repo
and release.source_repo == installed_ext.meta.installed_release.source_repo
):
click.echo(f"Extension '{extension}' already up to date.")
return False, "Already up to date"
+225 -531
View File
File diff suppressed because it is too large Load Diff
+11 -9
View File
@@ -3,14 +3,14 @@ import importlib
from loguru import logger
from lnbits.core import core_app_extra
from lnbits.core.crud import (
add_installed_extension,
create_installed_extension,
delete_installed_extension,
get_dbversions,
get_db_version,
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
@@ -18,22 +18,24 @@ from .models import Extension, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_id = ext_info.id
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 []
installed_ext = await get_installed_extension(ext_id)
if installed_ext:
ext_info.meta = installed_ext.meta
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)
db_version = await get_db_version(ext_id)
await migrate_extension_database(ext_info, db_version)
await add_installed_extension(ext_info)
await create_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)
await stop_extension_background_work(ext_id)
return extension
+86 -72
View File
@@ -109,8 +109,8 @@ class ReleasePaymentInfo(BaseModel):
class PayToEnableInfo(BaseModel):
required: Optional[bool] = False
amount: Optional[int] = None
amount: int
required: bool = False
wallet: Optional[str] = None
@@ -120,6 +120,7 @@ class UserExtensionInfo(BaseModel):
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
@@ -372,29 +373,37 @@ class ExtensionRelease(BaseModel):
return None
class ExtensionMeta(BaseModel):
installed_release: Optional[ExtensionRelease] = None
latest_release: Optional[ExtensionRelease] = None
pay_to_enable: Optional[PayToEnableInfo] = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: Optional[str] = None
featured: bool = False
class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: list[str] = []
is_admin_only: bool = False
stars: int = 0
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: list[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
meta: Optional[ExtensionMeta] = None
@property
def is_admin_only(self) -> bool:
return self.id in settings.lnbits_admin_extensions
@property
def hash(self) -> str:
if self.installed_release:
if self.installed_release.hash:
return self.installed_release.hash
if self.meta and self.meta.installed_release:
if self.meta.installed_release.hash:
return self.meta.installed_release.hash
m = hashlib.sha256()
m.update(f"{self.installed_release.archive}".encode())
m.update(f"{self.meta.installed_release.archive}".encode())
return m.hexdigest()
return "not-installed"
@@ -432,15 +441,15 @@ class InstallableExtension(BaseModel):
@property
def installed_version(self) -> str:
if self.installed_release:
return self.installed_release.version
if self.meta and self.meta.installed_release:
return self.meta.installed_release.version
return ""
@property
def requires_payment(self) -> bool:
if not self.pay_to_enable:
if not self.meta or not self.meta.pay_to_enable:
return False
return self.pay_to_enable.required is True
return self.meta.pay_to_enable.required is True
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
@@ -448,12 +457,14 @@ class InstallableExtension(BaseModel):
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
assert self.installed_release, "installed_release is none."
assert (
self.meta and self.meta.installed_release
), "installed_release is none."
self._restore_payment_info()
await asyncio.to_thread(
download_url, self.installed_release.archive_url, ext_zip_file
download_url, self.meta.installed_release.archive_url, ext_zip_file
)
self._remember_payment_info()
@@ -463,7 +474,11 @@ class InstallableExtension(BaseModel):
raise AssertionError("Cannot fetch extension archive file") from exc
archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash:
if (
self.meta
and self.meta.installed_release.hash
and self.meta.installed_release.hash != archive_hash
):
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
@@ -497,17 +512,18 @@ class InstallableExtension(BaseModel):
self.short_description = config_json.get("short_description")
if (
self.installed_release
and self.installed_release.is_github_release
self.meta
and self.meta.installed_release
and self.meta.installed_release.is_github_release
and config_json.get("tile")
):
self.icon = icon_to_github_url(
self.installed_release.source_repo, config_json.get("tile")
self.meta.installed_release.source_repo, config_json.get("tile")
)
shutil.rmtree(self.ext_dir, True)
shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
logger.info(f"Extension {self.name} ({self.installed_version}) extracted.")
def clean_extension_files(self):
# remove downloaded archive
@@ -522,64 +538,54 @@ class InstallableExtension(BaseModel):
def check_latest_version(self, release: Optional[ExtensionRelease]):
if not release:
return
if not self.latest_release:
self.latest_release = release
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
self.meta = meta
return
if version_parse(self.latest_release.version) < version_parse(release.version):
self.latest_release = release
if version_parse(self.meta.latest_release.version) < version_parse(
release.version
):
self.meta.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
(p for p in self.payments if p.pay_link == pay_link),
(p for p in self.meta.payments if p.pay_link == pay_link),
None,
)
def _restore_payment_info(self):
if not self.installed_release:
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
or not self.meta.installed_release.payment_hash
):
return
if not self.installed_release.pay_link:
return
if self.installed_release.payment_hash:
return
payment_info = self.find_existing_payment(self.installed_release.pay_link)
payment_info = self.find_existing_payment(self.meta.installed_release.pay_link)
if payment_info:
self.installed_release.payment_hash = payment_info.payment_hash
self.meta.installed_release.payment_hash = payment_info.payment_hash
def _remember_payment_info(self):
if not self.installed_release or not self.installed_release.pay_link:
if (
not self.meta
or not self.meta.installed_release
or not self.meta.installed_release.pay_link
):
return
payment_info = ReleasePaymentInfo(
amount=self.installed_release.cost_sats,
pay_link=self.installed_release.pay_link,
payment_hash=self.installed_release.payment_hash,
amount=self.meta.installed_release.cost_sats,
pay_link=self.meta.installed_release.pay_link,
payment_hash=self.meta.installed_release.payment_hash,
)
self.payments = [
p for p in self.payments if p.pay_link != payment_info.pay_link
self.meta.payments = [
p for p in self.meta.payments if p.pay_link != payment_info.pay_link
]
self.payments.append(payment_info)
@classmethod
def from_row(cls, data: dict) -> InstallableExtension:
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("pay_to_enable"):
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
def from_rows(cls, rows: Optional[list[Any]] = None) -> list[InstallableExtension]:
if rows is None:
rows = []
return [InstallableExtension.from_row(row) for row in rows]
self.meta.payments.append(payment_info)
@classmethod
async def from_github_release(
@@ -593,14 +599,17 @@ class InstallableExtension(BaseModel):
return InstallableExtension(
id=github_release.id,
name=config.name,
version=latest_release.tag_name,
short_description=config.short_description,
stars=int(repo.stargazers_count),
icon=icon_to_github_url(
source_repo,
config.tile,
),
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
),
)
except Exception as e:
@@ -609,13 +618,14 @@ class InstallableExtension(BaseModel):
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
return InstallableExtension(
id=e.id,
name=e.name,
archive=e.archive,
version=e.version,
short_description=e.short_description,
icon=e.icon,
dependencies=e.dependencies,
meta=meta,
)
@classmethod
@@ -636,11 +646,13 @@ class InstallableExtension(BaseModel):
existing_ext = next(
(ee for ee in extension_list if ee.id == r.id), None
)
if existing_ext:
existing_ext.check_latest_version(ext.latest_release)
if existing_ext and ext.meta:
existing_ext.check_latest_version(ext.meta.latest_release)
continue
ext.featured = ext.id in manifest.featured
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
extension_id_list += [ext.id]
@@ -654,7 +666,9 @@ class InstallableExtension(BaseModel):
continue
ext = InstallableExtension.from_explicit_release(e)
ext.check_latest_version(release)
ext.featured = ext.id in manifest.featured
meta = ext.meta or ExtensionMeta()
meta.featured = ext.id in manifest.featured
ext.meta = meta
extension_list += [ext]
extension_id_list += [e.id]
except Exception as e:
+31 -16
View File
@@ -1,6 +1,6 @@
import importlib
import re
from typing import Any
from typing import Any, Optional
from urllib.parse import urlparse
from uuid import UUID
@@ -8,39 +8,44 @@ from loguru import logger
from lnbits.core import migrations as core_migrations
from lnbits.core.crud import (
get_dbversions,
get_db_versions,
get_installed_extensions,
update_migration_version,
)
from lnbits.core.db import db as core_db
from lnbits.core.extensions.models import (
Extension,
)
from lnbits.core.extensions.models import InstallableExtension
from lnbits.core.models import DbVersion
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
from lnbits.settings import settings
async def migrate_extension_database(ext: Extension, current_version):
async def migrate_extension_database(
ext: InstallableExtension, current_version: Optional[DbVersion] = None
):
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
ext_db = importlib.import_module(ext.module_name).db
except ImportError as exc:
logger.error(exc)
raise ImportError(f"Cannot import module for extension '{ext.code}'.") from exc
raise ImportError(f"Cannot import module for extension '{ext.id}'.") from exc
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations, ext.code, current_version)
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
async def run_migration(
db: Connection, migrations_module: Any, db_name: str, current_version: int
db: Connection,
migrations_module: Any,
db_name: str,
current_version: Optional[DbVersion] = None,
):
matcher = re.compile(r"^m(\d\d\d)_")
for key, migrate in migrations_module.__dict__.items():
match = matcher.match(key)
if match:
version = int(match.group(1))
if version > current_version:
if not current_version or version > current_version.version:
logger.debug(f"running migration {db_name}.{version}")
print(f"running migration {db_name}.{version}")
await migrate(db)
@@ -87,21 +92,31 @@ async def migrate_databases():
if not exists:
await core_migrations.m000_create_migrations_table(conn)
current_versions = await get_dbversions(conn)
core_version = current_versions.get("core", 0)
current_versions = await get_db_versions(conn)
core_version = next(
(v for v in current_versions if v.db == "core"),
DbVersion(db="core", version=0),
)
await run_migration(conn, core_migrations, "core", core_version)
# here is the first place we can be sure that the
# `installed_extensions` table has been created
await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in Extension.get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0)
for ext in await get_installed_extensions():
current_version = next(
(v for v in current_versions if v.db == ext.id),
DbVersion(db=ext.id, version=0),
)
if current_version is None:
logger.warning(
f"Extension {ext.id} has no migration version. This should not happen."
)
continue
try:
await migrate_extension_database(ext, current_version)
except Exception as e:
logger.exception(f"Error migrating extension {ext.code}: {e}")
logger.exception(f"Error migrating extension {ext.id}: {e}")
logger.info("✔️ All migrations done.")
+35 -2
View File
@@ -541,8 +541,6 @@ async def m021_add_success_failed_to_apipayments(db):
GROUP BY apipayments.wallet
"""
)
# TODO: drop column in next release
# await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m022_add_pubkey_to_accounts(db):
@@ -553,3 +551,38 @@ async def m022_add_pubkey_to_accounts(db):
await db.execute("ALTER TABLE accounts ADD COLUMN pubkey TEXT")
except OperationalError:
pass
async def m023_add_column_column_to_apipayments(db):
"""
renames hash to payment_hash and drops unused index
"""
await db.execute("DROP INDEX by_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN hash TO payment_hash")
await db.execute("ALTER TABLE apipayments RENAME COLUMN wallet TO wallet_id")
await db.execute("ALTER TABLE accounts RENAME COLUMN pass TO password_hash")
await db.execute("CREATE INDEX by_hash ON apipayments (payment_hash)")
async def m024_drop_pending(db):
await db.execute("ALTER TABLE apipayments DROP COLUMN pending")
async def m025_refresh_view(db):
await db.execute("DROP VIEW balances")
await db.execute(
"""
CREATE VIEW balances AS
SELECT apipayments.wallet_id,
SUM(apipayments.amount - ABS(apipayments.fee)) AS balance
FROM wallets
LEFT JOIN apipayments ON apipayments.wallet_id = wallets.id
WHERE (wallets.deleted = false OR wallets.deleted is NULL)
AND (
(apipayments.status = 'success' AND apipayments.amount > 0)
OR (apipayments.status IN ('success', 'pending') AND apipayments.amount < 0)
)
GROUP BY apipayments.wallet_id
"""
)
+75 -67
View File
@@ -1,19 +1,18 @@
from __future__ import annotations
import datetime
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Callable, Optional
from ecdsa import SECP256k1, SigningKey
from fastapi import Query
from pydantic import BaseModel, validator
from passlib.context import CryptContext
from pydantic import BaseModel, Field, validator
from lnbits.db import FilterModel, FromRowModel
from lnbits.db import FilterModel
from lnbits.helpers import url_for
from lnbits.lnurl import encode as lnurl_encode
from lnbits.settings import settings
@@ -35,16 +34,21 @@ class BaseWallet(BaseModel):
balance_msat: int
class Wallet(BaseWallet):
class Wallet(BaseModel):
id: str
user: str
currency: Optional[str]
deleted: bool
created_at: Optional[int] = None
updated_at: Optional[int] = None
name: str
adminkey: str
inkey: str
deleted: bool = False
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
currency: Optional[str] = None
balance_msat: int = Field(default=0, no_database=True)
@property
def balance(self) -> int:
return self.balance_msat // 1000
return int(self.balance_msat // 1000)
@property
def withdrawable_balance(self) -> int:
@@ -68,11 +72,6 @@ class Wallet(BaseWallet):
linking_key, curve=SECP256k1, hashfunc=hashlib.sha256
)
async def get_payment(self, payment_hash: str) -> Optional[Payment]:
from .crud import get_standalone_payment
return await get_standalone_payment(payment_hash)
class KeyType(Enum):
admin = 0
@@ -90,7 +89,7 @@ class WalletTypeInfo:
wallet: Wallet
class UserConfig(BaseModel):
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
@@ -103,16 +102,43 @@ class UserConfig(BaseModel):
provider: Optional[str] = "lnbits" # auth provider
class Account(FromRowModel):
class Account(BaseModel):
id: str
is_super_user: Optional[bool] = False
is_admin: Optional[bool] = False
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
balance_msat: Optional[int] = 0
extra: UserExtra = UserExtra()
created_at: datetime = datetime.now(timezone.utc)
updated_at: datetime = datetime.now(timezone.utc)
@property
def is_super_user(self) -> bool:
return self.id == settings.super_user
@property
def is_admin(self) -> bool:
return self.id in settings.lnbits_admin_users or self.is_super_user
def hash_password(self, password: str) -> str:
"""sets and returns the hashed password"""
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.password_hash = pwd_context.hash(password)
return self.password_hash
def verify_password(self, password: str) -> bool:
"""returns True if the password matches the hash"""
if not self.password_hash:
return False
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
return pwd_context.verify(password, self.password_hash)
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
last_payment: Optional[datetime.datetime] = None
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
class AccountFilters(FilterModel):
@@ -127,7 +153,7 @@ class AccountFilters(FilterModel):
]
id: str
last_payment: Optional[datetime.datetime] = None
last_payment: Optional[datetime] = None
transaction_count: Optional[int] = None
wallet_count: Optional[int] = None
username: Optional[str] = None
@@ -136,6 +162,8 @@ class AccountFilters(FilterModel):
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
@@ -144,9 +172,7 @@ class User(BaseModel):
admin: bool = False
super_user: bool = False
has_password: bool = False
config: Optional[UserConfig] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
extra: UserExtra = UserExtra()
@property
def wallet_ids(self) -> list[str]:
@@ -178,7 +204,7 @@ class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
config: Optional[UserConfig] = None
extra: Optional[UserExtra] = None
class UpdateUserPassword(BaseModel):
@@ -238,29 +264,31 @@ class CreatePayment(BaseModel):
amount: int
memo: str
preimage: Optional[str] = None
expiry: Optional[datetime.datetime] = None
expiry: Optional[datetime] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
fee: int = 0
class Payment(FromRowModel):
class Payment(BaseModel):
status: str
# TODO should be removed in the future, backward compatibility
pending: bool
checking_id: str
payment_hash: str
wallet_id: str
amount: int
fee: int
memo: Optional[str]
time: int
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[float]
expiry: Optional[datetime]
extra: Optional[dict]
wallet_id: str
webhook: Optional[str]
webhook_status: Optional[int]
webhook_status: Optional[int] = None
preimage: Optional[str] = "0" * 64
@property
def pending(self) -> bool:
return self.status == PaymentState.PENDING.value
@property
def success(self) -> bool:
@@ -270,27 +298,6 @@ class Payment(FromRowModel):
def failed(self) -> bool:
return self.status == PaymentState.FAILED.value
@classmethod
def from_row(cls, row: dict):
return cls(
checking_id=row["checking_id"],
payment_hash=row["hash"] or "0" * 64,
bolt11=row["bolt11"] or "",
preimage=row["preimage"] or "0" * 64,
extra=json.loads(row["extra"] or "{}"),
status=row["status"],
# TODO should be removed in the future, backward compatibility
pending=row["status"] == PaymentState.PENDING.value,
amount=row["amount"],
fee=row["fee"],
memo=row["memo"],
time=row["time"],
expiry=row["expiry"],
wallet_id=row["wallet"],
webhook=row["webhook"],
webhook_status=row["webhook_status"],
)
@property
def tag(self) -> Optional[str]:
if self.extra is None:
@@ -315,7 +322,7 @@ class Payment(FromRowModel):
@property
def is_expired(self) -> bool:
return self.expiry < time.time() if self.expiry else False
return self.expiry < datetime.now(timezone.utc) if self.expiry else False
@property
def is_internal(self) -> bool:
@@ -343,11 +350,11 @@ class PaymentFilters(FilterModel):
amount: int
fee: int
memo: Optional[str]
time: datetime.datetime
time: datetime
bolt11: str
preimage: str
payment_hash: str
expiry: Optional[datetime.datetime]
expiry: Optional[datetime]
extra: dict = {}
wallet_id: str
webhook: Optional[str]
@@ -355,7 +362,7 @@ class PaymentFilters(FilterModel):
class PaymentHistoryPoint(BaseModel):
date: datetime.datetime
date: datetime
income: int
spending: int
balance: int
@@ -377,10 +384,6 @@ class TinyURL(BaseModel):
wallet: str
time: float
@classmethod
def from_row(cls, row: dict):
return cls(**dict(row))
class ConversionData(BaseModel):
from_: str = "sat"
@@ -451,7 +454,7 @@ class WebPushSubscription(BaseModel):
user: str
data: str
host: str
timestamp: str
timestamp: datetime
class BalanceDelta(BaseModel):
@@ -466,3 +469,8 @@ class BalanceDelta(BaseModel):
class SimpleStatus(BaseModel):
success: bool
message: str
class DbVersion(BaseModel):
db: str
version: int
+96 -107
View File
@@ -13,11 +13,11 @@ from bolt11 import decode as bolt11_decode
from cryptography.hazmat.primitives import serialization
from fastapi import Depends, WebSocket
from loguru import logger
from passlib.context import CryptContext
from py_vapid import Vapid
from py_vapid.utils import b64urlencode
from lnbits.core.db import db
from lnbits.core.extensions.models import UserExtension
from lnbits.db import Connection
from lnbits.decorators import (
WalletTypeInfo,
@@ -46,34 +46,37 @@ from lnbits.wallets.base import (
from .crud import (
check_internal,
check_internal_pending,
create_account,
create_admin_settings,
create_payment,
create_wallet,
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_payment,
get_payments,
get_standalone_payment,
get_super_settings,
get_total_balance,
get_user,
get_wallet,
get_wallet_payment,
is_internal_status_success,
update_admin_settings,
update_payment_details,
update_payment_status,
update_payment,
update_super_user,
update_user_extension,
)
from .helpers import to_valid_user_id
from .models import (
Account,
BalanceDelta,
CreatePayment,
Payment,
PaymentState,
User,
UserConfig,
UserExtra,
Wallet,
)
@@ -243,19 +246,17 @@ async def pay_invoice(
extra=extra,
)
# we check if an internal invoice exists that has already been paid
# (not pending anymore)
if not await check_internal_pending(invoice.payment_hash, conn=conn):
if await is_internal_status_success(invoice.payment_hash, conn=conn):
raise PaymentError("Internal invoice already paid.", status="failed")
# check_internal() returns the checking_id of the invoice we're waiting for
# (pending only)
internal_checking_id = await check_internal(invoice.payment_hash, conn=conn)
if internal_checking_id:
internal_payment = await check_internal(invoice.payment_hash, conn=conn)
if internal_payment:
# perform additional checks on the internal payment
# the payment hash is not enough to make sure that this is the same invoice
internal_invoice = await get_standalone_payment(
internal_checking_id, incoming=True, conn=conn
internal_payment.checking_id, incoming=True, conn=conn
)
assert internal_invoice is not None
if (
@@ -289,7 +290,7 @@ async def pay_invoice(
wallet = await get_wallet(wallet_id, conn=conn)
assert wallet, "Wallet for balancecheck could not be fetched"
fee_reserve_total_msat = fee_reserve_total(invoice.amount_msat, internal=False)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_checking_id)
_check_wallet_balance(wallet, fee_reserve_total_msat, internal_payment)
if extra and "tag" in extra:
# check if the payment is made for an extension that the user disabled
@@ -297,79 +298,71 @@ async def pay_invoice(
if not status.success:
raise PaymentError(status.message)
if internal_checking_id:
if internal_payment:
service_fee_msat = service_fee(invoice.amount_msat, internal=True)
logger.debug(f"marking temporary payment as not pending {internal_checking_id}")
logger.debug(
f"marking temporary payment as not pending {internal_payment.checking_id}"
)
# mark the invoice from the other side as not pending anymore
# so the other side only has access to his new money when we are sure
# the payer has enough to deduct from
async with db.connect() as conn:
await update_payment_status(
checking_id=internal_checking_id,
status=PaymentState.SUCCESS,
conn=conn,
)
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
await send_payment_notification(wallet, new_payment)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
logger.debug(f"enqueuing internal invoice {internal_checking_id}")
await internal_invoice_queue.put(internal_checking_id)
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
await internal_invoice_queue.put(internal_payment.checking_id)
else:
fee_reserve_msat = fee_reserve(invoice.amount_msat, internal=False)
service_fee_msat = service_fee(invoice.amount_msat, internal=False)
logger.debug(f"backend: sending payment {temp_id}")
# actually pay the external invoice
funding_source = get_funding_source()
payment: PaymentResponse = await funding_source.pay_invoice(
payment_response: PaymentResponse = await funding_source.pay_invoice(
payment_request, fee_reserve_msat
)
if payment.checking_id and payment.checking_id != temp_id:
if payment_response.checking_id and payment_response.checking_id != temp_id:
logger.warning(
f"backend sent unexpected checking_id (expected: {temp_id} got:"
f" {payment.checking_id})"
f" {payment_response.checking_id})"
)
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment}")
if payment.checking_id and payment.ok is not False:
logger.debug(f"backend: pay_invoice finished {temp_id}, {payment_response}")
if payment_response.checking_id and payment_response.ok is not False:
# payment.ok can be True (paid) or None (pending)!
logger.debug(f"updating payment {temp_id}")
async with db.connect() as conn:
await update_payment_details(
checking_id=temp_id,
status=(
PaymentState.SUCCESS
if payment.ok is True
else PaymentState.PENDING
),
fee=-(
abs(payment.fee_msat if payment.fee_msat else 0)
+ abs(service_fee_msat)
),
preimage=payment.preimage,
new_checking_id=payment.checking_id,
conn=conn,
payment = await get_payment(temp_id, conn=conn)
# new checking id
payment.checking_id = payment_response.checking_id
payment.status = (
PaymentState.SUCCESS
if payment_response.ok is True
else PaymentState.PENDING
)
payment.fee = -(
abs(payment_response.fee_msat or 0) + abs(service_fee_msat)
)
payment.preimage = payment_response.preimage
await update_payment(payment, conn=conn)
wallet = await get_wallet(wallet_id, conn=conn)
updated = await get_wallet_payment(
wallet_id, payment.checking_id, conn=conn
)
if wallet and updated:
await send_payment_notification(wallet, updated)
logger.success(f"payment successful {payment.checking_id}")
elif payment.checking_id is None and payment.ok is False:
if wallet:
await send_payment_notification(wallet, payment)
logger.success(f"payment successful {payment_response.checking_id}")
elif payment_response.checking_id is None and payment_response.ok is False:
# payment failed
logger.debug(f"payment failed {temp_id}, {payment.error_message}")
logger.debug(f"payment failed {temp_id}, {payment_response.error_message}")
async with db.connect() as conn:
await update_payment_status(
checking_id=temp_id,
status=PaymentState.FAILED,
conn=conn,
)
payment = await get_payment(temp_id, conn=conn)
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
raise PaymentError(
f"Payment failed: {payment.error_message}"
f"Payment failed: {payment_response.error_message}"
or "Payment failed, but backend didn't give us an error message.",
status="failed",
)
@@ -416,9 +409,8 @@ async def _create_external_payment(
status = await old_payment.check_status()
if status.success:
# payment was successful on the fundingsource
await update_payment_status(
checking_id=temp_id, status=PaymentState.SUCCESS, conn=conn
)
old_payment.status = PaymentState.SUCCESS
await update_payment(old_payment, conn=conn)
raise PaymentError(
"Failed payment was already paid on the fundingsource.",
status="success",
@@ -450,11 +442,11 @@ async def _create_external_payment(
def _check_wallet_balance(
wallet: Wallet,
fee_reserve_total_msat: int,
internal_checking_id: Optional[str] = None,
internal_payment: Optional[Payment] = None,
):
if wallet.balance_msat < 0:
logger.debug("balance is too low, deleting temporary payment")
if not internal_checking_id and wallet.balance_msat > -fee_reserve_total_msat:
if not internal_payment and wallet.balance_msat > -fee_reserve_total_msat:
raise PaymentError(
f"You must reserve at least ({round(fee_reserve_total_msat/1000)}"
" sat) to cover potential routing fees.",
@@ -713,22 +705,23 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
)
async with db.connect() as conn:
checking_id = await check_internal(payment_hash, conn=conn)
assert checking_id, "newly created checking_id cannot be retrieved"
await update_payment_status(
checking_id=checking_id, status=PaymentState.SUCCESS, conn=conn
payment_hash, _ = await create_invoice(
wallet_id=wallet_id,
amount=amount,
memo="Admin top up",
internal=True,
conn=conn,
)
internal_payment = await check_internal(payment_hash, conn=conn)
assert internal_payment, "newly created checking_id cannot be retrieved"
internal_payment.status = PaymentState.SUCCESS
await update_payment(internal_payment, conn=conn)
# notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue
await internal_invoice_queue.put(checking_id)
await internal_invoice_queue.put(internal_payment.checking_id)
async def check_admin_settings():
@@ -762,7 +755,7 @@ async def check_admin_settings():
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.config and account.config.provider == "env":
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
logger.success(
@@ -813,53 +806,53 @@ async def init_admin_settings(super_user: Optional[str] = None) -> SuperSettings
if super_user:
account = await get_account(super_user)
if not account:
account = await create_account(
user_id=super_user, user_config=UserConfig(provider="env")
account_id = super_user or uuid4().hex
account = Account(
id=account_id,
extra=UserExtra(provider="env"),
)
if not account.wallets or len(account.wallets) == 0:
await create_account(account)
await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict())
return await create_admin_settings(account.id, editable_settings.dict())
async def create_user_account(
user_id: Optional[str] = None,
email: Optional[str] = None,
username: Optional[str] = None,
pubkey: Optional[str] = None,
password: Optional[str] = None,
wallet_name: Optional[str] = None,
user_config: Optional[UserConfig] = None,
account: Optional[Account] = None, wallet_name: Optional[str] = None
) -> User:
if not settings.new_accounts_allowed:
raise ValueError("Account creation is disabled.")
if username and await get_account_by_username(username):
raise ValueError("Username already exists.")
if account:
if account.username and await get_account_by_username(account.username):
raise ValueError("Username already exists.")
if email and await get_account_by_email(email):
raise ValueError("Email already exists.")
if account.email and await get_account_by_email(account.email):
raise ValueError("Email already exists.")
if user_id:
user_uuid4 = UUID(hex=user_id, version=4)
assert user_uuid4.hex == user_id, "User ID is not valid UUID4 hex string"
else:
user_id = uuid4().hex
if account.pubkey and await get_account_by_pubkey(account.pubkey):
raise ValueError("Pubkey already exists.")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password = pwd_context.hash(password) if password else None
if account.id:
user_uuid4 = UUID(hex=account.id, version=4)
assert user_uuid4.hex == account.id, "User ID is not valid UUID4 hex string"
else:
account.id = uuid4().hex
account = await create_account(
user_id, username, pubkey, email, password, user_config
account = await create_account(account)
await create_wallet(
user_id=account.id,
wallet_name=wallet_name or settings.lnbits_default_wallet_name,
)
wallet = await create_wallet(user_id=account.id, wallet_name=wallet_name)
account.wallets = [wallet]
for ext_id in settings.lnbits_user_default_extensions:
await update_user_extension(user_id=account.id, extension=ext_id, active=True)
user_ext = UserExtension(user=account.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
return account
user = await get_user(account)
assert user, "Cannot find user for account."
return user
class WebsocketConnectionManager:
@@ -914,12 +907,8 @@ async def update_pending_payments(wallet_id: str):
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.FAILED,
)
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
await update_payment_status(
checking_id=payment.checking_id,
status=PaymentState.SUCCESS,
)
payment.status = PaymentState.SUCCESS
await update_payment(payment)
+5 -7
View File
@@ -16,14 +16,14 @@
<q-item dense class="q-pa-none">
<q-item-section>
<q-item-label>
<strong>Wallet ID: </strong><em>{{ wallet.id }}</em>
<strong>Wallet ID: </strong><em v-text="wallet.id"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon
name="content_copy"
class="cursor-pointer"
@click="copyText('{{ wallet.id }}')"
@click="copyText(wallet.id)"
></q-icon>
</q-item-section>
</q-item>
@@ -32,7 +32,7 @@
<q-item-label>
<strong>Admin key: </strong
><em
v-text="adminkeyHidden ? '****************' : `{{ wallet.adminkey }}`"
v-text="adminkeyHidden ? '****************' : wallet.adminkey"
></em>
</q-item-label>
</q-item-section>
@@ -55,9 +55,7 @@
<q-item-section>
<q-item-label>
<strong>Invoice/read key: </strong
><em
v-text="inkeyHidden ? '****************' : `{{ wallet.inkey }}`"
></em>
><em v-text="inkeyHidden ? '****************' : wallet.inkey"></em>
</q-item-label>
</q-item-section>
<q-item-section side>
@@ -70,7 +68,7 @@
<q-icon
name="content_copy"
class="cursor-pointer q-ml-sm"
@click="copyText('{{ wallet.inkey }}')"
@click="copyText(wallet.inkey)"
></q-icon>
</div>
</q-item-section>
+10 -10
View File
@@ -38,9 +38,9 @@
</div>
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
@@ -133,9 +133,9 @@
<div class="row">
<div class="col">
<q-img
v-if="user.config.picture"
v-if="user.extra.picture"
style="max-width: 100px"
:src="user.config.picture"
:src="user.extra.picture"
class="float-right"
></q-img>
</div>
@@ -236,9 +236,9 @@
</div>
</q-card-section>
<q-card-section v-if="user.config">
<q-card-section v-if="user.extra">
<q-input
v-model="user.config.first_name"
v-model="user.extra.first_name"
:label="$t('first_name')"
filled
dense
@@ -246,7 +246,7 @@
>
</q-input>
<q-input
v-model="user.config.last_name"
v-model="user.extra.last_name"
:label="$t('last_name')"
filled
dense
@@ -254,7 +254,7 @@
>
</q-input>
<q-input
v-model="user.config.provider"
v-model="user.extra.provider"
:label="$t('auth_provider')"
filled
dense
@@ -263,7 +263,7 @@
>
</q-input>
<q-input
v-model="user.config.picture"
v-model="user.extra.picture"
:label="$t('picture')"
filled
class="q-mb-md"
@@ -470,7 +470,7 @@
v-model="reactionChoice"
:options="reactionOptions"
label="Reactions"
@input="reactionChoiceFunc"
@update:model-value="reactionChoiceFunc"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
+5 -7
View File
@@ -107,7 +107,7 @@
color="secondary"
style=""
v-model="extension.isActive"
@input="toggleExtension(extension)"
@update:model-value="toggleExtension(extension)"
><q-tooltip>
&nbsp;
<span
@@ -659,11 +659,9 @@
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div v-else class="col">
@@ -1060,7 +1058,7 @@
extension.inProgress = false
})
},
toggleExtension: function (extension) {
toggleExtension(extension) {
const action = extension.isActive ? 'activate' : 'deactivate'
LNbits.api
.request(
+9 -15
View File
@@ -6,7 +6,7 @@
<script src="{{ static_url_for('static', 'js/wallet.js') }}"></script>
{% endblock %}
<!---->
{% block title %} {{ wallet.name }} - {{ SITE_TITLE }} {% endblock %}
{% block title %}{{ wallet_name }} - {{ SITE_TITLE }} {% endblock %}
<!---->
{% block page %}
<div class="row q-col-gutter-md">
@@ -38,7 +38,6 @@
<strong v-text="formattedBalance"></strong>
<small>{{LNBITS_DENOMINATION}}</small>
<lnbits-update-balance
v-if="'{{user.super_user}}' == 'True'"
:wallet_id="this.g.wallet.id"
flat
:callback="updateBalanceCallback"
@@ -154,10 +153,7 @@
<q-card>
<q-card-section class="text-center">
<p v-text="$t('export_to_phone_desc')"></p>
<qrcode-vue
:value="'{{request.base_url}}wallet?usr={{user.id}}&wal={{wallet.id}}'"
:options="{ width: 256 }"
></qrcode-vue>
<lnbits-qrcode :value="exportUrl"></lnbits-qrcode>
</q-card-section>
<q-card-actions class="flex-center q-pb-md">
<q-btn
@@ -370,11 +366,9 @@
>
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + receive.paymentReq.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
@@ -627,8 +621,8 @@
<div v-else>
<q-responsive :ratio="1">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</q-responsive>
@@ -651,8 +645,8 @@
<q-card class="q-pa-lg q-pt-xl">
<div class="text-center q-mb-lg">
<qrcode-stream
@decode="decodeQR"
@init="onInitQR"
@detect="decodeQR"
@camera-on="onInitQR"
class="rounded-borders"
></qrcode-stream>
</div>
@@ -37,6 +37,7 @@
icon="content_copy"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy Wallet ID</q-tooltip>
@@ -45,6 +46,7 @@
v-if="!props.row.deleted"
:wallet_id="props.row.id"
:callback="topupCallback"
class="q-ml-xs"
></lnbits-update-balance>
<q-btn
round
@@ -52,6 +54,7 @@
icon="vpn_key"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.adminkey)"
>
<q-tooltip>Copy Admin Key</q-tooltip>
@@ -62,6 +65,7 @@
icon="vpn_key"
size="sm"
color="secondary"
class="q-ml-xs"
@click="copyText(props.row.inkey)"
>
<q-tooltip>Copy Invoice Key</q-tooltip>
@@ -72,6 +76,7 @@
icon="toggle_off"
size="sm"
color="secondary"
class="q-ml-xs"
@click="undeleteUserWallet(props.row.user, props.row.id)"
>
<q-tooltip>Undelete Wallet</q-tooltip>
@@ -81,6 +86,7 @@
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUserWallet(props.row.user, props.row.id, props.row.deleted)"
>
<q-tooltip>Delete Wallet</q-tooltip>
+8 -1
View File
@@ -61,6 +61,7 @@ include "users/_createWalletDialog.html" %}
icon="content_copy"
size="sm"
color="primary"
class="q-ml-xs"
@click="copyText(props.row.id)"
>
<q-tooltip>Copy User ID</q-tooltip>
@@ -71,6 +72,7 @@ include "users/_createWalletDialog.html" %}
icon="build"
size="sm"
:color="props.row.is_admin ? 'primary' : 'grey'"
class="q-ml-xs"
@click="toggleAdmin(props.row.id)"
>
<q-tooltip>Toggle Admin</q-tooltip>
@@ -81,6 +83,7 @@ include "users/_createWalletDialog.html" %}
icon="build"
size="sm"
color="positive"
class="q-ml-xs"
>
<q-tooltip>Super User</q-tooltip>
</q-btn>
@@ -98,6 +101,7 @@ include "users/_createWalletDialog.html" %}
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
@click="deleteUser(props.row.id, props)"
>
<q-tooltip>Delete User</q-tooltip>
@@ -111,7 +115,10 @@ include "users/_createWalletDialog.html" %}
<q-td auto-width v-text="props.row.transaction_count"></q-td>
<q-td auto-width v-text="props.row.username"></q-td>
<q-td auto-width v-text="props.row.email"></q-td>
<q-td auto-width v-text="props.row.last_payment"></q-td>
<q-td
auto-width
v-text="formatDate(props.row.last_payment)"
></q-td>
</q-tr>
</template>
</q-table>
+11 -20
View File
@@ -3,7 +3,6 @@ import json
from http import HTTPStatus
from io import BytesIO
from time import time
from typing import Dict, List
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import httpx
@@ -13,7 +12,7 @@ from fastapi import (
Depends,
)
from fastapi.exceptions import HTTPException
from starlette.responses import StreamingResponse
from fastapi.responses import StreamingResponse
from lnbits.core.models import (
BaseWallet,
@@ -40,10 +39,6 @@ from lnbits.utils.exchange_rates import (
from ..services import create_user_account, perform_lnurlauth
# backwards compatibility for extension
# TODO: remove api_payment and pay_invoice imports from extensions
from .payment_api import api_payment, pay_invoice # noqa: F401
api_router = APIRouter(tags=["Core"])
@@ -60,20 +55,16 @@ async def health() -> dict:
"/api/v1/wallets",
name="Wallets",
description="Get basic info for all of user's wallets.",
response_model=list[BaseWallet],
)
async def api_wallets(user: User = Depends(check_user_exists)) -> List[BaseWallet]:
return [BaseWallet(**w.dict()) for w in user.wallets]
async def api_wallets(user: User = Depends(check_user_exists)) -> list[Wallet]:
return user.wallets
@api_router.post("/api/v1/account", response_model=Wallet)
@api_router.post("/api/v1/account")
async def api_create_account(data: CreateWallet) -> Wallet:
if not settings.new_accounts_allowed:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Account creation is disabled.",
)
account = await create_user_account(wallet_name=data.name)
return account.wallets[0]
user = await create_user_account(wallet_name=data.name)
return user.wallets[0]
@api_router.get("/api/v1/lnurlscan/{code}")
@@ -101,7 +92,7 @@ async def api_lnurlscan(
) from exc
# params is what will be returned to the client
params: Dict = {"domain": domain}
params: dict = {"domain": domain}
if "tag=login" in url:
params.update(kind="auth")
@@ -150,7 +141,7 @@ async def api_lnurlscan(
# callback with k1 already in it
parsed_callback: ParseResult = urlparse(data["callback"])
qs: Dict = parse_qs(parsed_callback.query)
qs: dict = parse_qs(parsed_callback.query)
qs["k1"] = data["k1"]
# balanceCheck/balanceNotify
@@ -207,13 +198,13 @@ async def api_perform_lnurlauth(
@api_router.get("/api/v1/rate/{currency}")
async def api_check_fiat_rate(currency: str) -> Dict[str, float]:
async def api_check_fiat_rate(currency: str) -> dict[str, float]:
rate = await get_fiat_rate_satoshis(currency)
return {"rate": rate}
@api_router.get("/api/v1/currencies")
async def api_list_currencies_available() -> List[str]:
async def api_list_currencies_available() -> list[str]:
return allowed_currencies()
+204 -229
View File
@@ -1,19 +1,15 @@
import base64
import importlib
import json
from http import HTTPStatus
from time import time
from typing import Callable, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
from lnbits.core.services import create_user_account
from lnbits.decorators import access_token_payload, check_user_exists
@@ -31,16 +27,14 @@ from ..crud import (
get_account,
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_account_by_username_or_email,
get_user,
get_user_password,
update_account,
update_user_password,
update_user_pubkey,
verify_user_password,
)
from ..models import (
AccessTokenPayload,
Account,
CreateUser,
LoginUsernamePassword,
LoginUsr,
@@ -50,7 +44,7 @@ from ..models import (
UpdateUserPassword,
UpdateUserPubkey,
User,
UserConfig,
UserExtra,
)
auth_router = APIRouter(prefix="/api/v1/auth", tags=["Auth"])
@@ -65,65 +59,43 @@ async def get_auth_user(user: User = Depends(check_user_exists)) -> User:
async def login(data: LoginUsernamePassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Login by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Login by 'Username and Password' not allowed."
)
try:
user = await get_account_by_username_or_email(data.username)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
if not await verify_user_password(user.id, data.password):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(user.username, user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
account = await get_account_by_username_or_email(data.username)
if not account or not account.verify_password(data.password):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid credentials.")
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/nostr", description="Login via Nostr")
async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login with Nostr Auth not allowed.")
try:
event = _nostr_nip98_event(request)
user = await get_account_by_pubkey(event["pubkey"])
if not user:
user = await create_user_account(
pubkey=event["pubkey"], user_config=UserConfig(provider="nostr")
)
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except AssertionError as exc:
raise HTTPException(HTTP_401_UNAUTHORIZED, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Login with Nostr Auth not allowed."
)
event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"])
if not account:
account = Account(
id=uuid4().hex,
pubkey=event["pubkey"],
extra=UserExtra(provider="nostr"),
)
await create_user_account(account)
return _auth_success_response(account.username or "", account.id, account.email)
@auth_router.post("/usr", description="Login via the User ID")
async def login_usr(data: LoginUsr) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.user_id_only):
raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'User ID' not allowed.")
try:
user = await get_user(data.usr)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(user.username or "", user.id, user.email)
except HTTPException as exc:
raise exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.") from exc
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"Login by 'User ID' not allowed.",
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
return _auth_success_response(account.username, account.id, account.email)
@auth_router.get("/{provider}", description="SSO Provider")
@@ -133,7 +105,8 @@ async def login_with_sso_provider(
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
@@ -147,31 +120,22 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
provider_sso = _new_sso(provider)
if not provider_sso:
raise HTTPException(
HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
HTTPStatus.UNAUTHORIZED,
f"Login by '{provider}' not allowed.",
)
try:
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
assert userinfo is not None
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
except HTTPException as exc:
raise exc
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR,
f"Cannot authenticate user with {provider} Auth.",
) from exc
with provider_sso:
userinfo = await provider_sso.verify_and_process(request)
if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
@auth_router.post("/logout")
async def logout() -> JSONResponse:
response = JSONResponse({"status": "success"}, status_code=status.HTTP_200_OK)
response = JSONResponse({"status": "success"}, HTTPStatus.OK)
response.delete_cookie("cookie_access_token")
response.delete_cookie("is_lnbits_user_authorized")
response.delete_cookie("is_access_token_expired")
@@ -184,62 +148,32 @@ async def logout() -> JSONResponse:
async def register(data: CreateUser) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Register by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED,
"Register by 'Username and Password' not allowed.",
)
if data.password != data.password_repeat:
raise HTTPException(HTTP_400_BAD_REQUEST, "Passwords do not match.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Passwords do not match.")
if not data.username:
raise HTTPException(HTTP_400_BAD_REQUEST, "Missing username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Missing username.")
if not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
try:
user = await create_user_account(
email=data.email, username=data.username, password=data.password
)
return _auth_success_response(user.username, user.id, user.email)
except ValueError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot create user."
) from exc
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
try:
if data.username and not user.username:
await update_account(user_id=user.id, username=data.username)
# old accounts do not have a pasword
if await get_user_password(data.user_id):
assert data.password_old, "Missing old password"
old_pwd_ok = await verify_user_password(data.user_id, data.password_old)
assert old_pwd_ok, "Invalid credentials."
return await update_user_password(data, payload.auth_time or 0)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user password."
) from exc
account = Account(
id=uuid4().hex,
email=data.email,
username=data.username,
)
account.hash_password(data.password)
await create_user_account(account)
return _auth_success_response(account.username, account.id, account.email)
@auth_router.put("/pubkey")
@@ -249,62 +183,89 @@ async def update_pubkey(
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
try:
data.pubkey = normalize_public_key(data.pubkey)
return await update_user_pubkey(data, payload.auth_time or 0)
_validate_auth_timeout(payload.auth_time)
if (
data.pubkey
and data.pubkey != user.pubkey
and await get_account_by_pubkey(data.pubkey)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Public key already in use.")
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user pubkey."
) from exc
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
account.pubkey = normalize_public_key(data.pubkey)
await update_account(account)
return await get_user(account)
@auth_router.put("/password")
async def update_password(
data: UpdateUserPassword,
user: User = Depends(check_user_exists),
payload: AccessTokenPayload = Depends(access_token_payload),
) -> Optional[User]:
_validate_auth_timeout(payload.auth_time)
assert data.user_id == user.id, "Invalid user ID."
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(user.id)
assert account, "Account not found."
# old accounts do not have a password
if account.password_hash:
assert data.password_old, "Missing old password."
assert account.verify_password(data.password_old), "Invalid old password."
account.username = data.username
account.hash_password(data.password)
await update_account(account)
_user = await get_user(account)
if not _user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return _user
@auth_router.put("/reset")
async def reset_password(data: ResetUserPassword) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.username_and_password):
raise HTTPException(
HTTP_401_UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
HTTPStatus.UNAUTHORIZED, "Auth by 'Username and Password' not allowed."
)
assert data.password == data.password_repeat, "Passwords do not match."
assert data.reset_key[:10].startswith("reset_key_"), "This is not a reset key."
try:
assert data.reset_key[:10] == "reset_key_", "This is not a reset key."
reset_data_json = decrypt_internal_message(
base64.b64decode(data.reset_key[10:]).decode()
)
assert reset_data_json, "Cannot process reset key."
action, user_id, request_time = json.loads(reset_data_json)
assert action == "reset", "Expected reset action."
assert user_id is not None, "Missing user ID."
assert request_time is not None, "Missing reset time."
user = await get_account(user_id)
assert user, "User not found."
update_pwd = UpdateUserPassword(
user_id=user.id,
username=user.username or "",
password=data.password,
password_repeat=data.password_repeat,
)
user = await update_user_password(update_pwd, request_time)
return _auth_success_response(
username=user.username, user_id=user_id, email=user.email
)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
reset_key = base64.b64decode(data.reset_key[10:]).decode()
reset_data_json = decrypt_internal_message(reset_key)
except Exception as exc:
logger.warning(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot reset user password."
) from exc
raise ValueError("Invalid reset key.") from exc
assert reset_data_json, "Cannot process reset key."
action, user_id, request_time = json.loads(reset_data_json)
assert action, "Missing action."
assert user_id, "Missing user ID."
assert request_time, "Missing reset time."
_validate_auth_timeout(request_time)
account = await get_account(user_id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
account.hash_password(data.password)
await update_account(account)
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update")
@@ -312,80 +273,83 @@ async def update(
data: UpdateUser, user: User = Depends(check_user_exists)
) -> Optional[User]:
if data.user_id != user.id:
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid user ID.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid user ID.")
if data.username and not is_valid_username(data.username):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid username.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if data.email != user.email:
raise HTTPException(HTTP_400_BAD_REQUEST, "Email mismatch.")
try:
return await update_account(user.id, data.username, None, data.config)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot update user."
) from exc
HTTPStatus.BAD_REQUEST,
"Email mismatch.",
)
if (
data.username
and user.username != data.username
and await get_account_by_username(data.username)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if (
data.email
and data.email != user.email
and await get_account_by_email(data.email)
):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Email already exists.")
account = await get_account(user.id)
if not account:
raise HTTPException(HTTPStatus.NOT_FOUND, "Account not found.")
if data.username:
account.username = data.username
if data.email:
account.email = data.email
if data.extra:
account.extra = data.extra
await update_account(account)
return await get_user(account)
@auth_router.put("/first_install")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
raise HTTPException(HTTP_401_UNAUTHORIZED, "This is not your first install")
try:
await update_account(
user_id=settings.super_user,
username=data.username,
user_config=UserConfig(provider="lnbits"),
)
super_user = UpdateUserPassword(
user_id=settings.super_user,
password=data.password,
password_repeat=data.password_repeat,
username=data.username,
)
user = await update_user_password(super_user, int(time()))
settings.first_install = False
return _auth_success_response(user.username, user.id, user.email)
except AssertionError as exc:
raise HTTPException(HTTP_403_FORBIDDEN, str(exc)) from exc
except Exception as exc:
logger.debug(exc)
raise HTTPException(
HTTP_500_INTERNAL_SERVER_ERROR, "Cannot init user password."
) from exc
raise HTTPException(HTTPStatus.UNAUTHORIZED, "This is not your first install")
account = await get_account(settings.super_user)
if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username
account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits"
account.hash_password(data.password)
await update_account(account)
settings.first_install = False
return _auth_success_response(account.username, account.id, account.email)
async def _handle_sso_login(userinfo: OpenID, verified_user_id: Optional[str] = None):
email = userinfo.email
if not email or not is_valid_email_address(email):
raise HTTPException(HTTP_400_BAD_REQUEST, "Invalid email.")
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet"
user_config = UserConfig(**dict(userinfo))
user_config.email_verified = True
account = await get_account_by_email(email)
if verified_user_id:
if account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Email already used.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Email already used.")
account = await get_account(verified_user_id)
if not account:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Cannot verify user email.")
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Cannot verify user email.")
redirect_path = "/account"
if account:
user = await update_account(account.id, email=email, user_config=user_config)
account.extra = account.extra or UserExtra()
account.extra.email_verified = True
await update_account(account)
else:
if not settings.new_accounts_allowed:
raise HTTPException(HTTP_400_BAD_REQUEST, "Account creation is disabled.")
user = await create_user_account(email=email, user_config=user_config)
if not user:
raise HTTPException(HTTP_401_UNAUTHORIZED, "User not found.")
account = Account(
id=uuid4().hex, email=email, extra=UserExtra(email_verified=True)
)
await create_user_account(account)
return _auth_redirect_response(redirect_path, email)
@@ -461,23 +425,23 @@ def _find_auth_provider_class(provider: str) -> Callable:
def _nostr_nip98_event(request: Request) -> dict:
auth_header = request.headers.get("Authorization")
assert auth_header, "Nostr Auth header missing."
if not auth_header:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Nostr Auth header missing.")
scheme, token = auth_header.split()
assert scheme.lower() == "nostr", "Authorization header is not nostr."
if scheme.lower() != "nostr":
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid Authorization scheme.")
event = None
try:
event_json = base64.b64decode(token.encode("ascii"))
event = json.loads(event_json)
except Exception as exc:
logger.warning(exc)
assert event, "Nostr login event cannot be parsed."
assert verify_event(event), "Nostr login event is not valid."
if not verify_event(event):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Nostr login event is not valid.")
assert event["kind"] == 27_235, "Invalid event kind."
auth_threshold = settings.auth_credetials_update_threshold
assert (
abs(time() - event["created_at"]) < auth_threshold
@@ -485,11 +449,22 @@ def _nostr_nip98_event(request: Request) -> dict:
method: Optional[str] = next((v for k, v in event["tags"] if k == "method"), None)
assert method, "Tag 'method' is missing."
assert method.upper() == "POST", "Incorrect value for tag 'method'."
assert method.upper() == "POST", "Invalid value for tag 'method'."
url = next((v for k, v in event["tags"] if k == "u"), None)
assert url, "Tag 'u' for URL is missing."
accepted_urls = [f"{u}/nostr" for u in settings.nostr_absolute_request_urls]
assert url in accepted_urls, f"Incorrect value for tag 'u': '{url}'."
assert url in accepted_urls, f"Invalid value for tag 'u': '{url}'."
return event
def _validate_auth_timeout(auth_time: Optional[int] = 0):
if abs(time() - (auth_time or 0)) > settings.auth_credetials_update_threshold:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"You can only update your credentials in the first"
f" {settings.auth_credetials_update_threshold} seconds."
" Please login again or ask a new reset key!",
)
+124 -104
View File
@@ -1,7 +1,4 @@
from http import HTTPStatus
from typing import (
List,
)
from bolt11 import decode as bolt11_decode
from fastapi import (
@@ -21,10 +18,12 @@ from lnbits.core.extensions.models import (
CreateExtension,
Extension,
ExtensionConfig,
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtension,
UserExtensionInfo,
)
from lnbits.core.models import (
@@ -38,15 +37,15 @@ from lnbits.decorators import (
)
from ..crud import (
create_user_extension,
delete_dbversion,
drop_extension_db,
get_dbversions,
get_db_version,
get_installed_extension,
get_installed_extensions,
get_user_extension,
update_extension_pay_to_enable,
update_installed_extension,
update_user_extension,
update_user_extension_extra,
)
extension_router = APIRouter(
@@ -71,8 +70,13 @@ async def api_install_extension(data: CreateExtension):
)
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
id=data.ext_id,
name=data.ext_id,
version=data.version,
meta=ext_meta,
icon=release.icon,
)
try:
@@ -109,33 +113,28 @@ async def api_install_extension(data: CreateExtension):
) from exc
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
@extension_router.get("/{ext_id}/details")
async def api_extension_details(
ext_id: str,
details_link: str,
):
all_releases = await InstallableExtension.get_extension_releases(ext_id)
try:
all_releases = await InstallableExtension.get_extension_releases(ext_id)
release = next(
(r for r in all_releases if r.details_link == details_link), None
)
assert release, "Details not found for release"
release_details = await ExtensionRelease.fetch_release_details(details_link)
assert release_details, "Cannot fetch details for release"
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
release = next((r for r in all_releases if r.details_link == details_link), None)
if not release:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR,
f"Failed to get details for extension {ext_id}.",
) from exc
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
)
release_details = await ExtensionRelease.fetch_release_details(details_link)
if not release_details:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot fetch details for release",
)
release_details["icon"] = release.icon
release_details["repo"] = release.repo
return release_details
@extension_router.put("/{ext_id}/sell")
@@ -144,22 +143,21 @@ async def api_update_pay_to_enable(
data: PayToEnableInfo,
user: User = Depends(check_admin),
) -> SimpleStatus:
try:
assert (
data.wallet in user.wallet_ids
), "Wallet does not belong to this admin user."
await update_extension_pay_to_enable(ext_id, data)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
if data.wallet not in user.wallet_ids:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
) from exc
HTTPStatus.BAD_REQUEST, "Wallet does not belong to this admin user."
)
extension = await get_installed_extension(ext_id)
if not extension:
raise HTTPException(HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' not found.")
if extension.meta:
extension.meta.pay_to_enable = data
else:
extension.meta = ExtensionMeta(pay_to_enable=data)
await update_installed_extension(extension)
return SimpleStatus(
success=True, message=f"Payment info updated for '{ext_id}' extension."
)
@extension_router.put("/{ext_id}/enable")
@@ -176,28 +174,34 @@ async def api_enable_extension(
assert ext, f"Extension '{ext_id}' is not installed."
assert ext.active, f"Extension '{ext_id}' is not activated."
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
if user.admin or not ext.requires_payment:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
user_ext = await get_user_extension(user.id, ext_id)
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
)
if user_ext.is_paid:
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(
success=True, message=f"Paid extension '{ext_id}' enabled."
)
assert (
ext.pay_to_enable and ext.pay_to_enable.wallet
ext.meta and ext.meta.pay_to_enable and ext.meta.pay_to_enable.wallet
), f"Extension '{ext_id}' is missing payment wallet."
payment_status = await check_transaction_status(
wallet_id=ext.pay_to_enable.wallet,
wallet_id=ext.meta.pay_to_enable.wallet,
payment_hash=user_ext.extra.payment_hash_to_enable,
)
@@ -207,10 +211,9 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
)
user_ext.active = True
user_ext.extra.paid_to_enable = True
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
except AssertionError as exc:
@@ -233,16 +236,15 @@ async def api_disable_extension(
raise HTTPException(
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
)
try:
logger.info(f"Disabeling extension: {ext_id}.")
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=(f"Failed to disable '{ext_id}'."),
) from exc
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext or not user_ext.active:
return SimpleStatus(
success=True, message=f"Extension '{ext_id}' already disabled."
)
logger.info(f"Disabeling extension: {ext_id}.")
user_ext.active = False
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
@@ -298,7 +300,11 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
installed_ext = next(
(ext for ext in installed_extensions if ext.id == valid_ext_id), None
)
if installed_ext and ext_id in installed_ext.dependencies:
if (
installed_ext
and installed_ext.meta
and ext_id in installed_ext.meta.dependencies
):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
@@ -319,9 +325,9 @@ async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
async def get_extension_releases(ext_id: str) -> list[ExtensionRelease]:
try:
extension_releases: List[ExtensionRelease] = (
extension_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
@@ -386,45 +392,59 @@ async def get_pay_to_install_invoice(
async def get_pay_to_enable_invoice(
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
):
try:
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
ext = await get_installed_extension(ext_id)
assert ext, f"Extension '{ext_id}' not found."
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
assert (
ext.pay_to_enable.required
), f"Payment not required for extension '{ext_id}'."
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
f"Payment wallet or amount missing for extension '{ext_id}'."
"Please contact the administrator."
)
assert (
data.amount >= ext.pay_to_enable.amount
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
payment_hash, payment_request = await create_invoice(
wallet_id=ext.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
user_ext_info = (
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
)
user_ext_info.payment_hash_to_enable = payment_hash
await update_user_extension_extra(user.id, ext_id, user_ext_info)
return {"payment_hash": payment_hash, "payment_request": payment_request}
except AssertionError as exc:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
except Exception as exc:
logger.warning(exc)
if not data.amount or data.amount <= 0:
raise HTTPException(
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
) from exc
status_code=HTTPStatus.BAD_REQUEST, detail="Amount must be greater than 0."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail=f"Extension '{ext_id}' not found."
)
if not ext.meta or not ext.meta.pay_to_enable:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment info not found for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.required:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment not required for extension '{ext_id}'.",
)
if not ext.meta.pay_to_enable.wallet or not ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Payment wallet or amount missing for extension '{ext_id}'.",
)
if data.amount < ext.meta.pay_to_enable.amount:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Amount {data.amount} sats is less than required "
f"{ext.meta.pay_to_enable.amount} sats."
),
)
payment_hash, payment_request = await create_invoice(
wallet_id=ext.meta.pay_to_enable.wallet,
amount=data.amount,
memo=f"Enable '{ext.name}' extension.",
)
user_ext = await get_user_extension(user.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
user_ext_info = user_ext.extra if user_ext.extra else UserExtensionInfo()
user_ext_info.payment_hash_to_enable = payment_hash
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return {"payment_hash": payment_hash, "payment_request": payment_request}
@extension_router.get(
@@ -454,7 +474,7 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
db_version = await get_db_version(ext_id)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
+97 -95
View File
@@ -9,13 +9,12 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.routing import APIRouter
from lnurl import decode as lnurl_decode
from loguru import logger
from pydantic.types import UUID4
from lnbits.core.extensions.models import Extension, InstallableExtension
from lnbits.core.extensions.models import Extension, ExtensionMeta, InstallableExtension
from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User
from lnbits.core.services import create_invoice
from lnbits.core.services import create_invoice, create_user_account
from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer
from lnbits.settings import settings
@@ -23,11 +22,11 @@ from lnbits.wallets import get_funding_source
from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_account,
create_wallet,
get_dbversions,
get_db_versions,
get_installed_extensions,
get_user,
get_user_by_id,
get_wallet,
)
generic_router = APIRouter(
@@ -74,83 +73,87 @@ async def robots():
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installed_exts: List[InstallableExtension] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [
e for e in installed_exts if e.id not in installable_exts_ids
]
installable_exts = await InstallableExtension.get_installable_extensions()
installable_exts_ids = [e.id for e in installable_exts]
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext:
e.installed_release = installed_ext.installed_release
if installed_ext.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable
for e in installable_exts:
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext and installed_ext.meta:
installed_release = installed_ext.meta.installed_release
if installed_ext.meta.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.meta.pay_to_enable.wallet = None
pay_to_enable = installed_ext.meta.pay_to_enable
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
if e.meta:
e.meta.installed_release = installed_release
e.meta.pay_to_enable = pay_to_enable
else:
e.meta = ExtensionMeta(
installed_release=installed_release,
pay_to_enable=pay_to_enable,
)
# use the installed extension values
e.name = installed_ext.name
e.short_description = installed_ext.short_description
e.icon = installed_ext.icon
except Exception as ex:
logger.warning(ex)
installable_exts = []
installed_exts_ids = []
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [e.id for e in await get_installed_extensions(active=False)]
db_versions = await get_db_versions()
try:
all_ext_ids = [ext.code for ext in Extension.get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
db_version = await get_dbversions()
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.featured,
"dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.latest_release) if ext.latest_release else None
),
"installedRelease": (
dict(ext.installed_release) if ext.installed_release else None
),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
extensions = [
{
"id": ext.id,
"name": ext.name,
"icon": ext.icon,
"shortDescription": ext.short_description,
"stars": ext.stars,
"isFeatured": ext.meta.featured if ext.meta else False,
"dependencies": ext.meta.dependencies if ext.meta else "",
"isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": next(
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release
else None
),
"installedRelease": (
dict(ext.meta.installed_release)
if ext.meta and ext.meta.installed_release
else None
),
"payToEnable": (
dict(ext.meta.pay_to_enable)
if ext.meta and ext.meta.pay_to_enable
else {}
),
"isPaymentRequired": ext.requires_payment,
}
for ext in installable_exts
]
# refresh user state. Eg: enabled extensions.
user = await get_user(user.id) or user
# refresh user state. Eg: enabled extensions.
# TODO: refactor
# user = await get_user(user.id) or user
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.dict(),
"extensions": extensions,
},
)
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return template_renderer().TemplateResponse(
request,
"core/extensions.html",
{
"user": user.json(),
"extensions": extensions,
},
)
@generic_router.get(
@@ -165,18 +168,16 @@ async def wallet(
wal: Optional[UUID4] = Query(None),
):
if wal:
wallet_id = wal.hex
wallet = await get_wallet(wal.hex)
elif len(user.wallets) == 0:
wallet = await create_wallet(user_id=user.id)
user = await get_user(user_id=user.id) or user
wallet_id = wallet.id
user.wallets.append(wallet)
elif lnbits_last_active_wallet and user.get_wallet(lnbits_last_active_wallet):
wallet_id = lnbits_last_active_wallet
wallet = await get_wallet(lnbits_last_active_wallet)
else:
wallet_id = user.wallets[0].id
wallet = user.wallets[0]
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
if not wallet or wallet.deleted:
return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
)
@@ -185,15 +186,16 @@ async def wallet(
request,
"core/wallet.html",
{
"user": user.dict(),
"wallet": user_wallet.dict(),
"user": user.json(),
"wallet": wallet.json(),
"wallet_name": wallet.name,
"currencies": allowed_currencies(),
"service_fee": settings.lnbits_service_fee,
"service_fee_max": settings.lnbits_service_fee_max,
"web_manifest": f"/manifest/{user.id}.webmanifest",
},
)
resp.set_cookie("lnbits_last_active_wallet", wallet_id)
resp.set_cookie("lnbits_last_active_wallet", wallet.id)
return resp
@@ -209,7 +211,9 @@ async def account(
return template_renderer().TemplateResponse(
request,
"core/account.html",
{"user": user.dict()},
{
"user": user.json(),
},
)
@@ -228,11 +232,9 @@ async def service_worker(request: Request):
@generic_router.get("/manifest/{usr}.webmanifest")
async def manifest(request: Request, usr: str):
host = urlparse(str(request.url)).netloc
user = await get_user(usr)
user = await get_user_by_id(usr)
if not user:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
return {
"short_name": settings.lnbits_site_title,
"name": settings.lnbits_site_title + " Wallet",
@@ -320,10 +322,10 @@ async def node(request: Request, user: User = Depends(check_admin)):
request,
"node/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"wallets": user.wallets[0].dict(),
"wallets": user.wallets[0].json(),
},
)
@@ -358,7 +360,7 @@ async def admin_index(request: Request, user: User = Depends(check_admin)):
request,
"admin/index.html",
{
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"balance": balance,
"currencies": list(currencies.keys()),
@@ -375,7 +377,7 @@ async def users_index(request: Request, user: User = Depends(check_admin)):
"users/index.html",
{
"request": request,
"user": user.dict(),
"user": user.json(),
"settings": settings.dict(),
"currencies": list(currencies.keys()),
},
@@ -424,7 +426,7 @@ async def lnurlwallet(request: Request):
status_code=HTTPStatus.BAD_REQUEST,
detail="Invalid lnurl. Expected maxWithdrawable",
)
account = await create_account()
account = await create_user_account()
wallet = await create_wallet(user_id=account.id)
_, payment_request = await create_invoice(
wallet_id=wallet.id,
+59 -86
View File
@@ -5,7 +5,7 @@ from http import HTTPStatus
from typing import List
from fastapi import APIRouter, Depends
from starlette.exceptions import HTTPException
from fastapi.exceptions import HTTPException
from lnbits.core.crud import (
delete_account,
@@ -17,8 +17,8 @@ from lnbits.core.crud import (
update_admin_settings,
)
from lnbits.core.models import (
Account,
AccountFilters,
AccountOverview,
CreateTopup,
User,
Wallet,
@@ -40,42 +40,33 @@ users_router = APIRouter(prefix="/users/api/v1", dependencies=[Depends(check_adm
)
async def api_get_users(
filters: Filters = Depends(parse_filters(AccountFilters)),
) -> Page[Account]:
try:
filtered = await get_accounts(filters=filters)
for user in filtered.data:
user.is_super_user = user.id == settings.super_user
user.is_admin = user.id in settings.lnbits_admin_users or user.is_super_user
return filtered
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch users. {exc!s}",
) from exc
) -> Page[AccountOverview]:
return await get_accounts(filters=filters)
@users_router.delete("/user/{user_id}", status_code=HTTPStatus.OK)
async def api_users_delete_user(
user_id: str, user: User = Depends(check_admin)
) -> None:
try:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise Exception("Cannot delete user with wallets.")
if user_id == settings.super_user:
raise Exception("Cannot delete super user.")
if user_id in settings.lnbits_admin_users and not user.super_user:
raise Exception("Only super_user can delete admin user.")
await delete_account(user_id)
except Exception as exc:
wallets = await get_wallets(user_id)
if len(wallets) > 0:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete user with wallets.",
)
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot delete super user.",
)
if user_id in settings.lnbits_admin_users and not user.super_user:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Only super_user can delete admin user.",
)
await delete_account(user_id)
@users_router.put(
@@ -98,66 +89,53 @@ async def api_users_reset_password(user_id: str) -> str:
@users_router.get("/user/{user_id}/admin", dependencies=[Depends(check_super_user)])
async def api_users_toggle_admin(user_id: str) -> None:
try:
if user_id == settings.super_user:
raise Exception("Cannot change super user.")
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(
lnbits_admin_users=settings.lnbits_admin_users
)
await update_admin_settings(update_settings)
except Exception as exc:
if user_id == settings.super_user:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not update admin settings. {exc}",
) from exc
status_code=HTTPStatus.BAD_REQUEST,
detail="Cannot change super user.",
)
if user_id in settings.lnbits_admin_users:
settings.lnbits_admin_users.remove(user_id)
else:
settings.lnbits_admin_users.append(user_id)
update_settings = EditableSettings(lnbits_admin_users=settings.lnbits_admin_users)
await update_admin_settings(update_settings)
@users_router.get("/user/{user_id}/wallet")
async def api_users_get_user_wallet(user_id: str) -> List[Wallet]:
try:
return await get_wallets(user_id)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Could not fetch user wallets. {exc}",
) from exc
return await get_wallets(user_id)
@users_router.get("/user/{user_id}/wallet/{wallet}/undelete")
async def api_users_undelete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
if not wal:
raise Exception("Wallet does not exist.")
if user_id != wal.user:
raise Exception("Wallet does not belong to user.")
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
except Exception as exc:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if user_id != wal.user:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN,
detail="Wallet does not belong to user.",
)
if wal.deleted:
await delete_wallet(user_id=user_id, wallet_id=wallet, deleted=False)
@users_router.delete("/user/{user_id}/wallet/{wallet}")
async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
try:
wal = await get_wallet(wallet)
if not wal:
raise Exception("Wallet does not exist.")
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
except Exception as exc:
wal = await get_wallet(wallet)
if not wal:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"{exc!s}",
) from exc
status_code=HTTPStatus.NOT_FOUND,
detail="Wallet does not exist.",
)
if wal.deleted:
await force_delete_wallet(wallet)
await delete_wallet(user_id=user_id, wallet_id=wallet)
@users_router.put(
@@ -167,14 +145,9 @@ async def api_users_delete_user_wallet(user_id: str, wallet: str) -> None:
dependencies=[Depends(check_super_user)],
)
async def api_topup_balance(data: CreateTopup) -> dict[str, str]:
try:
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await get_wallet(data.id)
if settings.lnbits_backend_wallet_class == "VoidWallet":
raise Exception("VoidWallet active")
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=f"{exc!s}"
) from exc
await update_wallet_balance(wallet_id=data.id, amount=int(data.amount))
return {"status": "Success"}
+30 -17
View File
@@ -1,9 +1,11 @@
from http import HTTPStatus
from typing import Optional
from fastapi import (
APIRouter,
Body,
Depends,
HTTPException,
)
from lnbits.core.models import (
@@ -20,6 +22,7 @@ from lnbits.decorators import (
from ..crud import (
create_wallet,
delete_wallet,
get_wallet,
update_wallet,
)
@@ -27,35 +30,45 @@ wallet_router = APIRouter(prefix="/api/v1/wallet", tags=["Wallet"])
@wallet_router.get("")
async def api_wallet(wallet: WalletTypeInfo = Depends(require_invoice_key)):
async def api_wallet(key_info: WalletTypeInfo = Depends(require_invoice_key)):
res = {
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
"name": key_info.wallet.name,
"balance": key_info.wallet.balance_msat,
}
if wallet.key_type == KeyType.admin:
res["id"] = wallet.wallet.id
if key_info.key_type == KeyType.admin:
res["id"] = key_info.wallet.id
return res
@wallet_router.put("/{new_name}")
async def api_update_wallet_name(
new_name: str, wallet: WalletTypeInfo = Depends(require_admin_key)
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
):
await update_wallet(wallet.wallet.id, new_name)
wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = new_name
await update_wallet(wallet)
return {
"id": wallet.wallet.id,
"name": wallet.wallet.name,
"balance": wallet.wallet.balance_msat,
"id": wallet.id,
"name": wallet.name,
"balance": wallet.balance_msat,
}
@wallet_router.patch("", response_model=Wallet)
@wallet_router.patch("")
async def api_update_wallet(
name: Optional[str] = Body(None),
currency: Optional[str] = Body(None),
wallet: WalletTypeInfo = Depends(require_admin_key),
):
return await update_wallet(wallet.wallet.id, name, currency)
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
wallet = await get_wallet(key_info.wallet.id)
if not wallet:
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
wallet.name = name or wallet.name
wallet.currency = currency if currency is not None else wallet.currency
await update_wallet(wallet)
return wallet
@wallet_router.delete("")
@@ -68,9 +81,9 @@ async def api_delete_wallet(
)
@wallet_router.post("", response_model=Wallet)
@wallet_router.post("")
async def api_create_wallet(
data: CreateWallet,
wallet: WalletTypeInfo = Depends(require_admin_key),
key_info: WalletTypeInfo = Depends(require_admin_key),
) -> Wallet:
return await create_wallet(user_id=wallet.wallet.user, wallet_name=data.name)
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
+170 -27
View File
@@ -2,12 +2,13 @@ from __future__ import annotations
import asyncio
import datetime
import json
import os
import re
import time
from contextlib import asynccontextmanager
from enum import Enum
from typing import Any, Generic, Literal, Optional, TypeVar
from typing import Any, Generic, Literal, Optional, TypeVar, Union
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -144,29 +145,59 @@ class Connection(Compat):
clean_values[key] = raw_value
return clean_values
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().all()
result.close()
if not row:
return []
if model:
return [dict_to_model(r, model) for r in row]
return row
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
row = result.mappings().first()
result.close()
if model and row:
return dict_to_model(row, model)
return row
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
):
await self.conn.execute(
text(update_query(table_name, model, where)), model_to_dict(model)
)
await self.conn.commit()
async def insert(self, table_name: str, model: BaseModel):
await self.conn.execute(
text(insert_query(table_name, model)), model_to_dict(model)
)
await self.conn.commit()
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
if not filters:
filters = Filters()
clause = filters.where(where)
@@ -190,11 +221,12 @@ class Connection(Compat):
{filters.pagination()}
""",
self.rewrite_values(parsed_values),
model,
)
if rows:
# no need for extra query if no pagination is specified
if filters.offset or filters.limit:
result = await self.fetchone(
result = await self.execute(
f"""
SELECT COUNT(*) as count FROM (
{query}
@@ -204,14 +236,16 @@ class Connection(Compat):
""",
parsed_values,
)
count = int(result.get("count", 0))
row = result.mappings().first()
result.close()
count = int(row.get("count", 0))
else:
count = len(rows)
else:
count = 0
return Page(
data=[model.from_row(row) for row in rows] if model else [],
data=rows,
total=count,
)
@@ -251,21 +285,19 @@ class Database(Compat):
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_timestamp(value):
def _parse_date(value) -> datetime.datetime:
if value is None:
return None
value = "1970-01-01 00:00:00"
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())
)
return datetime.datetime.strptime(value, f)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime.datetime,
decoder=_parse_timestamp,
decoder=_parse_date,
schema="pg_catalog",
)
)
@@ -296,13 +328,33 @@ class Database(Compat):
finally:
self.lock.release()
async def fetchall(self, query: str, values: Optional[dict] = None) -> list[dict]:
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> list[TModel]:
async with self.connect() as conn:
return await conn.fetchall(query, values)
return await conn.fetchall(query, values, model)
async def fetchone(self, query: str, values: Optional[dict] = None) -> dict:
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
) -> TModel:
async with self.connect() as conn:
return await conn.fetchone(query, values)
return await conn.fetchone(query, values, model)
async def insert(self, table_name: str, model: BaseModel) -> None:
async with self.connect() as conn:
await conn.insert(table_name, model)
async def update(
self, table_name: str, model: BaseModel, where: str = "WHERE id = :id"
) -> None:
async with self.connect() as conn:
await conn.update(table_name, model, where)
async def fetch_page(
self,
@@ -310,9 +362,9 @@ class Database(Compat):
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TRowModel]] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
) -> Page[TRowModel]:
) -> Page[TModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
@@ -372,12 +424,6 @@ class Operator(Enum):
raise ValueError("Unknown SQL Operator")
class FromRowModel(BaseModel):
@classmethod
def from_row(cls, row: dict):
return cls(**row)
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None
@@ -385,7 +431,6 @@ class FilterModel(BaseModel):
T = TypeVar("T")
TModel = TypeVar("TModel", bound=BaseModel)
TRowModel = TypeVar("TRowModel", bound=FromRowModel)
TFilterModel = TypeVar("TFilterModel", bound=FilterModel)
@@ -518,3 +563,101 @@ class Filters(BaseModel, Generic[TFilterModel]):
if self.search and self.model:
values["search"] = f"%{self.search}%"
return values
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = []
keys = model_to_dict(model).keys()
for field in keys:
placeholders.append(get_placeholder(model, field))
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
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
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = :id`
"""
fields = []
for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
def model_to_dict(model: BaseModel) -> dict:
"""
Convert a Pydantic model to a dictionary with JSON-encoded nested models
private fields starting with _ are ignored
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in model.dict().items():
type_ = model.__fields__[key].type_
if model.__fields__[key].field_info.extra.get("no_database", False):
continue
if isinstance(value, datetime.datetime):
_dict[key] = value.timestamp()
continue
if type(type_) is type(BaseModel):
_dict[key] = json.dumps(value)
continue
_dict[key] = value
return _dict
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null":
return None
_subdict = json.loads(value)
elif isinstance(value, dict):
_subdict = value
else:
logger.warning(f"Expected str or dict, got {type(value)}")
return None
# recursively convert nested models
return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
"""
Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database
:param model: Pydantic model
"""
_dict: dict = {}
for key, value in _row.items():
if key not in model.__fields__:
logger.warning(f"Converting {key} to model `{model}`.")
continue
type_ = model.__fields__[key].type_
if issubclass(type_, bool):
_dict[key] = bool(value)
continue
if issubclass(type_, BaseModel) and value:
_dict[key] = dict_to_submodel(type_, value)
continue
if type_ is dict and value:
_dict[key] = json.loads(value)
continue
_dict[key] = value
continue
_model = model.construct(**_dict)
return _model
+11 -8
View File
@@ -20,6 +20,7 @@ from lnbits.core.crud import (
)
from lnbits.core.models import (
AccessTokenPayload,
Account,
KeyType,
SimpleStatus,
User,
@@ -65,7 +66,7 @@ class KeyChecker(SecurityBase):
name="X-API-KEY",
description="Wallet API Key - HEADER",
)
self.model: APIKey = openapi_model
self.model: APIKey = openapi_model # type: ignore
async def __call__(self, request: Request) -> WalletTypeInfo:
@@ -144,14 +145,16 @@ async def check_user_exists(
else:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing user ID or access token.")
if not account or not settings.is_user_allowed(account.id):
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
if not settings.is_user_allowed(account.id):
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not allowed.")
user = await get_user(account.id)
assert user, "User not found for account."
user = await get_user(account)
if not user:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User not found.")
await _check_user_extension_access(user.id, r["path"])
return user
@@ -261,7 +264,7 @@ async def _check_user_extension_access(user_id: str, current_path: str):
)
async def _get_account_from_token(access_token) -> Optional[User]:
async def _get_account_from_token(access_token) -> Optional[Account]:
try:
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
user = await _get_user_from_jwt_payload(payload)
@@ -281,7 +284,7 @@ async def _get_account_from_token(access_token) -> Optional[User]:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid access token.") from exc
async def _get_user_from_jwt_payload(payload) -> Optional[User]:
async def _get_user_from_jwt_payload(payload) -> Optional[Account]:
if "sub" in payload and payload.get("sub"):
return await get_account_by_username(str(payload.get("sub")))
if "usr" in payload and payload.get("usr"):
+18 -16
View File
@@ -23,14 +23,6 @@ class InvoiceError(Exception):
self.status = status
def register_exception_handlers(app: FastAPI):
register_exception_handler(app)
register_request_validation_exception_handler(app)
register_http_exception_handler(app)
register_payment_error_handler(app)
register_invoice_error_handler(app)
def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
# Only the browser sends "text/html" request
# not fail proof, but everything else get's a JSON response
@@ -63,7 +55,9 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
return None
def register_exception_handler(app: FastAPI):
def register_exception_handlers(app: FastAPI):
"""Register exception handlers for the FastAPI app"""
@app.exception_handler(Exception)
async def exception_handler(request: Request, exc: Exception):
etype, _, tb = sys.exc_info()
@@ -74,8 +68,22 @@ def register_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
@app.exception_handler(AssertionError)
async def assert_error_handler(request: Request, exc: AssertionError):
logger.warning(f"AssertionError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError):
logger.warning(f"ValueError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
)
def register_request_validation_exception_handler(app: FastAPI):
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
@@ -86,8 +94,6 @@ def register_request_validation_exception_handler(app: FastAPI):
content={"detail": str(exc)},
)
def register_http_exception_handler(app: FastAPI):
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
logger.error(f"HTTPException {exc.status_code}: {exc.detail}")
@@ -96,8 +102,6 @@ def register_http_exception_handler(app: FastAPI):
content={"detail": exc.detail},
)
def register_payment_error_handler(app: FastAPI):
@app.exception_handler(PaymentError)
async def payment_error_handler(request: Request, exc: PaymentError):
logger.error(f"{exc.message}, {exc.status}")
@@ -106,8 +110,6 @@ def register_payment_error_handler(app: FastAPI):
content={"detail": exc.message, "status": exc.status},
)
def register_invoice_error_handler(app: FastAPI):
@app.exception_handler(InvoiceError)
async def invoice_error_handler(request: Request, exc: InvoiceError):
logger.error(f"{exc.message}, Status: {exc.status}")
+6 -37
View File
@@ -1,17 +1,15 @@
import json
import re
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, List, Optional, Type
from typing import Any, Optional, Type
import jinja2
import jwt
import shortuuid
from pydantic import BaseModel
from pydantic.schema import field_schema
from lnbits.core.extensions.models import Extension
from lnbits.db import get_placeholder
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.nodes import get_node_class
from lnbits.requestvars import g
@@ -51,7 +49,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
def template_renderer(additional_folders: Optional[list] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders:
additional_folders += [
@@ -175,37 +173,6 @@ def generate_filter_params_openapi(model: Type[FilterModel], keep_optional=False
}
def insert_query(table_name: str, model: BaseModel) -> str:
"""
Generate an insert query with placeholders for a given table and model
:param table_name: Name of the table
:param model: Pydantic model
"""
placeholders = []
for field in model.dict().keys():
placeholders.append(get_placeholder(model, field))
fields = ", ".join(model.dict().keys())
values = ", ".join(placeholders)
return f"INSERT INTO {table_name} ({fields}) VALUES ({values})"
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
:param table_name: Name of the table
:param model: Pydantic model
:param where: Where string, default to `WHERE id = :id`
"""
fields = []
for field in model.dict().keys():
placeholder = get_placeholder(model, field)
fields.append(f"{field} = {placeholder}")
query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}"
def is_valid_email_address(email: str) -> bool:
email_regex = r"[A-Za-z0-9\._%+-]+@[A-Za-z0-9\.-]+\.[A-Za-z]{2,63}"
return re.fullmatch(email_regex, email) is not None
@@ -217,7 +184,9 @@ def is_valid_username(username: str) -> bool:
def create_access_token(data: dict):
expire = datetime.utcnow() + timedelta(minutes=settings.auth_token_expire_minutes)
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.auth_token_expire_minutes
)
to_encode = data.copy()
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
-6
View File
@@ -7,7 +7,6 @@ import json
from enum import Enum
from hashlib import sha256
from os import path
from sqlite3 import Row
from time import time
from typing import Any, Optional
@@ -635,11 +634,6 @@ class ReadOnlySettings(
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
@classmethod
def from_row(cls, row: Row) -> Settings:
data = dict(row)
return cls(**data)
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
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
+2 -2
View File
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -530,23 +530,26 @@ video {
overflow-wrap: break-word;
}
.qrcode__wrapper canvas {
.qrcode__wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important;
max-width: 100%;
max-height: 100%;
height: 100% !important;
max-width: 350px;
}
.qrcode__image {
position: absolute;
max-width: 52px;
width: 15%;
height: 15%;
overflow: hidden;
background: #fff;
left: 50%;
overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem;
border-radius: 0.2rem;
}
+2 -2
View File
@@ -92,7 +92,7 @@ window.app = Vue.createApp({
user_id: this.user.id,
username: this.user.username,
email: this.user.email,
config: this.user.config
extra: this.user.extra
}
)
this.user = data
@@ -183,7 +183,7 @@ window.app = Vue.createApp({
const {data} = await LNbits.api.getAuthenticatedUser()
this.user = data
this.hasUsername = !!data.username
if (!this.user.config) this.user.config = {}
if (!this.user.extra) this.user.extra = {}
} catch (e) {
LNbits.utils.notifyApiError(e)
}
+7 -1
View File
@@ -278,7 +278,7 @@ window.LNbits = {
preimage: data.preimage,
payment_hash: data.payment_hash,
expiry: data.expiry,
extra: data.extra,
extra: data.extra ?? {},
wallet_id: data.wallet_id,
webhook: data.webhook,
webhook_status: data.webhook_status,
@@ -337,6 +337,12 @@ window.LNbits = {
.join('')
return hashHex
},
formatDate: function (timestamp) {
return Quasar.date.formatDate(
new Date(timestamp * 1000),
'YYYY-MM-DD HH:mm'
)
},
formatCurrency: function (value, currency) {
return new Intl.NumberFormat(window.LOCALE, {
style: 'currency',
+35 -4
View File
@@ -405,7 +405,7 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'value'],
props: ['options', 'modelValue'],
data() {
return {
formData: null,
@@ -427,11 +427,42 @@ window.app.component('lnbits-dynamic-fields', {
}, {})
},
handleValueChanged() {
this.$emit('input', this.formData)
this.$emit('update:model-value', this.formData)
}
},
created() {
this.formData = this.buildData(this.options, this.value)
this.formData = this.buildData(this.options, this.modelValue)
}
})
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
chip: '',
chips: []
}
},
methods: {
addChip() {
if (!this.chip) return
this.chips.push(this.chip)
this.chip = ''
this.$emit('update:model-value', this.chips.join(','))
},
removeChip(index) {
this.chips.splice(index, 1)
this.$emit('update:model-value', this.chips.join(','))
}
},
created() {
if (typeof this.modelValue === 'string') {
this.chips = this.modelValue.split(',')
} else {
this.chips = [...this.modelValue]
}
}
})
@@ -444,7 +475,7 @@ window.app.component('lnbits-update-balance', {
return LNBITS_DENOMINATION
},
admin() {
return this.g.user.admin
return user.super_user
}
},
data: function () {
+16 -23
View File
@@ -165,32 +165,22 @@ window.app = Vue.createApp({
type: 'bubble',
options: {
scales: {
xAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'Tx count'
}
x: {
type: 'linear',
beginAtZero: true,
title: {
text: 'Transaction count'
}
],
yAxes: [
{
type: 'linear',
ticks: {
beginAtZero: true
},
scaleLabel: {
display: true,
labelString: 'User balance in million sats'
}
},
y: {
type: 'linear',
beginAtZero: true,
title: {
text: 'User balance in million sats'
}
]
}
},
tooltips: {
tooltip: {
callbacks: {
label: function (tooltipItem, data) {
const dataset = data.datasets[tooltipItem.datasetIndex]
@@ -215,6 +205,9 @@ window.app = Vue.createApp({
})
},
methods: {
formatDate: function (value) {
return LNbits.utils.formatDate(value)
},
formatSat: function (value) {
return LNbits.utils.formatSat(Math.floor(value / 1000))
},
+3 -1
View File
@@ -5,7 +5,9 @@ window.app = Vue.createApp({
return {
updatePayments: false,
origin: window.location.origin,
wallet: LNbits.map.wallet(window.wallet),
user: LNbits.map.user(window.user),
exportUrl: `${window.location.origin}/wallet?usr=${window.user.id}&wal=${window.wallet.id}`,
receive: {
show: false,
status: 'pending',
@@ -255,7 +257,7 @@ window.app = Vue.createApp({
})
},
decodeQR: function (res) {
this.parse.data.request = res
this.parse.data.request = res[0].rawValue
this.decodeRequest()
this.parse.camera.show = false
},
+10 -9
View File
@@ -207,23 +207,24 @@ video {
}
// qrcode
.qrcode__wrapper canvas {
.qrcode__wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode__wrapper canvas {
width: 100% !important; // important to override qrcode inline width
max-width: 100%;
max-height: 100%;
height: 100% !important;
max-width: 350px; // default width of <lnbits-qrcode> component
}
.qrcode__image {
position: absolute;
max-width: 52px;
width: 15%;
height: 15%;
overflow: hidden;
background: #fff;
left: 50%;
overflow: hidden;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.2rem;
border-radius: 0.2rem;
}
File diff suppressed because one or more lines are too long
+11 -19
View File
@@ -20,8 +20,7 @@ from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment_details,
update_payment_status,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.settings import settings
@@ -181,17 +180,14 @@ async def check_pending_payments():
status = await payment.check_status()
prefix = f"payment ({i+1} / {count})"
if status.failed:
await update_payment_status(
payment.checking_id, status=PaymentState.FAILED
)
payment.status = PaymentState.FAILED
await update_payment(payment)
logger.debug(f"{prefix} failed {payment.checking_id}")
elif status.success:
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
logger.debug(f"{prefix} success {payment.checking_id}")
else:
logger.debug(f"{prefix} pending {payment.checking_id}")
@@ -211,14 +207,10 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
payment = await get_standalone_payment(checking_id, incoming=True)
if payment and payment.is_in:
status = await payment.check_status()
await update_payment_details(
checking_id=payment.checking_id,
fee=status.fee_msat,
preimage=status.preimage,
status=PaymentState.SUCCESS,
)
payment = await get_standalone_payment(checking_id, incoming=True)
assert payment, "updated payment not found"
payment.fee = status.fee_msat or 0
payment.preimage = status.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
+54 -19
View File
@@ -301,7 +301,7 @@
v-if="o.options?.length"
:options="o.options"
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
class="q-ml-xl"
>
</lnbits-dynamic-fields>
@@ -310,7 +310,7 @@
v-if="o.type === 'number'"
type="number"
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
@@ -322,7 +322,7 @@
type="textarea"
rows="5"
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:rules="applyRules(o.required)"
@@ -332,7 +332,7 @@
<q-input
v-else-if="o.type === 'password'"
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
type="password"
:label="o.label || o.name"
:hint="o.description"
@@ -343,7 +343,7 @@
<q-select
v-else-if="o.type === 'select'"
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
:label="o.label || o.name"
:hint="o.description"
:options="o.values"
@@ -352,7 +352,7 @@
<q-select
v-else-if="o.isList"
v-model.trim="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
input-debounce="0"
new-value-mode="add-unique"
:label="o.label || o.name"
@@ -371,7 +371,7 @@
<q-item-section avatar top>
<q-checkbox
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
/>
</q-item-section>
<q-item-section>
@@ -391,10 +391,16 @@
style="display: none"
:rules="applyRules(o.required)"
></q-input>
<div v-else-if="o.type === 'chips'">
<lnbits-dynamic-chips
v-model="formData[o.name]"
@update:model-value="handleValueChanged"
></lnbits-dynamic-chips>
</div>
<q-input
v-else
v-model="formData[o.name]"
@input="handleValueChanged"
@update:model-value="handleValueChanged"
:hint="o.description"
:label="o.label || o.name"
:rules="applyRules(o.required)"
@@ -407,6 +413,32 @@
</div>
</template>
<template id="lnbits-dynamic-chips">
<q-input
filled
v-model="chip"
@keydown.enter.prevent="addChip"
type="text"
label="wss://...."
hint="Add relays"
class="q-mb-md"
>
<q-btn @click="addChip" dense flat icon="add"></q-btn>
</q-input>
<div>
<q-chip
v-for="(chip, i) in chips"
:key="chip"
removable
@remove="removeChip(i)"
color="primary"
text-color="white"
:label="chip"
>
</q-chip>
</div>
</template>
<template id="lnbits-notifications-btn">
<q-btn
v-if="g.user.wallets"
@@ -457,8 +489,15 @@
<template id="lnbits-qrcode">
<div class="qrcode__wrapper">
<qrcode-vue :value="value" size="350" class="rounded-borders"></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="..." />
<qrcode-vue
:value="value"
level="Q"
render-as="svg"
margin="1"
size="350"
class="rounded-borders"
></qrcode-vue>
<img class="qrcode__image" :src="logo" alt="qrcode icon" />
</div>
</template>
@@ -585,12 +624,12 @@
:rows="paymentsOmitter"
:row-key="paymentTableRowKey"
:columns="paymentsTable.columns"
:pagination.sync="paymentsTable.pagination"
:no-data-label="$t('no_transactions')"
:filter="paymentsTable.search"
:loading="paymentsTable.loading"
:hide-header="mobileSimple"
:hide-bottom="mobileSimple"
v-model:pagination="paymentsTable.pagination"
@request="fetchPayments"
>
<template v-slot:header="props">
@@ -699,13 +738,9 @@
></lnbits-payment-details>
<div v-if="props.row.bolt11" class="text-center q-mb-lg">
<a :href="'lightning:' + props.row.bolt11">
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="
'lightning:' + props.row.bolt11.toUpperCase()
"
></lnbits-qrcode>
</q-responsive>
<lnbits-qrcode
:value="'lightning:' + props.row.bolt11.toUpperCase()"
></lnbits-qrcode>
</a>
</div>
<div class="row q-mt-lg">
@@ -797,7 +832,7 @@
</q-form>
</template>
<template id="lnbits-extension-btn-dialog">
<template id="lnbits-extension-settings-btn-dialog">
<q-btn
v-if="options"
unelevated
+17 -22
View File
@@ -39,26 +39,21 @@
</q-card-section>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
data: function () {
return {}
},
methods: {
goBack: function () {
window.history.back()
},
goHome: function () {
window.location.href = '/'
}
}
})
</script>
{% endblock %}
</div>
{% endblock %} {% block scripts %}
<script>
window.app = Vue.createApp({
el: '#vue',
mixins: [window.windowMixin],
methods: {
goBack: function () {
window.history.back()
},
goHome: function () {
window.location.href = '/'
}
}
})
</script>
{% endblock %}
+2 -2
View File
@@ -5,10 +5,10 @@
window.currencies = {{ currencies | tojson | safe }};
{% endif %}
{% if user %}
window.user = {{ user | tojson | safe }};
window.user = JSON.parse({{ user | tojson | safe }});
{% endif %}
{% if wallet %}
window.wallet = {{ wallet | tojson | safe }};
window.wallet = JSON.parse({{ wallet | tojson | safe }});
{% endif %}
</script>
{%- endmacro %}
+4 -3
View File
@@ -1320,9 +1320,10 @@
}
},
"node_modules/vue-qrcode-reader": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.10.tgz",
"integrity": "sha512-lj83FKqRyvo0VLMu49wrLsaHueonfXcwyX9r/GDw0y+myOY5xTfsl75hjBgmmByAxzFSlCPI+CGA9FxYVtRAFQ==",
"version": "5.5.11",
"resolved": "https://registry.npmjs.org/vue-qrcode-reader/-/vue-qrcode-reader-5.5.11.tgz",
"integrity": "sha512-Ec/bVML1jgxSX+usbgdcXGhOFEFo4EzApCO2CNT1YK0Dcb0Mp7ASygz78RJJs22SU2oI7vz9iJDyr4ucSDTvjQ==",
"license": "MIT",
"dependencies": {
"barcode-detector": "2.2.2",
"webrtc-adapter": "8.2.3"
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc2"
version = "1.0.0-rc4"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
@@ -201,6 +201,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[tool.ruff.lint.pep8-naming]
classmethod-decorators = [
"root_validator",
"validator",
]
[tool.ruff.lint.mccabe]
+3 -2
View File
@@ -1,6 +1,7 @@
import pytest
from lnbits.settings import settings
from lnbits.core.models import User
from lnbits.settings import Settings
@pytest.mark.asyncio
@@ -18,7 +19,7 @@ async def test_admin_get_settings(client, superuser):
@pytest.mark.asyncio
async def test_admin_update_settings(client, superuser):
async def test_admin_update_settings(client, superuser: User, settings: Settings):
new_site_title = "UPDATED SITETITLE"
response = await client.put(
f"/admin/api/v1/settings?usr={superuser.id}",
+15 -10
View File
@@ -5,7 +5,7 @@ import pytest
from lnbits import bolt11
from lnbits.core.models import CreateInvoice, Payment
from lnbits.core.views.payment_api import api_payment
from lnbits.settings import settings
from lnbits.settings import Settings
from ..helpers import (
get_random_invoice_data,
@@ -14,10 +14,13 @@ from ..helpers import (
# create account POST /api/v1/account
@pytest.mark.asyncio
async def test_create_account(client):
async def test_create_account(client, settings: Settings):
settings.lnbits_allow_new_accounts = False
response = await client.post("/api/v1/account", json={"name": "test"})
assert response.status_code == 403
assert response.status_code == 400
assert response.json().get("detail") == "Account creation is disabled."
settings.lnbits_allow_new_accounts = True
response = await client.post("/api/v1/account", json={"name": "test"})
assert response.status_code == 200
@@ -475,7 +478,7 @@ async def test_update_wallet(client, adminkey_headers_from):
@pytest.mark.asyncio
async def test_fiat_tracking(client, adminkey_headers_from):
async def test_fiat_tracking(client, adminkey_headers_from, settings: Settings):
async def create_invoice():
data = await get_random_invoice_data()
response = await client.post(
@@ -501,13 +504,15 @@ async def test_fiat_tracking(client, adminkey_headers_from):
settings.lnbits_default_accounting_currency = "USD"
payment = await create_invoice()
assert payment["extra"]["wallet_fiat_currency"] == "USD"
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
assert payment["extra"]["wallet_fiat_rate"]
extra = payment["extra"]
assert extra["wallet_fiat_currency"] == "USD"
assert extra["wallet_fiat_amount"] != payment["amount"]
assert extra["wallet_fiat_rate"]
await update_currency("EUR")
payment = await create_invoice()
assert payment["extra"]["wallet_fiat_currency"] == "EUR"
assert payment["extra"]["wallet_fiat_amount"] != payment["amount"]
assert payment["extra"]["wallet_fiat_rate"]
extra = payment["extra"]
assert extra["wallet_fiat_currency"] == "EUR"
assert extra["wallet_fiat_amount"] != payment["amount"]
assert extra["wallet_fiat_rate"]
+60 -47
View File
@@ -11,7 +11,7 @@ from httpx import AsyncClient
from lnbits.core.models import AccessTokenPayload, User
from lnbits.core.views.user_api import api_users_reset_password
from lnbits.settings import AuthMethods, settings
from lnbits.settings import AuthMethods, Settings
from lnbits.utils.nostr import hex_to_npub, sign_event
nostr_event = {
@@ -29,8 +29,6 @@ private_key = secp256k1.PrivateKey(
)
pubkey_hex = private_key.pubkey.serialize().hex()[2:]
settings.auth_allowed_methods = AuthMethods.all()
################################ LOGIN ################################
@pytest.mark.asyncio
@@ -63,7 +61,9 @@ async def test_login_alan_usr(user_alan: User, http_client: AsyncClient):
@pytest.mark.asyncio
async def test_login_usr_not_allowed(user_alan: User, http_client: AsyncClient):
async def test_login_usr_not_allowed(
user_alan: User, http_client: AsyncClient, settings: Settings
):
# exclude 'user_id_only'
settings.auth_allowed_methods = [AuthMethods.username_and_password.value]
@@ -83,7 +83,7 @@ async def test_login_usr_not_allowed(user_alan: User, http_client: AsyncClient):
@pytest.mark.asyncio
async def test_login_alan_username_password_ok(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
@@ -95,6 +95,7 @@ async def test_login_alan_username_password_ok(
payload: dict = jwt.decode(access_token, settings.auth_secret_key, ["HS256"])
access_token_payload = AccessTokenPayload(**payload)
assert access_token_payload.sub == "alan", "Subject is Alan."
assert access_token_payload.email == "alan@lnbits.com"
assert access_token_payload.auth_time, "Auth time should be set by server."
@@ -113,7 +114,9 @@ async def test_login_alan_username_password_ok(
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
assert (
len(user.wallets) == 1
), f"Expected 1 default wallet, not {len(user.wallets)}."
@pytest.mark.asyncio
@@ -139,7 +142,7 @@ async def test_login_alan_password_nok(user_alan: User, http_client: AsyncClient
@pytest.mark.asyncio
async def test_login_username_password_not_allowed(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
# exclude 'username_password'
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
@@ -164,7 +167,7 @@ async def test_login_username_password_not_allowed(
@pytest.mark.asyncio
async def test_login_alan_change_auth_secret_key(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
@@ -221,7 +224,9 @@ async def test_register_ok(http_client: AsyncClient):
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
assert (
len(user.wallets) == 1
), f"Expected 1 default wallet, not {len(user.wallets)}."
@pytest.mark.asyncio
@@ -250,7 +255,8 @@ async def test_register_email_twice(http_client: AsyncClient):
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 403, "Not allowed."
assert response.status_code == 400, "Not allowed."
assert response.json().get("detail") == "Email already exists."
@@ -280,7 +286,7 @@ async def test_register_username_twice(http_client: AsyncClient):
"email": f"u21.{tiny_id_2}@lnbits.com",
},
)
assert response.status_code == 403, "Not allowed."
assert response.status_code == 400, "Not allowed."
assert response.json().get("detail") == "Username already exists."
@@ -320,7 +326,7 @@ async def test_register_bad_email(http_client: AsyncClient):
################################ CHANGE PASSWORD ################################
@pytest.mark.asyncio
async def test_change_password_ok(http_client: AsyncClient):
async def test_change_password_ok(http_client: AsyncClient, settings: Settings):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
@@ -409,8 +415,8 @@ async def test_alan_change_password_old_nok(user_alan: User, http_client: AsyncC
},
)
assert response.status_code == 403, "Old password bad."
assert response.json().get("detail") == "Invalid credentials."
assert response.status_code == 400, "Old password bad."
assert response.json().get("detail") == "Invalid old password."
@pytest.mark.asyncio
@@ -441,7 +447,7 @@ async def test_alan_change_password_different_user(
@pytest.mark.asyncio
async def test_alan_change_password_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
@@ -464,7 +470,7 @@ async def test_alan_change_password_auth_threshold_expired(
},
)
assert response.status_code == 403, "Treshold expired."
assert response.status_code == 400
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds."
@@ -476,7 +482,7 @@ async def test_alan_change_password_auth_threshold_expired(
@pytest.mark.asyncio
async def test_register_nostr_ok(http_client: AsyncClient):
async def test_register_nostr_ok(http_client: AsyncClient, settings: Settings):
event = {**nostr_event}
event["created_at"] = int(time.time())
@@ -502,6 +508,7 @@ async def test_register_nostr_ok(http_client: AsyncClient):
response = await http_client.get(
"/api/v1/auth", headers={"Authorization": f"Bearer {access_token}"}
)
user = User(**response.json())
assert user.username is None, "No username."
assert user.email is None, "No email."
@@ -509,11 +516,13 @@ async def test_register_nostr_ok(http_client: AsyncClient):
assert not user.admin, "Not admin."
assert not user.super_user, "Not superuser."
assert not user.has_password, "Password configured."
assert len(user.wallets) == 1, "One default wallet."
assert (
len(user.wallets) == 1
), f"Expected 1 default wallet, not {len(user.wallets)}."
@pytest.mark.asyncio
async def test_register_nostr_not_allowed(http_client: AsyncClient):
async def test_register_nostr_not_allowed(http_client: AsyncClient, settings: Settings):
# exclude 'nostr_auth_nip98'
settings.auth_allowed_methods = [AuthMethods.username_and_password.value]
response = await http_client.post(
@@ -540,25 +549,25 @@ async def test_register_nostr_bad_header(http_client: AsyncClient):
)
assert response.status_code == 401, "Non nostr header."
assert response.json().get("detail") == "Authorization header is not nostr."
assert response.json().get("detail") == "Invalid Authorization scheme."
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": "nostr xyz"},
)
assert response.status_code == 401, "Nostr not base64."
assert response.status_code == 400, "Nostr not base64."
assert response.json().get("detail") == "Nostr login event cannot be parsed."
@pytest.mark.asyncio
async def test_register_nostr_bad_event(http_client: AsyncClient):
async def test_register_nostr_bad_event(http_client: AsyncClient, settings: Settings):
settings.auth_allowed_methods = AuthMethods.all()
base64_event = base64.b64encode(json.dumps(nostr_event).encode()).decode("ascii")
response = await http_client.post(
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event expired."
assert response.status_code == 400, "Nostr event expired."
assert (
response.json().get("detail")
== f"More than {settings.auth_credetials_update_threshold}"
@@ -574,7 +583,7 @@ async def test_register_nostr_bad_event(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event signature invalid."
assert response.status_code == 400, "Nostr event signature invalid."
assert response.json().get("detail") == "Nostr login event is not valid."
@@ -591,7 +600,7 @@ async def test_register_nostr_bad_event_kind(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_bad_kind}"},
)
assert response.status_code == 401, "Nostr event kind invalid."
assert response.status_code == 400, "Nostr event kind invalid."
assert response.json().get("detail") == "Invalid event kind."
@@ -610,7 +619,7 @@ async def test_register_nostr_bad_event_tag_u(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_tag_kind}"},
)
assert response.status_code == 401, "Nostr event tag missing."
assert response.status_code == 400, "Nostr event tag missing."
assert response.json().get("detail") == "Tag 'method' is missing."
event_bad_kind["tags"] = [["u", "http://localhost:5000/nostr"], ["method", "XYZ"]]
@@ -623,8 +632,8 @@ async def test_register_nostr_bad_event_tag_u(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event_tag_kind}"},
)
assert response.status_code == 401, "Nostr event tag invalid."
assert response.json().get("detail") == "Incorrect value for tag 'method'."
assert response.status_code == 400, "Nostr event tag invalid."
assert response.json().get("detail") == "Invalid value for tag 'method'."
@pytest.mark.asyncio
@@ -642,7 +651,7 @@ async def test_register_nostr_bad_event_tag_menthod(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event tag missing."
assert response.status_code == 400, "Nostr event tag missing."
assert response.json().get("detail") == "Tag 'u' for URL is missing."
event_bad_kind["tags"] = [["u", "http://demo.lnbits.com/nostr"], ["method", "POST"]]
@@ -655,15 +664,15 @@ async def test_register_nostr_bad_event_tag_menthod(http_client: AsyncClient):
"/api/v1/auth/nostr",
headers={"Authorization": f"nostr {base64_event}"},
)
assert response.status_code == 401, "Nostr event tag invalid."
assert response.status_code == 400, "Nostr event tag invalid."
assert (
response.json().get("detail") == "Incorrect value for tag 'u':"
response.json().get("detail") == "Invalid value for tag 'u':"
" 'http://demo.lnbits.com/nostr'."
)
################################ CHANGE PUBLIC KEY ################################
async def test_change_pubkey_npub_ok(http_client: AsyncClient, user_alan: User):
async def test_change_pubkey_npub_ok(http_client: AsyncClient, settings: Settings):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
@@ -703,7 +712,9 @@ async def test_change_pubkey_npub_ok(http_client: AsyncClient, user_alan: User):
@pytest.mark.asyncio
async def test_change_pubkey_ok(http_client: AsyncClient, user_alan: User):
async def test_change_pubkey_ok(
http_client: AsyncClient, user_alan: User, settings: Settings
):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
@@ -783,7 +794,7 @@ async def test_change_pubkey_ok(http_client: AsyncClient, user_alan: User):
},
)
assert response.status_code == 403, "Pubkey already used."
assert response.status_code == 400, "Pubkey already used."
assert response.json().get("detail") == "Public key already in use."
@@ -825,7 +836,7 @@ async def test_change_pubkey_other_user(http_client: AsyncClient, user_alan: Use
@pytest.mark.asyncio
async def test_alan_change_pubkey_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
response = await http_client.post("/api/v1/auth/usr", json={"usr": user_alan.id})
@@ -835,7 +846,7 @@ async def test_alan_change_pubkey_auth_threshold_expired(
assert access_token is not None
settings.auth_credetials_update_threshold = 1
time.sleep(1.1)
time.sleep(2.1)
response = await http_client.put(
"/api/v1/auth/pubkey",
headers={"Authorization": f"Bearer {access_token}"},
@@ -845,17 +856,17 @@ async def test_alan_change_pubkey_auth_threshold_expired(
},
)
assert response.status_code == 403, "Treshold expired."
assert response.status_code == 400, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds after login."
" Please login again!"
" in the first 1 seconds."
" Please login again or ask a new reset key!"
)
################################ RESET PASSWORD ################################
@pytest.mark.asyncio
async def test_request_reset_key_ok(http_client: AsyncClient):
async def test_request_reset_key_ok(http_client: AsyncClient, settings: Settings):
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
@@ -922,12 +933,14 @@ async def test_request_reset_key_user_not_found(http_client: AsyncClient):
},
)
assert response.status_code == 403, "User does not exist."
assert response.status_code == 404, "User does not exist."
assert response.json().get("detail") == "User not found."
@pytest.mark.asyncio
async def test_reset_username_password_not_allowed(http_client: AsyncClient):
async def test_reset_username_password_not_allowed(
http_client: AsyncClient, settings: Settings
):
# exclude 'username_password'
settings.auth_allowed_methods = [AuthMethods.user_id_only.value]
@@ -968,7 +981,7 @@ async def test_reset_username_passwords_do_not_matcj(
},
)
assert response.status_code == 403, "Passwords do not match."
assert response.status_code == 400, "Passwords do not match."
assert response.json().get("detail") == "Passwords do not match."
@@ -983,13 +996,13 @@ async def test_reset_username_password_bad_key(http_client: AsyncClient):
"password_repeat": "secret0000",
},
)
assert response.status_code == 500, "Bad reset key."
assert response.json().get("detail") == "Cannot reset user password."
assert response.status_code == 400, "Bad reset key."
assert response.json().get("detail") == "Invalid reset key."
@pytest.mark.asyncio
async def test_reset_password_auth_threshold_expired(
user_alan: User, http_client: AsyncClient
user_alan: User, http_client: AsyncClient, settings: Settings
):
reset_key = await api_users_reset_password(user_alan.id)
@@ -1006,7 +1019,7 @@ async def test_reset_password_auth_threshold_expired(
},
)
assert response.status_code == 403, "Treshold expired."
assert response.status_code == 400, "Treshold expired."
assert (
response.json().get("detail") == "You can only update your credentials"
" in the first 1 seconds."
+64 -37
View File
@@ -1,58 +1,62 @@
# ruff: noqa: E402
import asyncio
from time import time
import uvloop
from asgi_lifespan import LifespanManager
uvloop.install()
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
from time import time
from uuid import uuid4
import pytest
import pytest_asyncio
from asgi_lifespan import LifespanManager
from fastapi.testclient import TestClient
from httpx import ASGITransport, AsyncClient
from lnbits.app import create_app
from lnbits.core.crud import (
create_account,
create_wallet,
delete_account,
get_account,
get_account_by_username,
get_payment,
get_user,
update_payment_status,
update_payment,
)
from lnbits.core.models import CreateInvoice, PaymentState
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.views.payment_api import api_payments_create_invoice
from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import AuthMethods, settings
from lnbits.settings import AuthMethods, Settings
from lnbits.settings import settings as lnbits_settings
from tests.helpers import (
get_random_invoice_data,
)
# override settings for tests
settings.lnbits_admin_extensions = []
settings.lnbits_data_folder = "./tests/data"
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
@pytest_asyncio.fixture(scope="session")
def settings():
# override settings for tests
lnbits_settings.lnbits_admin_extensions = []
lnbits_settings.lnbits_data_folder = "./tests/data"
lnbits_settings.lnbits_admin_ui = True
lnbits_settings.lnbits_extensions_default_install = []
lnbits_settings.lnbits_extensions_deactivate_all = True
return lnbits_settings
@pytest.fixture(autouse=True)
def run_before_and_after_tests():
def run_before_and_after_tests(settings: Settings):
"""Fixture to execute asserts before and after a test is run"""
##### BEFORE TEST RUN #####
settings.lnbits_allow_new_accounts = True
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 2000
settings.lnbits_service_fee = 0
settings.lnbits_wallet_limit_daily_max_withdraw = 0
_settings_cleanup(settings)
yield # this is where the testing happens
##### AFTER TEST RUN #####
_settings_cleanup(settings)
@pytest_asyncio.fixture(scope="session")
@@ -64,7 +68,7 @@ def event_loop():
# use session scope to run once before and once after all tests
@pytest_asyncio.fixture(scope="session")
async def app():
async def app(settings: Settings):
app = create_app()
async with LifespanManager(app) as manager:
settings.first_install = False
@@ -72,7 +76,7 @@ async def app():
@pytest_asyncio.fixture(scope="session")
async def client(app):
async def client(app, settings: Settings):
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
yield client
@@ -80,7 +84,7 @@ async def client(app):
# function scope
@pytest_asyncio.fixture(scope="function")
async def http_client(app):
async def http_client(app, settings: Settings):
url = f"http://{settings.host}:{settings.port}"
async with AsyncClient(transport=ASGITransport(app=app), base_url=url) as client:
@@ -97,25 +101,33 @@ async def db():
yield Database("database")
@pytest_asyncio.fixture(scope="package")
@pytest_asyncio.fixture(scope="session")
async def user_alan():
user = await get_account_by_username("alan")
if not user:
user = await create_user_account(
email="alan@lnbits.com", username="alan", password="secret1234"
)
account = await get_account_by_username("alan")
if account:
await delete_account(account.id)
account = Account(
id=uuid4().hex,
email="alan@lnbits.com",
username="alan",
)
account.hash_password("secret1234")
user = await create_user_account(account)
yield user
@pytest_asyncio.fixture(scope="session")
async def from_user():
user = await create_account()
user = await create_user_account()
yield user
@pytest_asyncio.fixture(scope="session")
async def from_wallet(from_user):
user = from_user
wallet = await create_wallet(user_id=user.id, wallet_name="test_wallet_from")
await update_wallet_balance(
wallet_id=wallet.id,
@@ -134,12 +146,12 @@ async def from_wallet_ws(from_wallet, test_client):
@pytest_asyncio.fixture(scope="session")
async def to_user():
user = await create_account()
user = await create_user_account()
yield user
@pytest.fixture()
def from_super_user(from_user):
def from_super_user(from_user: User, settings: Settings):
prev = settings.super_user
settings.super_user = from_user.id
yield from_user
@@ -147,8 +159,10 @@ def from_super_user(from_user):
@pytest_asyncio.fixture(scope="session")
async def superuser():
user = await get_user(settings.super_user)
async def superuser(settings: Settings):
account = await get_account(settings.super_user)
assert account, "Superuser not found"
user = await get_user(account)
yield user
@@ -237,7 +251,20 @@ async def fake_payments(client, adminkey_headers_from):
assert response.is_success
data = response.json()
assert data["checking_id"]
await update_payment_status(data["checking_id"], status=PaymentState.SUCCESS)
payment = await get_payment(data["checking_id"])
payment.status = PaymentState.SUCCESS
await update_payment(payment)
params = {"time[ge]": ts, "time[le]": time()}
return fake_data, params
def _settings_cleanup(settings: Settings):
settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all()
settings.auth_credetials_update_threshold = 120
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 2000
settings.lnbits_service_fee = 0
settings.lnbits_wallet_limit_daily_max_withdraw = 0
+17 -2
View File
@@ -2,7 +2,8 @@ import random
import string
from typing import Optional
from lnbits.db import FromRowModel
from pydantic import BaseModel
from lnbits.wallets import get_funding_source, set_funding_source
@@ -10,12 +11,26 @@ class FakeError(Exception):
pass
class DbTestModel(FromRowModel):
class DbTestModel(BaseModel):
id: int
name: str
value: Optional[str] = None
class DbTestModel2(BaseModel):
id: int
label: str
description: Optional[str] = None
child: DbTestModel
class DbTestModel3(BaseModel):
id: int
user: str
child: DbTestModel2
active: bool = False
def get_random_string(iterations: int = 10):
return "".join(
random.SystemRandom().choice(string.ascii_uppercase + string.digits)
+3 -10
View File
@@ -4,7 +4,7 @@ import hashlib
import pytest
from lnbits import bolt11
from lnbits.core.crud import get_standalone_payment, update_payment_details
from lnbits.core.crud import get_standalone_payment, update_payment
from lnbits.core.models import CreateInvoice, Payment, PaymentState
from lnbits.core.services import fee_reserve_total, get_balance_delta
from lnbits.tasks import create_task, wait_for_paid_invoices
@@ -143,7 +143,6 @@ async def test_pay_real_invoice_set_pending_and_check_state(
payment = await get_standalone_payment(invoice["payment_hash"])
assert payment
assert payment.success
assert payment.pending is False
@pytest.mark.asyncio
@@ -167,7 +166,6 @@ async def test_pay_hold_invoice_check_pending(
payment_db = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db
assert payment_db.pending is True
settle_invoice(preimage)
@@ -181,7 +179,6 @@ async def test_pay_hold_invoice_check_pending(
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db_after_settlement
assert payment_db_after_settlement.pending is False
@pytest.mark.asyncio
@@ -205,7 +202,6 @@ async def test_pay_hold_invoice_check_pending_and_fail(
payment_db = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db
assert payment_db.pending is True
preimage_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
@@ -221,7 +217,6 @@ async def test_pay_hold_invoice_check_pending_and_fail(
# payment should be in database as failed
payment_db_after_settlement = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db_after_settlement
assert payment_db_after_settlement.pending is False
assert payment_db_after_settlement.failed is True
@@ -246,7 +241,6 @@ async def test_pay_hold_invoice_check_pending_and_fail_cancel_payment_task_in_me
payment_db = await get_standalone_payment(invoice_obj.payment_hash)
assert payment_db
assert payment_db.pending is True
# cancel payment task, this simulates the client dropping the connection
task.cancel()
@@ -307,16 +301,15 @@ async def test_receive_real_invoice_set_pending_and_check_state(
assert payment_status["paid"]
assert payment
assert payment.pending is False
# set the incoming invoice to pending
await update_payment_details(payment.checking_id, status=PaymentState.PENDING)
payment.status = PaymentState.PENDING
await update_payment(payment)
payment_pending = await get_standalone_payment(
invoice["payment_hash"], incoming=True
)
assert payment_pending
assert payment_pending.pending is True
assert payment_pending.success is False
assert payment_pending.failed is False
+2 -1
View File
@@ -5,6 +5,7 @@ from lnbits.core.crud import (
)
from lnbits.core.services import (
PaymentError,
PaymentState,
pay_invoice,
)
@@ -21,7 +22,7 @@ async def test_services_pay_invoice(to_wallet, real_invoice):
assert payment_hash
payment = await get_standalone_payment(payment_hash)
assert payment
assert not payment.pending
assert not payment.status == PaymentState.SUCCESS
assert payment.memo == description
+56 -12
View File
@@ -1,28 +1,72 @@
import json
import pytest
from lnbits.helpers import (
from lnbits.db import (
dict_to_model,
insert_query,
model_to_dict,
update_query,
)
from tests.helpers import DbTestModel
from tests.helpers import DbTestModel, DbTestModel2, DbTestModel3
test = DbTestModel(id=1, name="test", value="yes")
test_data = DbTestModel3(
id=1,
user="userid",
child=DbTestModel2(
id=2,
label="test",
description="mydesc",
child=DbTestModel(id=3, name="myname", value="myvalue"),
),
active=True,
)
@pytest.mark.asyncio
async def test_helpers_insert_query():
q = insert_query("test_helpers_query", test)
assert (
q == "INSERT INTO test_helpers_query (id, name, value) "
"VALUES (:id, :name, :value)"
q = insert_query("test_helpers_query", test_data)
assert q == (
"""INSERT INTO test_helpers_query ("id", "user", "child", "active") """
"VALUES (:id, :user, :child, :active)"
)
@pytest.mark.asyncio
async def test_helpers_update_query():
q = update_query("test_helpers_query", test)
assert (
q == "UPDATE test_helpers_query "
"SET id = :id, name = :name, value = :value "
"WHERE id = :id"
q = update_query("test_helpers_query", test_data)
assert q == (
"""UPDATE test_helpers_query SET "id" = :id, "user" = """
""":user, "child" = :child, "active" = :active WHERE id = :id"""
)
child_json = json.dumps(
{
"id": 2,
"label": "test",
"description": "mydesc",
"child": {"id": 3, "name": "myname", "value": "myvalue"},
}
)
test_dict = {"id": 1, "user": "userid", "child": child_json, "active": True}
@pytest.mark.asyncio
async def test_helpers_model_to_dict():
d = model_to_dict(test_data)
assert d.get("id") == test_data.id
assert d.get("active") == test_data.active
assert d.get("child") == child_json
assert d.get("user") == test_data.user
assert d == test_dict
@pytest.mark.asyncio
async def test_helpers_dict_to_model():
m = dict_to_model(test_dict, DbTestModel3)
assert m == test_data
assert type(m) is DbTestModel3
assert m.active is True
assert type(m.child) is DbTestModel2
assert type(m.child.child) is DbTestModel
+8 -8
View File
@@ -5,7 +5,7 @@ from lnbits.core.services import (
fee_reserve_total,
service_fee,
)
from lnbits.settings import settings
from lnbits.settings import Settings
@pytest.mark.asyncio
@@ -15,7 +15,7 @@ async def test_fee_reserve_internal():
@pytest.mark.asyncio
async def test_fee_reserve_min():
async def test_fee_reserve_min(settings: Settings):
settings.lnbits_reserve_fee_percent = 2
settings.lnbits_reserve_fee_min = 500
fee = fee_reserve(10000)
@@ -23,7 +23,7 @@ async def test_fee_reserve_min():
@pytest.mark.asyncio
async def test_fee_reserve_percent():
async def test_fee_reserve_percent(settings: Settings):
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 100
fee = fee_reserve(100000)
@@ -31,14 +31,14 @@ async def test_fee_reserve_percent():
@pytest.mark.asyncio
async def test_service_fee_no_wallet():
async def test_service_fee_no_wallet(settings: Settings):
settings.lnbits_service_fee_wallet = ""
fee = service_fee(10000)
assert fee == 0
@pytest.mark.asyncio
async def test_service_fee_internal():
async def test_service_fee_internal(settings: Settings):
settings.lnbits_service_fee_wallet = "wallet_id"
settings.lnbits_service_fee_ignore_internal = True
fee = service_fee(10000, internal=True)
@@ -46,7 +46,7 @@ async def test_service_fee_internal():
@pytest.mark.asyncio
async def test_service_fee():
async def test_service_fee(settings: Settings):
settings.lnbits_service_fee_wallet = "wallet_id"
settings.lnbits_service_fee = 2
fee = service_fee(10000)
@@ -54,7 +54,7 @@ async def test_service_fee():
@pytest.mark.asyncio
async def test_service_fee_max():
async def test_service_fee_max(settings: Settings):
settings.lnbits_service_fee_wallet = "wallet_id"
settings.lnbits_service_fee = 2
settings.lnbits_service_fee_max = 199
@@ -63,7 +63,7 @@ async def test_service_fee_max():
@pytest.mark.asyncio
async def test_fee_reserve_total():
async def test_fee_reserve_total(settings: Settings):
settings.lnbits_reserve_fee_percent = 1
settings.lnbits_reserve_fee_min = 100
settings.lnbits_service_fee = 2
+4 -4
View File
@@ -1,11 +1,11 @@
import pytest
from lnbits.core.services import check_wallet_daily_withdraw_limit
from lnbits.settings import settings
from lnbits.settings import Settings
@pytest.mark.asyncio
async def test_no_wallet_limit():
async def test_no_wallet_limit(settings: Settings):
settings.lnbits_wallet_limit_daily_max_withdraw = 0
result = await check_wallet_daily_withdraw_limit(
conn=None, wallet_id="333333", amount_msat=0
@@ -15,7 +15,7 @@ async def test_no_wallet_limit():
@pytest.mark.asyncio
async def test_wallet_limit_but_no_payments():
async def test_wallet_limit_but_no_payments(settings: Settings):
settings.lnbits_wallet_limit_daily_max_withdraw = 5
result = await check_wallet_daily_withdraw_limit(
conn=None, wallet_id="333333", amount_msat=0
@@ -25,7 +25,7 @@ async def test_wallet_limit_but_no_payments():
@pytest.mark.asyncio
async def test_no_wallet_spend_allowed():
async def test_no_wallet_spend_allowed(settings: Settings):
settings.lnbits_wallet_limit_daily_max_withdraw = -1
with pytest.raises(