Compare commits

..
Author SHA1 Message Date
dni ⚡ bc7e3d4dc8 fifi 2026-03-25 11:29:03 +01:00
dni ⚡ 034ba44241 manual testing works 2026-03-25 11:14:53 +01:00
dni ⚡ b0b8ea95f2 2nd stage draft pydantic 2026-03-25 10:47:13 +01:00
dni ⚡ 261350243f stage2 update fastapi 2026-03-25 10:23:35 +01:00
dni ⚡ ed02802ad6 stage one moving to last fastapi version supporting pydantic.v1 2026-03-25 09:39:22 +01:00
125 changed files with 1276 additions and 14349 deletions
+1 -9
View File
@@ -7,13 +7,9 @@
# They are NOT managed by the Admin UI and are not stored in the database. # They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token === # === First Install Token ===
# If set the user is required to enter this token on the /first_install page # if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken" # FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development === # === Logging and Development ===
DEBUG=False DEBUG=False
@@ -28,10 +24,6 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months" LOG_RETENTION="3 months"
# for database cleanup commands # for database cleanup commands
# CLEANUP_WALLETS_DAYS=90 # CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings === # === Admin Settings ===
-3
View File
@@ -69,10 +69,7 @@ jobs:
--onefile \ --onefile \
--name lnbits \ --name lnbits \
--hidden-import=embit \ --hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \ --collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \ --collect-all lnbits \
--collect-all sqlalchemy \ --collect-all sqlalchemy \
--collect-all breez_sdk \ --collect-all breez_sdk \
+4
View File
@@ -1,5 +1,9 @@
name: LNbits CI name: LNbits CI
on: on:
push:
branches:
- main
- dev
pull_request: pull_request:
-1
View File
@@ -6,7 +6,6 @@ __pycache__
*$py.class *$py.class
.mypy_cache .mypy_cache
.vscode .vscode
.codex
*-lock.json *-lock.json
.python-version .python-version
+1 -1
View File
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"] CMD ["sh", "-c", "uv run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
-1
View File
@@ -66,7 +66,6 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \ LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
DEBUG=true \ DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest uv run pytest tests/regtest
test-migration: test-migration:
+10 -10
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer"> <a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg"> <source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px"> <img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture> </picture>
</a> </a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org) ![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img alt="lnbits_head" src="docs/assets/header.jpg" /> <img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg) [![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit # LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source. LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" /> <img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe ## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions. All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" /> <img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API ## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services. LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" /> <img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager ## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier. LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" /> <img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools ## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business. As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" /> <img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits ## Powered by LNbits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+3 -1
View File
@@ -711,7 +711,9 @@ async def _call_install_extension(
user_id = user_id or get_super_user() user_id = user_id or get_super_user()
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
f"{url}/api/v1/extension?usr={user_id}", json=data.dict(), timeout=40 f"{url}/api/v1/extension?usr={user_id}",
json=data.model_dump(),
timeout=40,
) )
resp.raise_for_status() resp.raise_for_status()
else: else:
-4
View File
@@ -12,7 +12,6 @@ from .extensions import (
drop_extension_db, drop_extension_db,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids, get_user_active_extensions_ids,
get_user_extension, get_user_extension,
get_user_extensions, get_user_extensions,
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username, get_account_by_username,
get_account_by_username_or_email, get_account_by_username_or_email,
get_accounts, get_accounts,
get_accounts_count,
get_user, get_user,
get_user_access_control_lists, get_user_access_control_lists,
get_user_from_account, get_user_from_account,
@@ -119,13 +117,11 @@ __all__ = [
"get_account_by_username", "get_account_by_username",
"get_account_by_username_or_email", "get_account_by_username_or_email",
"get_accounts", "get_accounts",
"get_accounts_count",
"get_admin_settings", "get_admin_settings",
"get_db_version", "get_db_version",
"get_db_versions", "get_db_versions",
"get_installed_extension", "get_installed_extension",
"get_installed_extensions", "get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension", "get_latest_payments_by_extension",
"get_payment", "get_payment",
"get_payments", "get_payments",
-7
View File
@@ -90,13 +90,6 @@ async def get_installed_extensions(
return all_extensions return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension( async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None: ) -> UserExtension | None:
+2 -3
View File
@@ -44,7 +44,7 @@ async def update_admin_settings(
data: EditableSettings, tag: str | None = "core" data: EditableSettings, tag: str | None = "core"
) -> None: ) -> None:
editable_settings = await get_settings_by_tag("core") or {} editable_settings = await get_settings_by_tag("core") or {}
editable_settings.update(data.dict(exclude_unset=True)) editable_settings.update(data.model_dump(exclude_unset=True))
for key, value in editable_settings.items(): for key, value in editable_settings.items():
try: try:
await set_settings_field(key, value, tag) await set_settings_field(key, value, tag)
@@ -105,8 +105,7 @@ async def get_settings_field(
) )
if not row: if not row:
return None return None
value = json.loads(row["value"]) if row["value"] else None return SettingsField(id=row["id"], value=json.loads(row["value"]), tag=row["tag"])
return SettingsField(id=row["id"], value=value, tag=row["tag"])
async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"): async def set_settings_field(id_: str, value: Any | None, tag: str | None = "core"):
-7
View File
@@ -37,13 +37,6 @@ async def create_account(
return account return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account: async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc) account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account) await (conn or db).update("accounts", account)
+2 -2
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -21,8 +22,7 @@ class AuditEntry(BaseModel):
delete_at: datetime | None = None delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
retention_days = max(0, settings.lnbits_audit_retention_days) or 365 retention_days = max(0, settings.lnbits_audit_retention_days) or 365
self.delete_at = self.created_at + timedelta(days=retention_days) self.delete_at = self.created_at + timedelta(days=retention_days)
+11 -72
View File
@@ -43,8 +43,6 @@ class ExplicitRelease(BaseModel):
details_link: str | None details_link: str | None
paid_features: str | None paid_features: str | None
pay_link: str | None pay_link: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self): def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -54,8 +52,6 @@ class GitHubRelease(BaseModel):
id: str id: str
organisation: str organisation: str
repository: str repository: str
admin_only: bool = False
super_user_only: bool = False
class Manifest(BaseModel): class Manifest(BaseModel):
@@ -87,8 +83,6 @@ class ExtensionConfig(BaseModel):
warning: str | None = "" warning: str | None = ""
min_lnbits_version: str | None min_lnbits_version: str | None
max_lnbits_version: str | None max_lnbits_version: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self) -> bool: def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -102,7 +96,7 @@ class ExtensionConfig(BaseModel):
) )
error_msg = "Cannot fetch GitHub extension config" error_msg = "Cannot fetch GitHub extension config"
config = await github_api_get(config_url, error_msg) config = await github_api_get(config_url, error_msg)
return ExtensionConfig.parse_obj(config) return ExtensionConfig.model_validate(config)
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
@@ -201,8 +195,6 @@ class ExtensionRelease(BaseModel):
cost_sats: int | None = None cost_sats: int | None = None
paid_sats: int | None = 0 paid_sats: int | None = 0
payment_hash: str | None = None payment_hash: str | None = None
admin_only: bool = False
super_user_only: bool = False
@property @property
def archive_url(self) -> str: def archive_url(self) -> str:
@@ -271,8 +263,6 @@ class ExtensionRelease(BaseModel):
paid_features=e.paid_features, paid_features=e.paid_features,
repo=e.repo, repo=e.repo,
icon=e.icon, icon=e.icon,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
) )
@classmethod @classmethod
@@ -299,8 +289,6 @@ class ExtensionRelease(BaseModel):
release.min_lnbits_version = config.min_lnbits_version release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible() release.is_version_compatible = config.is_version_compatible()
release.admin_only = config.admin_only
release.super_user_only = config.super_user_only
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile) release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
@@ -316,7 +304,7 @@ class ExtensionRelease(BaseModel):
releases_url = f"https://api.github.com/repos/{org}/{repo}/releases" releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
error_msg = "Cannot fetch extension releases" error_msg = "Cannot fetch extension releases"
releases = await github_api_get(releases_url, error_msg) releases = await github_api_get(releases_url, error_msg)
return [GitHubRepoRelease.parse_obj(r) for r in releases] return [GitHubRepoRelease.model_validate(r) for r in releases]
@classmethod @classmethod
async def fetch_release_details(cls, details_link: str) -> dict | None: async def fetch_release_details(cls, details_link: str) -> dict | None:
@@ -348,8 +336,6 @@ class ExtensionMeta(BaseModel):
paid_features: str | None = None paid_features: str | None = None
has_paid_release: bool = False has_paid_release: bool = False
has_free_release: bool = False has_free_release: bool = False
admin_only: bool = False
super_user_only: bool = False
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
@@ -413,16 +399,6 @@ class InstallableExtension(BaseModel):
return False return False
return self.meta.pay_to_enable.required is True return self.meta.pay_to_enable.required is True
@property
def is_admin_only(self) -> bool:
return settings.is_admin_extension(self.id) or bool(
self.meta and self.meta.admin_only
)
@property
def is_super_user_only(self) -> bool:
return bool(self.meta and self.meta.super_user_only)
async def download_archive(self): async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).") logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path ext_zip_file = self.zip_path
@@ -478,16 +454,6 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name") self.name = config_json.get("name")
self.short_description = config_json.get("short_description") self.short_description = config_json.get("short_description")
if self.meta:
self.meta.admin_only = config_json.get("admin_only", False)
self.meta.super_user_only = config_json.get("super_user_only", False)
if self.meta.installed_release:
self.meta.installed_release.admin_only = config_json.get(
"admin_only", False
)
self.meta.installed_release.super_user_only = config_json.get(
"super_user_only", False
)
if ( if (
self.meta self.meta
@@ -530,10 +496,6 @@ class InstallableExtension(BaseModel):
return return
if not release.is_version_compatible: if not release.is_version_compatible:
return return
if not self.meta:
self.meta = ExtensionMeta()
self.meta.admin_only = release.admin_only
self.meta.super_user_only = release.super_user_only
if not self.meta or not self.meta.latest_release: if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta() meta = self.meta or ExtensionMeta()
meta.latest_release = release meta.latest_release = release
@@ -596,13 +558,6 @@ class InstallableExtension(BaseModel):
github_release.organisation, github_release.repository github_release.organisation, github_release.repository
) )
source_repo = f"{github_release.organisation}/{github_release.repository}" source_repo = f"{github_release.organisation}/{github_release.repository}"
admin_only = github_release.admin_only or config.admin_only
super_user_only = github_release.super_user_only or config.super_user_only
latest_extension_release = ExtensionRelease.from_github_release(
source_repo, latest_release
)
latest_extension_release.admin_only = admin_only
latest_extension_release.super_user_only = super_user_only
return InstallableExtension( return InstallableExtension(
id=github_release.id, id=github_release.id,
name=config.name, name=config.name,
@@ -614,9 +569,9 @@ class InstallableExtension(BaseModel):
config.tile, config.tile,
), ),
meta=ExtensionMeta( meta=ExtensionMeta(
admin_only=admin_only, latest_release=ExtensionRelease.from_github_release(
super_user_only=super_user_only, source_repo, latest_release
latest_release=latest_extension_release, ),
), ),
) )
except Exception as e: except Exception as e:
@@ -625,12 +580,7 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension: def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta( meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
archive=e.archive,
dependencies=e.dependencies,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
)
return InstallableExtension( return InstallableExtension(
id=e.id, id=e.id,
name=e.name, name=e.name,
@@ -660,8 +610,6 @@ class InstallableExtension(BaseModel):
short_description=config_json.get("short_description"), short_description=config_json.get("short_description"),
icon=config_json.get("tile"), icon=config_json.get("tile"),
meta=ExtensionMeta( meta=ExtensionMeta(
admin_only=config_json.get("admin_only", False),
super_user_only=config_json.get("super_user_only", False),
installed_release=ExtensionRelease( installed_release=ExtensionRelease(
name=ext_id, name=ext_id,
version=version, version=version,
@@ -669,9 +617,7 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}", source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"), min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"), max_lnbits_version=config_json.get("max_lnbits_version"),
admin_only=config_json.get("admin_only", False), )
super_user_only=config_json.get("super_user_only", False),
),
), ),
) )
@@ -773,13 +719,6 @@ class InstallableExtension(BaseModel):
repo_releases = await ExtensionRelease.get_github_releases( repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository r.organisation, r.repository
) )
for repo_release in repo_releases:
repo_release.admin_only = (
repo_release.admin_only or r.admin_only
)
repo_release.super_user_only = (
repo_release.super_user_only or r.super_user_only
)
extension_releases += repo_releases extension_releases += repo_releases
for e in manifest.extensions: for e in manifest.extensions:
@@ -818,7 +757,7 @@ class InstallableExtension(BaseModel):
repo_url = f"https://api.github.com/repos/{org}/{repository}" repo_url = f"https://api.github.com/repos/{org}/{repository}"
error_msg = "Cannot fetch extension repo" error_msg = "Cannot fetch extension repo"
repo = await github_api_get(repo_url, error_msg) repo = await github_api_get(repo_url, error_msg)
github_repo = GitHubRepo.parse_obj(repo) github_repo = GitHubRepo.model_validate(repo)
lates_release_url = ( lates_release_url = (
f"https://api.github.com/repos/{org}/{repository}/releases/latest" f"https://api.github.com/repos/{org}/{repository}/releases/latest"
@@ -832,15 +771,15 @@ class InstallableExtension(BaseModel):
return ( return (
github_repo, github_repo,
GitHubRepoRelease.parse_obj(latest_release), GitHubRepoRelease.model_validate(latest_release),
ExtensionConfig.parse_obj(config), ExtensionConfig.model_validate(config),
) )
@classmethod @classmethod
async def fetch_manifest(cls, url) -> Manifest: async def fetch_manifest(cls, url) -> Manifest:
error_msg = "Cannot fetch extensions manifest" error_msg = "Cannot fetch extensions manifest"
manifest = await github_api_get(url, error_msg) manifest = await github_api_get(url, error_msg)
return Manifest.parse_obj(manifest) return Manifest.model_validate(manifest)
class CreateExtension(BaseModel): class CreateExtension(BaseModel):
+31 -21
View File
@@ -6,7 +6,7 @@ import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, validator from pydantic import BaseModel, field_validator
from lnbits.helpers import ( from lnbits.helpers import (
camel_to_snake, camel_to_snake,
@@ -141,7 +141,8 @@ class DataField(BaseModel):
else: else:
return f"{self.name} {index}" return f"{self.name} {index}"
@validator("name") @field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Field name is required.") raise ValueError("Field name is required.")
@@ -149,7 +150,8 @@ class DataField(BaseModel):
raise ValueError(f"Field Name must be snake_case. Found: {v}") raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v return v
@validator("type") @field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Owner Data type is required") raise ValueError("Owner Data type is required")
@@ -171,7 +173,8 @@ class DataField(BaseModel):
) )
return v return v
@validator("label") @field_validator("label")
@classmethod
def validate_label(cls, v: str | None) -> str | None: def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -179,7 +182,8 @@ class DataField(BaseModel):
) )
return v return v
@validator("hint") @field_validator("hint")
@classmethod
def validate_hint(cls, v: str | None) -> str | None: def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}') raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
@@ -191,8 +195,7 @@ class DataFields(BaseModel):
editable: bool = True editable: bool = True
fields: list[DataField] = [] fields: list[DataField] = []
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
self.normalize() self.normalize()
def normalize(self) -> None: def normalize(self) -> None:
@@ -210,7 +213,8 @@ class DataFields(BaseModel):
return field return field
return None return None
@validator("name") @field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Data fields name is required") raise ValueError("Data fields name is required")
@@ -223,12 +227,13 @@ class SettingsFields(DataFields):
enabled: bool = False enabled: bool = False
type: str = "user" type: str = "user"
@validator("type") @field_validator("type")
@classmethod
def validate_type(cls, v: str) -> str: def validate_type(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Settings type is required") raise ValueError("Settings type is required")
if v not in ["user", "admin"]: if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}") raise ValueError(f"Field Type must be one of: user, admin. Found: {v}")
return v return v
@@ -265,8 +270,7 @@ class PreviewAction(BaseModel):
is_client_data_preview: bool = False is_client_data_preview: bool = False
is_public_page_preview: bool = False is_public_page_preview: bool = False
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
if not self.is_preview_mode: if not self.is_preview_mode:
self.is_settings_preview = False self.is_settings_preview = False
self.is_owner_data_preview = False self.is_owner_data_preview = False
@@ -286,8 +290,7 @@ class ExtensionData(BaseModel):
public_page: PublicPageFields public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction() preview_action: PreviewAction = PreviewAction()
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
self.validate_data() self.validate_data()
self.normalize() self.normalize()
@@ -434,7 +437,8 @@ class ExtensionData(BaseModel):
f" Received: {paid_flag_field.type}." f" Received: {paid_flag_field.type}."
) )
@validator("id") @field_validator("id")
@classmethod
def validate_id(cls, v: str) -> str: def validate_id(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Extension ID is required") raise ValueError("Extension ID is required")
@@ -442,13 +446,15 @@ class ExtensionData(BaseModel):
raise ValueError(f"Extension Id must be snake_case. Found: {v}") raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v return v
@validator("name") @field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str: def validate_name(cls, v: str) -> str:
if v.strip() == "": if v.strip() == "":
raise ValueError("Extension name is required") raise ValueError("Extension name is required")
return v return v
@validator("stub_version") @field_validator("stub_version")
@classmethod
def validate_stub_version(cls, v: str | None) -> str | None: def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -456,7 +462,8 @@ class ExtensionData(BaseModel):
) )
return v return v
@validator("short_description") @field_validator("short_description")
@classmethod
def validate_short_description(cls, v: str | None) -> str | None: def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -464,7 +471,8 @@ class ExtensionData(BaseModel):
) )
return v return v
@validator("description") @field_validator("description")
@classmethod
def validate_description(cls, v: str | None) -> str | None: def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v: if v and '"' in v:
raise ValueError( raise ValueError(
@@ -472,13 +480,15 @@ class ExtensionData(BaseModel):
) )
return v return v
@validator("owner_data") @field_validator("owner_data")
@classmethod
def validate_owner_data(cls, v: DataFields) -> DataFields: def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0: if len(v.fields) == 0:
raise ValueError("At least one owner data field is required") raise ValueError("At least one owner data field is required")
return v return v
@validator("client_data") @field_validator("client_data")
@classmethod
def validate_client_data(cls, v: DataFields) -> DataFields: def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0: if len(v.fields) == 0:
raise ValueError("At least one client data field is required") raise ValueError("At least one client data field is required")
+2 -2
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel, Field
class CreateLnurlPayment(BaseModel): class CreateLnurlPayment(BaseModel):
res: LnurlPayResponse | None = None res: LnurlPayResponse | None = None
lnurl: Lnurl | LnAddress | None = None lnurl: Lnurl | None = None
amount: int amount: int
comment: str | None = None comment: str | None = None
unit: str | None = None unit: str | None = None
@@ -14,7 +14,7 @@ class CreateLnurlPayment(BaseModel):
class CreateLnurlWithdraw(BaseModel): class CreateLnurlWithdraw(BaseModel):
lnurl_w: Lnurl lnurl_w: Lnurl | LnAddress
class LnurlScan(BaseModel): class LnurlScan(BaseModel):
+14 -9
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Literal from typing import Any, Literal
from fastapi import Query from fastapi import Query
from lnurl import LnurlWithdrawResponse from lnurl import LnurlWithdrawResponse
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, field_validator
from lnbits.db import FilterModel from lnbits.db import FilterModel
from lnbits.fiat.base import ( from lnbits.fiat.base import (
@@ -62,7 +62,10 @@ class Payment(BaseModel):
amount: int amount: int
fee: int fee: int
bolt11: str bolt11: str
payment_request: str | None = Field(default=None, no_database=True) payment_request: str | None = Field(
default=None,
json_schema_extra={"no_database": True},
)
fiat_provider: str | None = None fiat_provider: str | None = None
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: str | None = None memo: str | None = None
@@ -78,8 +81,7 @@ class Payment(BaseModel):
labels: list[str] = [] labels: list[str] = []
extra: dict = {} extra: dict = {}
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
if "fiat_payment_request" in self.extra: if "fiat_payment_request" in self.extra:
self.payment_request = self.extra["fiat_payment_request"] self.payment_request = self.extra["fiat_payment_request"]
else: else:
@@ -250,13 +252,14 @@ class CreateInvoice(BaseModel):
fiat_provider: str | None = None fiat_provider: str | None = None
labels: list[str] = [] labels: list[str] = []
@validator("payment_hash") @field_validator("payment_hash")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
if v: if v:
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
@validator("unit") @field_validator("unit")
@classmethod @classmethod
def unit_is_from_allowed_currencies(cls, v): def unit_is_from_allowed_currencies(cls, v):
if v != "sat" and v not in allowed_currencies(): if v != "sat" and v not in allowed_currencies():
@@ -279,7 +282,8 @@ class SettleInvoice(BaseModel):
max_length=64, max_length=64,
) )
@validator("preimage") @field_validator("preimage")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
@@ -293,7 +297,8 @@ class CancelInvoice(BaseModel):
max_length=64, max_length=64,
) )
@validator("payment_hash") @field_validator("payment_hash")
@classmethod
def check_hex(cls, v): def check_hex(cls, v):
_ = bytes.fromhex(v) _ = bytes.fromhex(v)
return v return v
+2 -4
View File
@@ -1,6 +1,4 @@
from datetime import datetime, timezone from pydantic import BaseModel
from pydantic import BaseModel, Field
class TinyURL(BaseModel): class TinyURL(BaseModel):
@@ -8,4 +6,4 @@ class TinyURL(BaseModel):
url: str url: str
endless: bool endless: bool
wallet: str wallet: str
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: float
+16 -9
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any
from uuid import UUID from uuid import UUID
from bcrypt import checkpw, gensalt, hashpw from bcrypt import checkpw, gensalt, hashpw
@@ -38,11 +39,9 @@ class WalletInviteRequest(BaseModel):
class UserLabel(BaseModel): class UserLabel(BaseModel):
name: str = Field(regex=r"([A-Za-z0-9 ._-]{1,100}$)") name: str = Field(pattern=r"([A-Za-z0-9 ._-]{1,100}$)")
description: str | None = Field(default=None, max_length=250) description: str | None = Field(default=None, max_length=250)
color: str | None = Field( color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
default=None, regex=r"^#[0-9A-Fa-f]{6}$"
) # e.g., "#RRGGBB"
class UserExtra(BaseModel): class UserExtra(BaseModel):
@@ -193,12 +192,20 @@ class Account(AccountId):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
is_super_user: bool = Field(default=False, no_database=True) is_super_user: bool = Field(
is_admin: bool = Field(default=False, no_database=True) default=False,
fiat_providers: list[str] = Field(default=[], no_database=True) json_schema_extra={"no_database": True},
)
is_admin: bool = Field(
default=False,
json_schema_extra={"no_database": True},
)
fiat_providers: list[str] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
self.is_super_user = settings.is_super_user(self.id) self.is_super_user = settings.is_super_user(self.id)
self.is_admin = settings.is_admin_user(self.id) self.is_admin = settings.is_admin_user(self.id)
self.fiat_providers = settings.get_fiat_providers_for_user(self.id) self.fiat_providers = settings.get_fiat_providers_for_user(self.id)
+8 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -126,14 +127,16 @@ class Wallet(BaseWallet):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: str | None = None currency: str | None = None
balance_msat: int = Field(default=0, no_database=True) balance_msat: int = Field(default=0, json_schema_extra={"no_database": True})
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
stored_paylinks: StoredPayLinks = StoredPayLinks() stored_paylinks: StoredPayLinks = Field(default_factory=StoredPayLinks)
# What permission this wallet has when it's a shared wallet # What permission this wallet has when it's a shared wallet
share_permissions: list[WalletPermission] = Field(default=[], no_database=True) share_permissions: list[WalletPermission] = Field(
default=[],
json_schema_extra={"no_database": True},
)
def __init__(self, **data): def model_post_init(self, __context: Any) -> None:
super().__init__(**data)
self._validate_data() self._validate_data()
def mirror_shared_wallet( def mirror_shared_wallet(
-12
View File
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
delete_installed_extension, delete_installed_extension,
get_db_version, get_db_version,
get_installed_extension, get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state, update_installed_extension_state,
) )
from lnbits.core.crud.extensions import ( from lnbits.core.crud.extensions import (
@@ -39,8 +38,6 @@ async def install_extension(
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download: if not skip_download:
await ext_info.download_archive() await ext_info.download_archive()
@@ -66,15 +63,6 @@ async def install_extension(
return extension return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str): async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id) await stop_extension_background_work(ext_id)
+3 -3
View File
@@ -97,7 +97,7 @@ def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path)
def _export_extension_data_json(data: ExtensionData, build_dir: Path): def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump( json.dump(
data.dict(), data.model_dump(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"), open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4, indent=4,
) )
@@ -133,7 +133,7 @@ async def _get_extension_stub_release(
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).") logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f: with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.dict(), indent=4)) f.write(json.dumps(release.model_dump(), indent=4))
return release return release
@@ -290,7 +290,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
{ {
"extension_builder_stub_public_client_inputs": public_client_inputs, "extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action, "preview": data.preview_action,
**data.public_page.action_fields.dict(), **data.public_page.action_fields.model_dump(),
"cancel_comment": remove_line_marker, "cancel_comment": remove_line_marker,
}, },
) )
+1 -32
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url from lnbits.helpers import check_callback_url
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source from lnbits.wallets import fake_wallet, get_funding_source
@@ -509,8 +509,6 @@ async def update_wallet_balance(
) )
payment.status = PaymentState.SUCCESS payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn) await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id) await internal_invoice_queue_put(payment.checking_id)
@@ -821,8 +819,6 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False) fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task( task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat) _fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
) )
@@ -1072,30 +1068,3 @@ async def _send_payment_notification_in_background(
if not wallet: if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed") raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment) send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
+2 -1
View File
@@ -45,10 +45,11 @@ def dict_to_settings(sets_dict: dict) -> UpdateSettings:
def update_cached_settings(sets_dict: dict): def update_cached_settings(sets_dict: dict):
editable_settings = dict_to_settings(sets_dict) editable_settings = dict_to_settings(sets_dict)
settings_keys = settings.model_dump().keys()
for key in sets_dict.keys(): for key in sets_dict.keys():
if key in readonly_variables: if key in readonly_variables:
continue continue
if key not in settings.dict().keys(): if key not in settings_keys:
continue continue
try: try:
value = getattr(editable_settings, key) value = getattr(editable_settings, key)
+3 -21
View File
@@ -23,7 +23,6 @@ from ..crud import (
get_account_by_email, get_account_by_email,
get_account_by_pubkey, get_account_by_pubkey,
get_account_by_username, get_account_by_username,
get_accounts_count,
get_super_settings, get_super_settings,
get_user_extensions, get_user_extensions,
get_user_from_account, get_user_from_account,
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None, conn: Connection | None = None,
) -> User: ) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account: if account:
account.validate_fields() account.validate_fields()
if account.username and await get_account_by_username( if account.username and await get_account_by_username(
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
return user return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account: async def update_user_account(account: Account) -> Account:
account.validate_fields() account.validate_fields()
@@ -175,7 +163,7 @@ async def check_admin_settings():
# .env super_user overwrites DB super_user # .env super_user overwrites DB super_user
settings_db = await update_super_user(settings.super_user) settings_db = await update_super_user(settings.super_user)
update_cached_settings(settings_db.dict()) update_cached_settings(settings_db.model_dump())
# saving superuser to {data_dir}/.super_user file # saving superuser to {data_dir}/.super_user file
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file: with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
@@ -185,12 +173,6 @@ async def check_admin_settings():
if account and account.extra and account.extra.provider == "env": if account and account.extra and account.extra.provider == "env":
settings.first_install = True settings.first_install = True
if settings.has_first_install_token_changed():
logger.warning("First install token is changed. Resetting admin settings.")
new_settings = await init_admin_settings()
settings.super_user = new_settings.super_user
settings.first_install = True
logger.success( logger.success(
"✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` " "✔️ Admin UI is enabled. run `uv run lnbits-cli superuser` "
"to get the superuser." "to get the superuser."
@@ -210,8 +192,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
await create_account(account) await create_account(account)
await create_wallet(user_id=account.id) await create_wallet(user_id=account.id)
editable_settings = EditableSettings.from_dict(settings.dict()) editable_settings = EditableSettings.from_dict(settings.model_dump())
return await create_admin_settings(account.id, editable_settings.dict()) return await create_admin_settings(account.id, editable_settings.model_dump())
async def check_register_activation_settings(data: RegisterUser): async def check_register_activation_settings(data: RegisterUser):
+2 -2
View File
@@ -87,7 +87,7 @@ async def api_update_settings(
admin_settings = await get_admin_settings(account.is_super_user) admin_settings = await get_admin_settings(account.is_super_user)
if not admin_settings: if not admin_settings:
raise ValueError("Updated admin settings not found.") raise ValueError("Updated admin settings not found.")
update_cached_settings(admin_settings.dict()) update_cached_settings(admin_settings.model_dump())
core_app_extra.register_new_ratelimiter() core_app_extra.register_new_ratelimiter()
return {"status": "Success"} return {"status": "Success"}
@@ -99,7 +99,7 @@ async def api_update_settings(
async def api_update_settings_partial( async def api_update_settings_partial(
data: dict, account: Account = Depends(check_admin) data: dict, account: Account = Depends(check_admin)
): ):
updatable_settings = dict_to_settings({**settings.dict(), **data}) updatable_settings = dict_to_settings({**settings.model_dump(), **data})
return await api_update_settings(updatable_settings, account) return await api_update_settings(updatable_settings, account)
+3 -11
View File
@@ -12,7 +12,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger from loguru import logger
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.crud.users import ( from lnbits.core.crud.users import (
get_user_access_control_lists, get_user_access_control_lists,
update_user_access_control_list, update_user_access_control_list,
@@ -549,13 +548,6 @@ async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
account.hash_password(data.password) account.hash_password(data.password)
await update_account(account) await update_account(account)
settings.first_install = False settings.first_install = False
# only confrm it after the super user has been successfully updated
if settings.first_install_token:
settings.first_install_token_confirmed = data.first_install_token
await set_settings_field(
"first_install_token_confirmed", data.first_install_token
)
return _auth_success_response(account.username, account.id, account.email) return _auth_success_response(account.username, account.id, account.email)
@@ -595,7 +587,7 @@ def _auth_success_response(
payload = AccessTokenPayload( payload = AccessTokenPayload(
sub=username or "", usr=user_id, email=email, auth_time=int(time()) sub=username or "", usr=user_id, email=email, auth_time=int(time())
) )
access_token = create_access_token(data=payload.dict()) access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response = JSONResponse({"access_token": access_token, "token_type": "bearer"}) response = JSONResponse({"access_token": access_token, "token_type": "bearer"})
response.set_cookie( response.set_cookie(
@@ -625,7 +617,7 @@ def _auth_api_token_response(
sub=username, api_token_id=api_token_id, auth_time=int(time()) sub=username, api_token_id=api_token_id, auth_time=int(time())
) )
return create_access_token( return create_access_token(
data=payload.dict(), token_expire_minutes=token_expire_minutes data=payload.model_dump(), token_expire_minutes=token_expire_minutes
) )
@@ -633,7 +625,7 @@ def _auth_redirect_response(path: str, user_id: str, email: str) -> RedirectResp
payload = AccessTokenPayload( payload = AccessTokenPayload(
usr=user_id, sub="", email=email, auth_time=int(time()) usr=user_id, sub="", email=email, auth_time=int(time())
) )
access_token = create_access_token(data=payload.dict()) access_token = create_access_token(data=payload.model_dump())
max_age = settings.auth_token_expire_minutes * 60 max_age = settings.auth_token_expire_minutes * 60
response = RedirectResponse(path) response = RedirectResponse(path)
response.set_cookie( response.set_cookie(
+6 -30
View File
@@ -79,11 +79,7 @@ async def api_install_extension(data: CreateExtension):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta( ext_meta = ExtensionMeta(installed_release=release)
installed_release=release,
admin_only=release.admin_only,
super_user_only=release.super_user_only,
)
ext_info = InstallableExtension( ext_info = InstallableExtension(
id=data.ext_id, id=data.ext_id,
name=data.ext_id, name=data.ext_id,
@@ -102,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files() ext_info.clean_extension_files()
detail = ( detail = (
str(exc) str(exc)
if isinstance(exc, (AssertionError, ValueError)) if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'." else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})." f"({ext_info.installed_version})."
) )
raise HTTPException( raise HTTPException(
status_code=( status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
detail=detail, detail=detail,
) from exc ) from exc
@@ -180,19 +172,6 @@ async def api_update_pay_to_enable(
) )
def _check_enable_extension_access(
ext_id: str, ext: InstallableExtension, account_id: AccountId
) -> None:
if ext.is_super_user_only and not settings.is_super_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
if ext.is_admin_only and not settings.is_admin_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
@@ -208,7 +187,6 @@ async def api_enable_extension(
raise ValueError(f"Extension '{ext_id}' is not installed.") raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
_check_enable_extension_access(ext_id, ext, account_id)
user_ext = await get_user_extension(account_id.id, ext_id) user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext: if not user_ext:
@@ -482,8 +460,6 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
"min_lnbits_version": config.min_lnbits_version, "min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(), "is_version_compatible": config.is_version_compatible(),
"warning": config.warning, "warning": config.warning,
"admin_only": config.admin_only,
"super_user_only": config.super_user_only,
} }
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
@@ -593,8 +569,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
(True for version in db_versions if version.db == ext.id), False (True for version in db_versions if version.db == ext.id), False
), ),
"isAvailable": ext.id in all_ext_ids, "isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.is_admin_only, "isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isSuperUserOnly": ext.is_super_user_only,
"isActive": ext.id not in inactive_extensions, "isActive": ext.id not in inactive_extensions,
"latestRelease": ( "latestRelease": (
dict(ext.meta.latest_release) dict(ext.meta.latest_release)
@@ -662,7 +637,8 @@ async def create_extension_review(
) -> ExtensionReviewPaymentRequest: ) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.post( resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict() settings.lnbits_extensions_reviews_url + "/reviews",
json=data.model_dump(),
) )
resp.raise_for_status() resp.raise_for_status()
payment_request = resp.json() payment_request = resp.json()
+125 -37
View File
@@ -8,10 +8,20 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Generic, Literal, TypeVar, get_origin from types import UnionType
from typing import (
Any,
ClassVar,
Generic,
Literal,
TypeVar,
Union,
get_args,
get_origin,
)
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text from sqlalchemy.sql import text
@@ -20,6 +30,7 @@ from lnbits.settings import settings
POSTGRES = "POSTGRES" POSTGRES = "POSTGRES"
COCKROACH = "COCKROACH" COCKROACH = "COCKROACH"
SQLITE = "SQLITE" SQLITE = "SQLITE"
PYDANTIC_MODEL_TYPES = (BaseModel,)
DateTrunc = Literal["hour", "day", "month"] DateTrunc = Literal["hour", "day", "month"]
sqlite_formats = { sqlite_formats = {
@@ -28,6 +39,72 @@ sqlite_formats = {
"month": "%Y-%m-01 00:00:00", "month": "%Y-%m-01 00:00:00",
} }
def _is_pydantic_model_class(model: Any) -> bool:
return isinstance(model, type) and any(
issubclass(model, model_type) for model_type in PYDANTIC_MODEL_TYPES
)
def _issubclass(candidate: Any, parent: Any) -> bool:
return isinstance(candidate, type) and issubclass(candidate, parent)
def _strip_optional(annotation: Any) -> Any:
origin = get_origin(annotation)
if origin not in {Union, UnionType}:
return annotation
args = [arg for arg in get_args(annotation) if arg is not type(None)]
if len(args) == 1:
return _strip_optional(args[0])
return annotation
def _model_fields(model: type[Any]) -> dict[str, Any]:
fields = getattr(model, "model_fields", None)
if fields is not None:
return fields
return getattr(model, "__fields__", {})
def _field_annotation(field: Any) -> Any:
annotation = getattr(field, "annotation", None)
if annotation is None:
annotation = getattr(field, "outer_type_", Any)
return _strip_optional(annotation)
def _field_inner_type(field: Any) -> Any:
type_ = getattr(field, "type_", None)
if type_ is not None:
return _strip_optional(type_)
annotation = _field_annotation(field)
origin = get_origin(annotation)
if origin in {list, set, tuple, dict}:
args = get_args(annotation)
return _strip_optional(args[0]) if args else Any
return annotation
def _field_extra(field: Any) -> dict[str, Any]:
field_info = getattr(field, "field_info", None)
if field_info is not None:
return getattr(field_info, "extra", {})
return getattr(field, "json_schema_extra", None) or {}
def _model_dump(model: BaseModel) -> dict[str, Any]:
return model.model_dump()
def _validate_model(model: type[Any], values: dict[str, Any]) -> Any:
return model.model_validate(values)
if settings.lnbits_database_url: if settings.lnbits_database_url:
database_uri = settings.lnbits_database_url database_uri = settings.lnbits_database_url
if database_uri.startswith("cockroachdb://"): if database_uri.startswith("cockroachdb://"):
@@ -35,7 +112,7 @@ if settings.lnbits_database_url:
else: else:
if not database_uri.startswith("postgres://"): if not database_uri.startswith("postgres://"):
raise ValueError( raise ValueError(
"Please use the 'postgres://...' " "format for the database URL." "Please use the 'postgres://...' format for the database URL."
) )
DB_TYPE = POSTGRES DB_TYPE = POSTGRES
@@ -447,8 +524,8 @@ class Operator(Enum):
class FilterModel(BaseModel): class FilterModel(BaseModel):
__search_fields__: list[str] = [] __search_fields__: ClassVar[list[str]] = []
__sort_fields__: list[str] | None = None __sort_fields__: ClassVar[list[str] | None] = None
T = TypeVar("T") T = TypeVar("T")
@@ -462,10 +539,12 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
model_config = ConfigDict(arbitrary_types_allowed=True)
table_name: str | None = None table_name: str | None = None
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
model: type[TFilterModel] | None model: type[TFilterModel] | None = Field(default=None, exclude=True)
values: dict | None = None values: dict | None = None
@classmethod @classmethod
@@ -487,16 +566,17 @@ class Filter(BaseModel, Generic[TFilterModel]):
field = key field = key
op = Operator("eq") op = Operator("eq")
if field in model.__fields__: model_fields = _model_fields(model)
compare_field = model.__fields__[field] if field in model_fields:
compare_field = model_fields[field]
values: dict = {} values: dict = {}
if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}: if op in {Operator.EVERY, Operator.ANY, Operator.INCLUDE, Operator.EXCLUDE}:
raw_values = [v for rv in raw_values for v in rv.split(",")] raw_values = [v for rv in raw_values for v in rv.split(",")]
for index, raw_value in enumerate(raw_values): for index, raw_value in enumerate(raw_values):
validated, errors = compare_field.validate(raw_value, {}, loc="none") validated = TypeAdapter(
if errors: _field_annotation(compare_field)
raise ValidationError(errors=[errors], model=model) ).validate_python(raw_value)
values[f"{field}__{index}"] = validated values[f"{field}__{index}"] = validated
else: else:
raise ValueError("Unknown filter field") raise ValueError("Unknown filter field")
@@ -508,7 +588,10 @@ class Filter(BaseModel, Generic[TFilterModel]):
prefix = f"{self.table_name}." if self.table_name else "" prefix = f"{self.table_name}." if self.table_name else ""
stmt = [] stmt = []
for key in self.values.keys() if self.values else []: for key in self.values.keys() if self.values else []:
if self.model and self.model.__fields__[self.field].type_ == datetime: if (
self.model
and _field_inner_type(_model_fields(self.model)[self.field]) == datetime
):
placeholder = compat_timestamp_placeholder(key) placeholder = compat_timestamp_placeholder(key)
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}") stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}: if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
@@ -534,7 +617,9 @@ class Filters(BaseModel, Generic[TFilterModel]):
the values can be validated. Otherwise, make sure to validate the inputs manually. the values can be validated. Otherwise, make sure to validate the inputs manually.
""" """
filters: list[Filter[TFilterModel]] = [] model_config = ConfigDict(arbitrary_types_allowed=True)
filters: list[Filter[TFilterModel]] = Field(default_factory=list)
search: str | None = None search: str | None = None
offset: int | None = None offset: int | None = None
@@ -542,18 +627,20 @@ class Filters(BaseModel, Generic[TFilterModel]):
sortby: str | None = None sortby: str | None = None
direction: Literal["asc", "desc"] | None = None direction: Literal["asc", "desc"] | None = None
model: type[TFilterModel] | None = None model: type[TFilterModel] | None = Field(default=None, exclude=True)
table_name: str | None = None table_name: str | None = Field(default=None, exclude=True)
@root_validator(pre=True) @model_validator(mode="before")
def validate_sortby(cls, values): @classmethod
def validate_sortby(cls, values: Any):
if not isinstance(values, dict):
return values
sortby = values.get("sortby") sortby = values.get("sortby")
model = values.get("model") model = values.get("model")
if sortby and model: if sortby and model:
model = values["model"]
# if no sort fields are specified explicitly all fields are allowed # if no sort fields are specified explicitly all fields are allowed
allowed = model.__sort_fields__ or model.__fields__ allowed = model.__sort_fields__ or _model_fields(model).keys()
if sortby not in allowed: if sortby not in allowed:
raise ValueError("Invalid sort field") raise ValueError("Invalid sort field")
return values return values
@@ -667,10 +754,12 @@ def model_to_dict(model: BaseModel) -> dict:
:param model: Pydantic model :param model: Pydantic model
""" """
_dict: dict = {} _dict: dict = {}
for key, value in model.dict().items(): model_fields = _model_fields(type(model))
type_ = model.__fields__[key].type_ for key, value in _model_dump(model).items():
outertype_ = model.__fields__[key].outer_type_ field = model_fields[key]
if model.__fields__[key].field_info.extra.get("no_database", False): type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if _field_extra(field).get("no_database", False):
continue continue
if isinstance(value, datetime): if isinstance(value, datetime):
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
@@ -681,7 +770,7 @@ def model_to_dict(model: BaseModel) -> dict:
_dict[key] = value.replace(tzinfo=None) _dict[key] = value.replace(tzinfo=None)
continue continue
if ( if (
type(type_) is type(BaseModel) _is_pydantic_model_class(type_)
or type_ is dict or type_ is dict
or get_origin(outertype_) is list or get_origin(outertype_) is list
): ):
@@ -705,51 +794,50 @@ def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
return dict_to_model(_subdict, model) return dict_to_model(_subdict, model)
def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901 def dict_to_model(_row: dict, model: type[TModel]) -> TModel:
""" """
Convert a dictionary with JSON-encoded nested models to a Pydantic model Convert a dictionary with JSON-encoded nested models to a Pydantic model
:param _dict: Dictionary from database :param _dict: Dictionary from database
:param model: Pydantic model :param model: Pydantic model
""" """
_dict: dict = {} _dict: dict = {}
model_fields = _model_fields(model)
for key, value in _row.items(): for key, value in _row.items():
if value is None: if value is None:
continue continue
if key not in model.__fields__: if key not in model_fields:
# Somethimes an SQL JOIN will create additional column # Somethimes an SQL JOIN will create additional column
continue continue
type_ = model.__fields__[key].type_ field = model_fields[key]
outertype_ = model.__fields__[key].outer_type_ type_ = _field_inner_type(field)
outertype_ = _field_annotation(field)
if get_origin(outertype_) is list: if get_origin(outertype_) is list:
_items = _safe_load_json(value) if isinstance(value, str) else value _items = _safe_load_json(value) if isinstance(value, str) else value
_dict[key] = [ _dict[key] = [
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v dict_to_submodel(type_, v) if _is_pydantic_model_class(type_) else v
for v in _items for v in _items
] ]
continue continue
if issubclass(type_, bool): if _issubclass(type_, bool):
_dict[key] = bool(value) _dict[key] = bool(value)
continue continue
if issubclass(type_, datetime): if _issubclass(type_, datetime):
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc) _dict[key] = datetime.fromtimestamp(value, timezone.utc)
else: else:
_dict[key] = value.replace(tzinfo=timezone.utc) _dict[key] = value.replace(tzinfo=timezone.utc)
continue continue
if issubclass(type_, BaseModel): if _is_pydantic_model_class(type_):
_dict[key] = dict_to_submodel(type_, value) _dict[key] = dict_to_submodel(type_, value)
continue continue
# TODO: remove this when all sub models are migrated to Pydantic # TODO: remove this when all sub models are migrated to Pydantic
# NOTE: this is for type dict on BaseModel, (used in Payment class) # NOTE: this is for type dict on BaseModel, (used in Payment class)
if type_ is dict and value: if (type_ is dict or get_origin(outertype_) is dict) and value:
_dict[key] = _safe_load_json(value) _dict[key] = _safe_load_json(value)
continue continue
_dict[key] = value _dict[key] = value
continue continue
_model = model.construct(**_dict) return _validate_model(model, _dict)
if isinstance(_model, BaseModel):
_model.__init__(**_dict) # type: ignore
return _model
def _safe_load_json(value: str) -> dict: def _safe_load_json(value: str) -> dict:
+1 -14
View File
@@ -14,7 +14,6 @@ from lnbits.core.crud import (
get_account, get_account,
get_account_by_email, get_account_by_email,
get_account_by_username, get_account_by_username,
get_installed_extension,
get_user_active_extensions_ids, get_user_active_extensions_ids,
get_user_from_account, get_user_from_account,
get_wallet_for_key, get_wallet_for_key,
@@ -425,19 +424,7 @@ async def check_user_extension_access(
Check if the user has access to a particular extension. Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed. Raises HTTP Forbidden if the user is not allowed.
""" """
ext = await get_installed_extension(ext_id, conn=conn) if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
is_admin_only = settings.is_admin_extension(ext_id) or bool(
ext and ext.meta and ext.meta.admin_only
)
is_super_user_only = bool(ext and ext.meta and ext.meta.super_user_only)
if is_super_user_only and not settings.is_super_user(user_id):
return SimpleStatus(
success=False,
message=f"User not authorized for extension '{ext_id}'.",
)
if is_admin_only and not settings.is_admin_user(user_id):
return SimpleStatus( return SimpleStatus(
success=False, message=f"User not authorized for extension '{ext_id}'." success=False, message=f"User not authorized for extension '{ext_id}'."
) )
+5 -8
View File
@@ -6,7 +6,7 @@ from typing import Any, Literal
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
@@ -28,8 +28,7 @@ FiatMethod = Literal["checkout", "subscription"]
class PayPalCheckoutOptions(BaseModel): class PayPalCheckoutOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
success_url: str | None = None success_url: str | None = None
cancel_url: str | None = None cancel_url: str | None = None
@@ -37,16 +36,14 @@ class PayPalCheckoutOptions(BaseModel):
class PayPalSubscriptionOptions(BaseModel): class PayPalSubscriptionOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
checking_id: str | None = None checking_id: str | None = None
payment_request: str | None = None payment_request: str | None = None
class PayPalCreateInvoiceOptions(BaseModel): class PayPalCreateInvoiceOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
checkout: PayPalCheckoutOptions | None = None checkout: PayPalCheckoutOptions | None = None
@@ -339,7 +336,7 @@ class PayPalWallet(FiatProvider):
self, raw_opts: dict[str, Any] self, raw_opts: dict[str, Any]
) -> PayPalCreateInvoiceOptions | None: ) -> PayPalCreateInvoiceOptions | None:
try: try:
return PayPalCreateInvoiceOptions.parse_obj(raw_opts) return PayPalCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e: except ValidationError as e:
logger.warning(f"Invalid PayPal options: {e}") logger.warning(f"Invalid PayPal options: {e}")
return None return None
+10 -11
View File
@@ -8,7 +8,7 @@ from urllib.parse import urlencode
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, ConfigDict, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings from lnbits.settings import settings
@@ -30,8 +30,7 @@ FiatMethod = Literal["checkout", "terminal", "subscription"]
class StripeTerminalOptions(BaseModel): class StripeTerminalOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
capture_method: Literal["automatic", "manual"] = "automatic" capture_method: Literal["automatic", "manual"] = "automatic"
metadata: dict[str, str] = Field(default_factory=dict) metadata: dict[str, str] = Field(default_factory=dict)
@@ -39,8 +38,7 @@ class StripeTerminalOptions(BaseModel):
class StripeCheckoutOptions(BaseModel): class StripeCheckoutOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
success_url: str | None = None success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict) metadata: dict[str, str] = Field(default_factory=dict)
@@ -48,16 +46,14 @@ class StripeCheckoutOptions(BaseModel):
class StripeSubscriptionOptions(BaseModel): class StripeSubscriptionOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
checking_id: str | None = None checking_id: str | None = None
payment_request: str | None = None payment_request: str | None = None
class StripeCreateInvoiceOptions(BaseModel): class StripeCreateInvoiceOptions(BaseModel):
class Config: model_config = ConfigDict(extra="ignore")
extra = "ignore"
fiat_method: FiatMethod = "checkout" fiat_method: FiatMethod = "checkout"
terminal: StripeTerminalOptions | None = None terminal: StripeTerminalOptions | None = None
@@ -171,7 +167,10 @@ class StripeWallet(FiatProvider):
("line_items[0][price]", subscription_id), ("line_items[0][price]", subscription_id),
("line_items[0][quantity]", f"{quantity}"), ("line_items[0][quantity]", f"{quantity}"),
] ]
subscription_data = {**payment_options.dict(), "alan_action": "subscription"} subscription_data = {
**payment_options.model_dump(),
"alan_action": "subscription",
}
subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {}) subscription_data["extra"] = json.dumps(subscription_data.get("extra") or {})
form_data += self._encode_metadata( form_data += self._encode_metadata(
@@ -494,7 +493,7 @@ class StripeWallet(FiatProvider):
self, raw_opts: dict[str, Any] self, raw_opts: dict[str, Any]
) -> StripeCreateInvoiceOptions | None: ) -> StripeCreateInvoiceOptions | None:
try: try:
return StripeCreateInvoiceOptions.parse_obj(raw_opts) return StripeCreateInvoiceOptions.model_validate(raw_opts)
except ValidationError as e: except ValidationError as e:
logger.warning(f"Invalid Stripe options: {e}") logger.warning(f"Invalid Stripe options: {e}")
return None return None
+11 -9
View File
@@ -12,7 +12,6 @@ import shortuuid
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from loguru import logger from loguru import logger
from packaging import version from packaging import version
from pydantic.schema import field_schema
from starlette.templating import Jinja2Templates from starlette.templating import Jinja2Templates
from lnbits.settings import settings from lnbits.settings import settings
@@ -71,7 +70,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
# used in base.html # used in base.html
t.env.globals["SITE_TITLE"] = settings.lnbits_site_title t.env.globals["SITE_TITLE"] = settings.lnbits_site_title
t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon t.env.globals["LNBITS_APPLE_TOUCH_ICON"] = settings.lnbits_apple_touch_icon
t.env.globals["SETTINGS"] = settings.to_public().dict(by_alias=True) t.env.globals["SETTINGS"] = settings.to_public().model_dump(by_alias=True)
t.env.globals["CURRENCIES"] = list(currencies.keys()) t.env.globals["CURRENCIES"] = list(currencies.keys())
if settings.bundle_assets: if settings.bundle_assets:
@@ -127,23 +126,26 @@ def generate_filter_params_openapi(model: type[FilterModel], keep_optional=False
:param keep_optional: If false, all parameters will be optional, :param keep_optional: If false, all parameters will be optional,
otherwise inferred from model otherwise inferred from model
""" """
fields = list(model.__fields__.values()) schema = model.model_json_schema()
properties = schema.get("properties", {})
required = set(schema.get("required", []))
params = [] params = []
for field in fields: for field_name, field in model.model_fields.items():
schema, _, _ = field_schema(field, model_name_map={}) field_key = field.alias or field_name
field_schema = properties.get(field_key, {})
description = "Supports Filtering" description = "Supports Filtering"
if ( if (
hasattr(model, "__search_fields__") hasattr(model, "__search_fields__")
and field.name in model.__search_fields__ and field_name in model.__search_fields__
): ):
description += ". Supports Search" description += ". Supports Search"
parameter = { parameter = {
"name": field.alias, "name": field_key,
"in": "query", "in": "query",
"required": field.required if keep_optional else False, "required": field_key in required if keep_optional else False,
"schema": schema, "schema": field_schema,
"description": description, "description": description,
} }
params.append(parameter) params.append(parameter)
+1 -1
View File
@@ -364,7 +364,7 @@ class LndRestNode(Node):
fee_report = await self.get("/v1/fees") fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels") balance = await self.get("/v1/balance/channels")
return NodeInfoResponse( return NodeInfoResponse(
**public.dict(), **public.model_dump(),
onchain_balance_sat=onchain["total_balance"], onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"], onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"], balance_msat=balance["local_balance"]["msat"],
+117 -43
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import importlib import importlib
import importlib.metadata import importlib.metadata
import inspect
import json import json
import os import os
import re import re
@@ -11,11 +10,12 @@ from enum import Enum
from os import path from os import path
from pathlib import Path from pathlib import Path
from time import gmtime, strftime, time from time import gmtime, strftime, time
from typing import Any from typing import Any, get_args, get_origin
from uuid import uuid4 from uuid import uuid4
from dotenv import dotenv_values
from loguru import logger from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator from pydantic import BaseModel, ConfigDict, Field, field_validator
def list_parse_fallback(v: str): def list_parse_fallback(v: str):
@@ -29,6 +29,89 @@ def list_parse_fallback(v: str):
return [] return []
def _remove_env_names(schema: dict[str, Any]) -> None:
for prop in schema.get("properties", {}).values():
prop.pop("env_names", None)
def _iter_validation_aliases(validation_alias: Any) -> list[str]:
if isinstance(validation_alias, str):
return [validation_alias]
choices = getattr(validation_alias, "choices", None)
if not choices:
return []
return [choice for choice in choices if isinstance(choice, str)]
def _annotation_has_origin(annotation: Any, origins: tuple[type, ...]) -> bool:
origin = get_origin(annotation)
if origin in origins:
return True
return any(_annotation_has_origin(arg, origins) for arg in get_args(annotation))
def _parse_settings_value(value: Any, annotation: Any) -> Any:
if not isinstance(value, str):
return value
stripped_value = value.strip()
if stripped_value.startswith("[") or stripped_value.startswith("{"):
return json.loads(stripped_value)
if _annotation_has_origin(annotation, (list, set, tuple)):
return list_parse_fallback(value)
return value
class LNbitsBaseSettings(BaseModel):
def __init__(self, **data):
super().__init__(**{**self._settings_data(), **data})
@classmethod
def _settings_data(cls) -> dict[str, Any]:
env_file = cls.model_config.get("env_file")
env_file_encoding = cls.model_config.get("env_file_encoding")
case_sensitive = bool(cls.model_config.get("case_sensitive", False))
raw_values: dict[str, Any] = {}
if env_file:
dotenv_items = dotenv_values(env_file, encoding=env_file_encoding)
raw_values.update(
{key: value for key, value in dotenv_items.items() if value is not None}
)
raw_values.update(os.environ)
lookup_values = (
raw_values
if case_sensitive
else {str(key).lower(): value for key, value in raw_values.items()}
)
settings_values: dict[str, Any] = {}
for field_name, field in cls.model_fields.items():
lookup_keys = _iter_validation_aliases(field.validation_alias)
if field.alias and field.alias != field_name:
lookup_keys.append(field.alias)
lookup_keys.append(field_name)
for key in lookup_keys:
lookup_key = key if case_sensitive else key.lower()
if lookup_key not in lookup_values:
continue
settings_values[field_name] = _parse_settings_value(
lookup_values[lookup_key], field.annotation
)
break
return settings_values
class LNbitsSettings(BaseModel): class LNbitsSettings(BaseModel):
@classmethod @classmethod
def validate_list(cls, val): def validate_list(cls, val):
@@ -324,8 +407,7 @@ class AssetSettings(LNbitsSettings):
"heif", "heif",
"heics", "heics",
"text/plain", "text/plain",
"text/json", "text/jsontext/xml",
"text/xml",
"application/json", "application/json",
"application/pdf", "application/pdf",
] ]
@@ -448,7 +530,6 @@ class SecuritySettings(LNbitsSettings):
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0) lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000, ge=0)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0) lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000, ge=0)
first_install_token_confirmed: str | None = Field(default=None)
def is_wallet_max_balance_exceeded(self, amount): def is_wallet_max_balance_exceeded(self, amount):
return ( return (
@@ -589,8 +670,6 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings): class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/") phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None) phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
@@ -656,9 +735,9 @@ class BoltzFundingSource(LNbitsSettings):
class StrikeFundingSource(LNbitsSettings): class StrikeFundingSource(LNbitsSettings):
strike_api_endpoint: str | None = Field( strike_api_endpoint: str | None = Field(
default="https://api.strike.me/v1", env="STRIKE_API_ENDPOINT" default="https://api.strike.me/v1", validation_alias="STRIKE_API_ENDPOINT"
) )
strike_api_key: str | None = Field(default=None, env="STRIKE_API_KEY") strike_api_key: str | None = Field(default=None, validation_alias="STRIKE_API_KEY")
class FiatProviderLimits(BaseModel): class FiatProviderLimits(BaseModel):
@@ -975,12 +1054,12 @@ class EditableSettings(
KeycloakAuthSettings, KeycloakAuthSettings,
OidcAuthSettings, OidcAuthSettings,
): ):
@validator( @field_validator(
"lnbits_admin_users", "lnbits_admin_users",
"lnbits_allowed_users", "lnbits_allowed_users",
"lnbits_theme_options", "lnbits_theme_options",
"lnbits_admin_extensions", "lnbits_admin_extensions",
pre=True, mode="before",
) )
@classmethod @classmethod
def validate_editable_settings(cls, val): def validate_editable_settings(cls, val):
@@ -988,21 +1067,21 @@ class EditableSettings(
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
return cls( return cls(**{k: v for k, v in d.items() if k in cls.model_fields})
**{k: v for k, v in d.items() if k in inspect.signature(cls).parameters}
)
# fixes openapi.json validation, remove field env_names # Fixes openapi.json validation by removing the v1-only env_names metadata.
class Config: model_config = ConfigDict(
@staticmethod populate_by_name=True,
def schema_extra(schema: dict[str, Any]) -> None: json_schema_extra=_remove_env_names,
for prop in schema.get("properties", {}).values(): )
prop.pop("env_names", None)
class UpdateSettings(EditableSettings): class UpdateSettings(EditableSettings):
class Config: model_config = ConfigDict(
extra = Extra.forbid populate_by_name=True,
extra="forbid",
json_schema_extra=_remove_env_names,
)
class EnvSettings(LNbitsSettings): class EnvSettings(LNbitsSettings):
@@ -1010,6 +1089,7 @@ class EnvSettings(LNbitsSettings):
debug_database: bool = Field(default=False) debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True) bundle_assets: bool = Field(default=True)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP. # When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True) auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1") host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0) port: int = Field(default=5000, gt=0)
@@ -1028,20 +1108,11 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0) cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0) funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property @property
def has_default_extension_path(self) -> bool: def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits" return self.lnbits_extensions_path == "lnbits"
def has_first_install_token_changed(self) -> bool:
if not self.first_install_token:
return False
if not settings.first_install_token_confirmed:
return False
return self.first_install_token != settings.first_install_token_confirmed
def check_auth_secret_key(self): def check_auth_secret_key(self):
if self.auth_secret_key: if self.auth_secret_key:
return return
@@ -1122,7 +1193,7 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] return [f for f in cls.model_fields if not f.startswith("_")]
class ReadOnlySettings( class ReadOnlySettings(
@@ -1137,9 +1208,9 @@ class ReadOnlySettings(
def lnbits_extensions_upgrade_path(self) -> str: def lnbits_extensions_upgrade_path(self) -> str:
return str(Path(self.lnbits_data_folder, "upgrades")) return str(Path(self.lnbits_data_folder, "upgrades"))
@validator( @field_validator(
"lnbits_allowed_funding_sources", "lnbits_allowed_funding_sources",
pre=True, mode="before",
) )
@classmethod @classmethod
def validate_readonly_settings(cls, val): def validate_readonly_settings(cls, val):
@@ -1147,15 +1218,18 @@ class ReadOnlySettings(
@classmethod @classmethod
def readonly_fields(cls): def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")] return [f for f in cls.model_fields if not f.startswith("_")]
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings): class Settings(
class Config: EditableSettings, ReadOnlySettings, TransientSettings, LNbitsBaseSettings
env_file = ".env" ):
env_file_encoding = "utf-8" model_config = ConfigDict(
case_sensitive = False populate_by_name=True,
json_loads = list_parse_fallback env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
def is_user_allowed(self, user_id: str) -> bool: def is_user_allowed(self, user_id: str) -> bool:
return ( return (
@@ -1360,7 +1434,7 @@ if not settings.user_agent:
# printing environment variable for debugging # printing environment variable for debugging
if not settings.lnbits_admin_ui: if not settings.lnbits_admin_ui:
logger.debug("Environment Settings:") logger.debug("Environment Settings:")
for key, value in settings.dict(exclude_none=True).items(): for key, value in settings.model_dump(exclude_none=True).items():
logger.debug(f"{key}: {value}") logger.debug(f"{key}: {value}")
File diff suppressed because one or more lines are too long
+11 -11
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -191,7 +191,6 @@ window.localisation.br = {
only_admins_can_create_extensions: only_admins_can_create_extensions:
'Apenas contas de administrador podem criar extensões', 'Apenas contas de administrador podem criar extensões',
admin_only: 'Apenas para Administração', admin_only: 'Apenas para Administração',
super_user_only: 'Apenas para superusuário',
make_user_admin: 'Tornar usuário administrador', make_user_admin: 'Tornar usuário administrador',
revoke_admin: 'Revogar Admin', revoke_admin: 'Revogar Admin',
new_version: 'Nova Versão', new_version: 'Nova Versão',
-1
View File
@@ -135,7 +135,6 @@ window.localisation.cn = {
all: '全部', all: '全部',
only_admins_can_install: '(只有管理员账户可以安装扩展)', only_admins_can_install: '(只有管理员账户可以安装扩展)',
admin_only: '仅限管理员', admin_only: '仅限管理员',
super_user_only: '仅限超级用户',
new_version: '新版本', new_version: '新版本',
extension_depends_on: '依赖于:', extension_depends_on: '依赖于:',
extension_rating_soon: '即将推出评分', extension_rating_soon: '即将推出评分',
-1
View File
@@ -140,7 +140,6 @@ window.localisation.cs = {
only_admins_can_install: only_admins_can_install:
'(Pouze administrátorské účty mohou instalovat rozšíření)', '(Pouze administrátorské účty mohou instalovat rozšíření)',
admin_only: 'Pouze pro adminy', admin_only: 'Pouze pro adminy',
super_user_only: 'Pouze pro superuživatele',
new_version: 'Nová verze', new_version: 'Nová verze',
extension_depends_on: 'Závisí na:', extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnocení brzy dostupné', extension_rating_soon: 'Hodnocení brzy dostupné',
-1
View File
@@ -143,7 +143,6 @@ window.localisation.de = {
only_admins_can_install: only_admins_can_install:
'(Nur Administratorkonten können Erweiterungen installieren)', '(Nur Administratorkonten können Erweiterungen installieren)',
admin_only: 'Nur für Admins', admin_only: 'Nur für Admins',
super_user_only: 'Nur für Superuser',
new_version: 'Neue Version', new_version: 'Neue Version',
extension_depends_on: 'Hängt ab von:', extension_depends_on: 'Hängt ab von:',
extension_rating_soon: 'Bewertungen sind bald verfügbar', extension_rating_soon: 'Bewertungen sind bald verfügbar',
-3
View File
@@ -189,7 +189,6 @@ window.localisation.en = {
only_admins_can_create_extensions: only_admins_can_create_extensions:
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
super_user_only: 'Super User Only',
make_user_admin: 'Make User Admin', make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin', revoke_admin: 'Revoke Admin',
new_version: 'New Version', new_version: 'New Version',
@@ -276,8 +275,6 @@ window.localisation.en = {
requires_server_restart: requires_server_restart:
'Changing these settings requires a server restart to take effect.', 'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet', funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.', latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
notifications_configure: 'Configure Notifications', notifications_configure: 'Configure Notifications',
-1
View File
@@ -142,7 +142,6 @@ window.localisation.es = {
only_admins_can_install: only_admins_can_install:
'(Solo las cuentas de administrador pueden instalar extensiones)', '(Solo las cuentas de administrador pueden instalar extensiones)',
admin_only: 'Solo administradores', admin_only: 'Solo administradores',
super_user_only: 'Solo superusuario',
new_version: 'Nueva Versión', new_version: 'Nueva Versión',
extension_depends_on: 'Depende de:', extension_depends_on: 'Depende de:',
extension_rating_soon: 'Calificaciones próximamente', extension_rating_soon: 'Calificaciones próximamente',
-1
View File
@@ -154,7 +154,6 @@ window.localisation.fi = {
all: 'Kaikki', all: 'Kaikki',
only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)', only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)',
admin_only: 'Pääkäyttäjille', admin_only: 'Pääkäyttäjille',
super_user_only: 'Vain superkäyttäjälle',
new_version: 'Uusi versio', new_version: 'Uusi versio',
extension_depends_on: 'Edellyttää:', extension_depends_on: 'Edellyttää:',
extension_rating_soon: 'Arvostelut on tulossa pian', extension_rating_soon: 'Arvostelut on tulossa pian',
-1
View File
@@ -145,7 +145,6 @@ window.localisation.fr = {
only_admins_can_install: only_admins_can_install:
'Seuls les comptes administrateurs peuvent installer des extensions', 'Seuls les comptes administrateurs peuvent installer des extensions',
admin_only: 'Réservé aux administrateurs', admin_only: 'Réservé aux administrateurs',
super_user_only: 'Réservé au super utilisateur',
new_version: 'Nouvelle version', new_version: 'Nouvelle version',
extension_depends_on: 'Dépend de :', extension_depends_on: 'Dépend de :',
extension_rating_soon: 'Notes des utilisateurs à venir bientôt', extension_rating_soon: 'Notes des utilisateurs à venir bientôt',
-1
View File
@@ -142,7 +142,6 @@ window.localisation.it = {
only_admins_can_install: only_admins_can_install:
'Solo gli account amministratore possono installare estensioni.', 'Solo gli account amministratore possono installare estensioni.',
admin_only: 'Solo amministratore', admin_only: 'Solo amministratore',
super_user_only: 'Solo superutente',
new_version: 'Nuova Versione', new_version: 'Nuova Versione',
extension_depends_on: 'Dipende da:', extension_depends_on: 'Dipende da:',
extension_rating_soon: 'Valutazioni in arrivo', extension_rating_soon: 'Valutazioni in arrivo',
-1
View File
@@ -137,7 +137,6 @@ window.localisation.jp = {
only_admins_can_install: only_admins_can_install:
'(管理者アカウントのみが拡張機能をインストールできます)', '(管理者アカウントのみが拡張機能をインストールできます)',
admin_only: '管理者のみ', admin_only: '管理者のみ',
super_user_only: 'スーパー ユーザーのみ',
new_version: '新しいバージョン', new_version: '新しいバージョン',
extension_depends_on: '依存先:', extension_depends_on: '依存先:',
extension_rating_soon: '評価は近日公開', extension_rating_soon: '評価は近日公開',
-1
View File
@@ -139,7 +139,6 @@ window.localisation.kr = {
all: '전체', all: '전체',
only_admins_can_install: '(관리자 계정만이 확장 기능을 설치할 수 있습니다)', only_admins_can_install: '(관리자 계정만이 확장 기능을 설치할 수 있습니다)',
admin_only: '관리자 전용', admin_only: '관리자 전용',
super_user_only: '슈퍼유저 전용',
new_version: '새로운 버전', new_version: '새로운 버전',
extension_depends_on: '의존성 존재:', extension_depends_on: '의존성 존재:',
extension_rating_soon: '평점 기능도 곧 구현됩니다', extension_rating_soon: '평점 기능도 곧 구현됩니다',
-1
View File
@@ -143,7 +143,6 @@ window.localisation.nl = {
only_admins_can_install: only_admins_can_install:
'Alleen beheerdersaccounts kunnen extensies installeren', 'Alleen beheerdersaccounts kunnen extensies installeren',
admin_only: 'Alleen beheerder', admin_only: 'Alleen beheerder',
super_user_only: 'Alleen supergebruiker',
new_version: 'Nieuwe Versie', new_version: 'Nieuwe Versie',
extension_depends_on: 'Afhankelijk van:', extension_depends_on: 'Afhankelijk van:',
extension_rating_soon: 'Beoordelingen binnenkort beschikbaar', extension_rating_soon: 'Beoordelingen binnenkort beschikbaar',
-1
View File
@@ -141,7 +141,6 @@ window.localisation.pi = {
all: 'Arr', all: 'Arr',
only_admins_can_install: '(Only admin accounts can install extensions)', only_admins_can_install: '(Only admin accounts can install extensions)',
admin_only: "Cap'n Only", admin_only: "Cap'n Only",
super_user_only: "Big Cap'n Only",
new_version: 'New Version', new_version: 'New Version',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
extension_rating_soon: "Ratings a'comin' soon", extension_rating_soon: "Ratings a'comin' soon",
-1
View File
@@ -140,7 +140,6 @@ window.localisation.pl = {
only_admins_can_install: only_admins_can_install:
'Tylko konta administratorów mogą instalować rozszerzenia', 'Tylko konta administratorów mogą instalować rozszerzenia',
admin_only: 'Tylko dla administratora', admin_only: 'Tylko dla administratora',
super_user_only: 'Tylko dla superużytkownika',
new_version: 'Nowa wersja', new_version: 'Nowa wersja',
extension_depends_on: 'Zależy od:', extension_depends_on: 'Zależy od:',
extension_rating_soon: 'Oceny będą dostępne wkrótce', extension_rating_soon: 'Oceny będą dostępne wkrótce',
-1
View File
@@ -141,7 +141,6 @@ window.localisation.pt = {
only_admins_can_install: only_admins_can_install:
'Apenas contas de administrador podem instalar extensões.', 'Apenas contas de administrador podem instalar extensões.',
admin_only: 'Apenas para administradores', admin_only: 'Apenas para administradores',
super_user_only: 'Apenas para superusuário',
new_version: 'Nova Versão', new_version: 'Nova Versão',
extension_depends_on: 'Depende de:', extension_depends_on: 'Depende de:',
extension_rating_soon: 'Avaliações em breve', extension_rating_soon: 'Avaliações em breve',
-1
View File
@@ -138,7 +138,6 @@ window.localisation.sk = {
only_admins_can_install: only_admins_can_install:
'(Iba administrátorské účty môžu inštalovať rozšírenia)', '(Iba administrátorské účty môžu inštalovať rozšírenia)',
admin_only: 'Iba pre administrátorov', admin_only: 'Iba pre administrátorov',
super_user_only: 'Iba pre superužívateľa',
new_version: 'Nová verzia', new_version: 'Nová verzia',
extension_depends_on: 'Závisí na:', extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnotenia budú čoskoro dostupné', extension_rating_soon: 'Hodnotenia budú čoskoro dostupné',
-1
View File
@@ -139,7 +139,6 @@ window.localisation.we = {
all: 'Pob', all: 'Pob',
only_admins_can_install: 'Dim ond cyfrifon gweinyddwr all osod estyniadau', only_admins_can_install: 'Dim ond cyfrifon gweinyddwr all osod estyniadau',
admin_only: 'Dim ond Gweinyddwr', admin_only: 'Dim ond Gweinyddwr',
super_user_only: 'Dim ond Uwchddefnyddiwr',
new_version: 'Fersiwn Newydd', new_version: 'Fersiwn Newydd',
extension_depends_on: 'Dibynnu ar:', extension_depends_on: 'Dibynnu ar:',
extension_rating_soon: 'Sgôr yn dod yn fuan', extension_rating_soon: 'Sgôr yn dod yn fuan',
+1 -1
View File
@@ -59,7 +59,7 @@ window.LNbits = {
newWallet.canSendPayments = perms.includes('send-payments') newWallet.canSendPayments = perms.includes('send-payments')
} }
newWallet.url = `/wallet?&wal=${data.id}` newWallet.url = `/wallet?&wal=${data.id}`
newWallet.storedPaylinks = data.stored_paylinks.links newWallet.storedPaylinks = data.stored_paylinks?.links
return newWallet return newWallet
} }
} }
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd', 'Phoenixd',
{ {
phoenixd_api_endpoint: 'Endpoint', phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key', phoenixd_api_password: 'Key'
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
} }
], ],
[ [
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
] ]
} }
}, },
methods: { methods: {}
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
}) })
@@ -165,26 +165,5 @@ window.app.component('lnbits-qrcode', {
this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px' this.$refs.qrCode.$el.style.maxWidth = this.maxWidth + 'px'
this.$refs.qrCode.$el.setAttribute('width', '100%') this.$refs.qrCode.$el.setAttribute('width', '100%')
this.$refs.qrCode.$el.removeAttribute('height') this.$refs.qrCode.$el.removeAttribute('height')
},
computed: {
optimizedValue() {
const separatorIndex = this.value.indexOf(':')
const type =
separatorIndex === -1 ? '' : this.value.substring(0, separatorIndex)
const value =
separatorIndex === -1
? this.value
: this.value.substring(separatorIndex + 1)
if (this.utils.isValidBech32(value)) {
const normalizedValue = value.toUpperCase()
if (type) {
return `${type.toUpperCase()}:${normalizedValue}`
}
return normalizedValue
}
return this.value
}
} }
}) })
+8 -33
View File
@@ -545,56 +545,31 @@ window.PageAccount = {
if (file) { if (file) {
this.uploadAsset(file) this.uploadAsset(file)
} }
e.target.value = null
}, },
onBackgroundImageInput(e) { async uploadAsset(file) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
try { try {
const {data} = await LNbits.api.request( await LNbits.api.request(
'POST', 'POST',
`/api/v1/assets?public_asset=${isPublic}`, `/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
null, null,
formData, formData,
{ {
headers: {'Content-Type': 'multipart/form-data'} headers: {'Content-Type': 'multipart/form-data'}
} }
) )
if (notifySuccess) { this.$q.notify({
this.$q.notify({ type: 'positive',
type: 'positive', message: 'Upload successful!',
message: 'Upload successful!', icon: null
icon: null })
})
}
await this.getUserAssets() await this.getUserAssets()
return data
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
LNbits.utils.notifyApiError(e) LNbits.utils.notifyApiError(e)
} }
}, },
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) { async deleteAsset(asset) {
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?') .confirmDialog('Are you sure you want to delete this asset?')
-40
View File
@@ -160,46 +160,6 @@ window._lnbitsUtils = {
return null return null
} }
}, },
isValidBech32(value) {
if (typeof value !== 'string') {
return false
}
const candidate = value.trim()
if (
!candidate ||
(candidate !== candidate.toLowerCase() &&
candidate !== candidate.toUpperCase())
) {
return false
}
const normalized = candidate.toLowerCase()
const splitPosition = normalized.lastIndexOf('1')
if (splitPosition <= 0) {
return false
}
const humanReadablePart = normalized.substring(0, splitPosition)
const data = normalized.substring(splitPosition + 1)
if (data.length < 6) {
return false
}
if (
typeof bech32ToFiveBitArray !== 'function' ||
typeof verify_checksum !== 'function'
) {
return false
}
const words = bech32ToFiveBitArray(data)
if (words.some(word => word < 0)) {
return false
}
return verify_checksum(humanReadablePart, words)
},
async notifyApiError(error) { async notifyApiError(error) {
if (!error.response) { if (!error.response) {
return console.error(error) return console.error(error)
+153 -294
View File
File diff suppressed because it is too large Load Diff
+35 -11
View File
@@ -5,8 +5,12 @@ from collections.abc import Callable, Coroutine
from loguru import logger from loguru import logger
from lnbits.core.models import Payment from lnbits.core.crud import (
from lnbits.core.services.payments import update_invoice_callback get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.wallets import get_funding_source from lnbits.wallets import get_funding_source
@@ -112,10 +116,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running: while settings.lnbits_running:
checking_id = await internal_invoice_queue.get() checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}") logger.info(f"got an internal payment notification {checking_id}")
payment = await update_invoice_callback(checking_id) await invoice_callback_dispatcher(checking_id, is_internal=True)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
async def invoice_listener() -> None: async def invoice_listener() -> None:
@@ -128,10 +129,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source() funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream(): async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}") logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_callback(checking_id) await invoice_callback_dispatcher(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
def wait_for_paid_invoices( def wait_for_paid_invoices(
@@ -167,7 +165,33 @@ def run_interval(
return wrapper return wrapper
async def invoice_callback_dispatcher(payment: Payment): async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
from lnbits.core.services.payments import check_payment_status
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items(): for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`") logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment) await send_chan.put(payment)
+29 -29
View File
@@ -75,35 +75,7 @@
</p> </p>
</div> </div>
</div> </div>
<div v-if="isSuperUser"> <div class="row q-col-gutter-md">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<div class="row q-col-gutter-md q-mt-lg">
<div class="col-12"> <div class="col-12">
<h6 class="q-my-none"> <h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span> <span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -210,6 +182,34 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<q-separator></q-separator> <q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p> <p v-text="$t('watchdog')"></p>
@@ -52,7 +52,6 @@
:label="prop.label" :label="prop.label"
:hint="prop.hint" :hint="prop.hint"
:value="prop.value" :value="prop.value"
:readonly="prop.readonly || false"
> >
<q-btn <q-btn
v-if="prop.copy" v-if="prop.copy"
@@ -74,15 +73,6 @@
></q-btn> ></q-btn>
</q-input> </q-input>
</div> </div>
<p
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
class="col-12 q-my-md"
>
<span>
<q-icon name="warning" color="orange" size="xs"></q-icon>
<span v-text="$t('phoenixd_warning')"></span>
</span>
</p>
</div> </div>
<q-expansion-item <q-expansion-item
v-if=" v-if="
@@ -251,27 +251,10 @@
type="text" type="text"
v-model="formData.lnbits_default_bgimage" v-model="formData.lnbits_default_bgimage"
label="Background Image" label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address." hint="This must be a trusted source. It can change the content and it can log your IP address."
> >
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.adminBackgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
</q-input> </q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md q-mb-md"> <div class="row q-col-gutter-md q-mb-md">
@@ -12,7 +12,7 @@
> >
<qrcode-vue <qrcode-vue
ref="qrCode" ref="qrCode"
:value="optimizedValue" :value="value"
:margin="margin" :margin="margin"
:size="size" :size="size"
level="Q" level="Q"
+68 -134
View File
@@ -492,28 +492,10 @@
siteCustomisationChanged({bgimageChoice: $event}) siteCustomisationChanged({bgimageChoice: $event})
" "
> >
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip <q-tooltip
><span v-text="$t('background_image')"></span ><span v-text="$t('background_image')"></span
></q-tooltip> ></q-tooltip>
</q-input> </q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div> </div>
</div> </div>
<div class="row q-mb-md"> <div class="row q-mb-md">
@@ -1126,130 +1108,82 @@
<q-separator></q-separator> <q-separator></q-separator>
<q-card-section> <q-card-section>
<div <q-btn-dropdown
class="row items-center no-wrap q-col-gutter-sm" color="grey"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
> >
<div class="col"> <q-list>
<q-btn-dropdown <q-item
color="grey" clickable
dense v-close-popup
outline @click="copyAssetLinkToClipboard(props.row)"
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
> >
<q-list> <q-item-section avatar>
<q-item <q-avatar icon="content_copy" />
clickable </q-item-section>
v-close-popup <q-item-section>
@click=" <q-item-label>Copy Link</q-item-label>
copyAssetLinkToClipboard(props.row) <q-item-label caption
" >Copy asset link to
clipboard</q-item-label
> >
<q-item-section avatar> </q-item-section>
<q-avatar icon="content_copy" /> </q-item>
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
<q-item <q-item
clickable clickable
v-close-popup v-close-popup
@click=" @click="toggleAssetPublicAccess(props.row)"
toggleAssetPublicAccess(props.row) >
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
" "
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
> >
<q-item-section avatar> </q-item-section>
<q-avatar <q-item-section v-else>
:icon=" <q-item-label>Publish</q-item-label>
props.row.is_public <q-item-label caption
? 'public_off' >Make this asset public</q-item-label
: 'public' >
" </q-item-section>
text-color="primary" </q-item>
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
<q-item <q-item
clickable clickable
v-close-popup v-close-popup
@click="deleteAsset(props.row)" @click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
> >
<q-item-section avatar> </q-item-section>
<q-avatar </q-item>
icon="delete" </q-list>
text-color="negative" </q-btn-dropdown>
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</div> </div>
+2 -10
View File
@@ -307,13 +307,7 @@
:label="$t('disable')" :label="$t('disable')"
></q-btn> ></q-btn>
<q-badge <q-badge
v-if="extension.isSuperUserOnly && !g.user.super_user" v-if="extension.isAdminOnly && !g.user.admin"
v-text="$t('super_user_only')"
>
</q-badge>
<q-badge
v-else-if="extension.isAdminOnly && !g.user.admin"
v-text="$t('admin_only')" v-text="$t('admin_only')"
> >
</q-badge> </q-badge>
@@ -322,9 +316,7 @@
v-else-if=" v-else-if="
extension.isInstalled && extension.isInstalled &&
extension.isActive && extension.isActive &&
!g.user.extensions.includes(extension.id) && !g.user.extensions.includes(extension.id)
(!extension.isAdminOnly || g.user.admin) &&
(!extension.isSuperUserOnly || g.user.super_user)
" "
flat flat
color="primary" color="primary"
+1 -5
View File
@@ -15,11 +15,7 @@ from lnbits.settings import settings
def log_server_info(): def log_server_info():
logger.info("LNbits Info") logger.info("LNbits Info")
if settings.first_install: if settings.first_install:
if settings.has_first_install_token_changed(): logger.success("This is a fresh install of LNbits.")
logger.success("This is a first install token reset.")
else:
logger.success("This is a fresh install of LNbits.")
if settings.first_install_token: if settings.first_install_token:
logger.success( logger.success(
f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. " f"FIRST_INSTALL_TOKEN: `{settings.first_install_token}`. "
+2 -52
View File
@@ -4,11 +4,9 @@ import hashlib
import json import json
import urllib.parse import urllib.parse
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
from embit.bip39 import mnemonic_is_valid
from httpx import RequestError, TimeoutException from httpx import RequestError, TimeoutException
from loguru import logger from loguru import logger
from websockets import connect from websockets import connect
@@ -63,8 +61,6 @@ class PhoenixdWallet(Wallet):
} }
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers) self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
self._seed_mnemonic_to_persist: str | None = None
self._load_mnemonic_from_seed_file()
async def cleanup(self): async def cleanup(self):
try: try:
@@ -73,7 +69,6 @@ class PhoenixdWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}") logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
await self._persist_loaded_mnemonic()
try: try:
r = await self.client.get("/getinfo", timeout=10) r = await self.client.get("/getinfo", timeout=10)
r.raise_for_status() r.raise_for_status()
@@ -106,6 +101,7 @@ class PhoenixdWallet(Wallet):
unhashed_description: bytes | None = None, unhashed_description: bytes | None = None,
**kwargs, **kwargs,
) -> InvoiceResponse: ) -> InvoiceResponse:
try: try:
msats_amount = amount msats_amount = amount
data: dict[str, Any] = { data: dict[str, Any] = {
@@ -313,7 +309,7 @@ class PhoenixdWallet(Wallet):
and message_json.get("type") == "payment_received" and message_json.get("type") == "payment_received"
): ):
logger.info( logger.info(
f"payment-received: {message_json['paymentHash']}" f'payment-received: {message_json["paymentHash"]}'
) )
yield message_json["paymentHash"] yield message_json["paymentHash"]
@@ -323,49 +319,3 @@ class PhoenixdWallet(Wallet):
"retrying in 5 seconds" "retrying in 5 seconds"
) )
await asyncio.sleep(5) await asyncio.sleep(5)
def _load_mnemonic_from_seed_file(self):
data_dir = settings.phoenixd_data_dir
if not data_dir:
return
seed_path = Path(data_dir).expanduser() / "seed.dat"
if not seed_path.is_file():
return
try:
mnemonic = seed_path.read_text(encoding="utf-8").strip()
if mnemonic == settings.phoenixd_mnemonic:
return
except OSError as exc:
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
return
if not mnemonic:
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
return
if not mnemonic_is_valid(mnemonic):
logger.warning(
f"Phoenixd seed file '{seed_path}' does not contain a valid "
"BIP39 mnemonic."
)
return
settings.phoenixd_mnemonic = mnemonic
self._seed_mnemonic_to_persist = mnemonic
async def _persist_loaded_mnemonic(self):
if not self._seed_mnemonic_to_persist:
return
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
try:
from lnbits.core.crud.settings import set_settings_field
await set_settings_field(
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
)
self._seed_mnemonic_to_persist = None
except Exception as exc:
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
+11 -15
View File
@@ -6,7 +6,7 @@
"": { "": {
"name": "lnbits", "name": "lnbits",
"dependencies": { "dependencies": {
"axios": "^1.15.0", "axios": "^1.13.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"nostr-tools": "^2.18.2", "nostr-tools": "^2.18.2",
@@ -734,14 +734,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.15.0", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^2.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
@@ -1498,9 +1498,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.2", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1555,13 +1555,9 @@
} }
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
"license": "MIT",
"engines": {
"node": ">=10"
}
}, },
"node_modules/pyright": { "node_modules/pyright": {
"version": "1.1.289", "version": "1.1.289",
+1 -1
View File
@@ -21,7 +21,7 @@
"terser": "^5.44.1" "terser": "^5.44.1"
}, },
"dependencies": { "dependencies": {
"axios": "^1.15.0", "axios": "^1.13.5",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"moment": "^2.30.1", "moment": "^2.30.1",
"nostr-tools": "^2.18.2", "nostr-tools": "^2.18.2",
Generated
+178 -49
View File
@@ -199,6 +199,18 @@ files = [
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
] ]
[[package]]
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
files = [
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
]
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.12.1" version = "4.12.1"
@@ -2259,14 +2271,14 @@ valkey = ["valkey (>=6)"]
[[package]] [[package]]
name = "lnurl" name = "lnurl"
version = "0.10.0" version = "2.0.0"
description = "LNURL implementation for Python." description = "LNURL implementation for Python."
optional = false optional = false
python-versions = "<3.13,>=3.10" python-versions = "<3.13,>=3.10"
groups = ["main"] groups = ["main"]
files = [ files = [
{file = "lnurl-0.10.0-py3-none-any.whl", hash = "sha256:0d43561b4370cecec19c8f1044fc840f0199fc2698bc83cd8782e8264bfde8f4"}, {file = "lnurl-2.0.0-py3-none-any.whl", hash = "sha256:e355f7433aa5ba31557ffbfd042978e8e6f9df90aaae907ebeb613f06ff4f9f4"},
{file = "lnurl-0.10.0.tar.gz", hash = "sha256:55ba0a7e810bcdfc7410c682f91c76df4446ff24e83d6bf931a7c759e2e0d5cc"}, {file = "lnurl-2.0.0.tar.gz", hash = "sha256:4ac17e289eb3818f217b84b41d455f27d0161b300560f6bddd345b923b5ba82d"},
] ]
[package.dependencies] [package.dependencies]
@@ -2276,7 +2288,7 @@ bolt11 = "*"
coincurve = ">=20.0.0" coincurve = ">=20.0.0"
httpx = "*" httpx = "*"
pycryptodomex = ">=3.21.0" pycryptodomex = ">=3.21.0"
pydantic = ">=1.10.0,<2.0.0" pydantic = ">=2.10.0,<3.0.0"
[[package]] [[package]]
name = "loguru" name = "loguru"
@@ -3320,58 +3332,160 @@ files = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "1.10.26" version = "2.12.5"
description = "Data validation and settings management using python type hints" description = "Data validation using Python type hints"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.9"
groups = ["main", "dev"] groups = ["main", "dev"]
files = [ files = [
{file = "pydantic-1.10.26-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f7ae36fa0ecef8d39884120f212e16c06bb096a38f523421278e2f39c1784546"}, {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-1.10.26-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d95a76cf503f0f72ed7812a91de948440b2bf564269975738a4751e4fadeb572"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
{file = "pydantic-1.10.26-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a943ce8e00ad708ed06a1d9df5b4fd28f5635a003b82a4908ece6f24c0b18464"},
{file = "pydantic-1.10.26-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:465ad8edb29b15c10b779b16431fe8e77c380098badf6db367b7a1d3e572cf53"},
{file = "pydantic-1.10.26-cp310-cp310-win_amd64.whl", hash = "sha256:80e6be6272839c8a7641d26ad569ab77772809dd78f91d0068dc0fc97f071945"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:116233e53889bcc536f617e38c1b8337d7fa9c280f0fd7a4045947515a785637"},
{file = "pydantic-1.10.26-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3cfdd361addb6eb64ccd26ac356ad6514cee06a61ab26b27e16b5ed53108f77"},
{file = "pydantic-1.10.26-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e4451951a9a93bf9a90576f3e25240b47ee49ab5236adccb8eff6ac943adf0f"},
{file = "pydantic-1.10.26-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9858ed44c6bea5f29ffe95308db9e62060791c877766c67dd5f55d072c8612b5"},
{file = "pydantic-1.10.26-cp311-cp311-win_amd64.whl", hash = "sha256:ac1089f723e2106ebde434377d31239e00870a7563245072968e5af5cc4d33df"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:468d5b9cacfcaadc76ed0a4645354ab6f263ec01a63fb6d05630ea1df6ae453f"},
{file = "pydantic-1.10.26-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c1b0b914be31671000ca25cf7ea17fcaaa68cfeadf6924529c5c5aa24b7ab1f"},
{file = "pydantic-1.10.26-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15b13b9f8ba8867095769e1156e0d7fbafa1f65b898dd40fd1c02e34430973cb"},
{file = "pydantic-1.10.26-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad7025ca324ae263d4313998e25078dcaec5f9ed0392c06dedb57e053cc8086b"},
{file = "pydantic-1.10.26-cp312-cp312-win_amd64.whl", hash = "sha256:4482b299874dabb88a6c3759e3d85c6557c407c3b586891f7d808d8a38b66b9c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ae7913bb40a96c87e3d3f6fe4e918ef53bf181583de4e71824360a9b11aef1c"},
{file = "pydantic-1.10.26-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8154c13f58d4de5d3a856bb6c909c7370f41fb876a5952a503af6b975265f4ba"},
{file = "pydantic-1.10.26-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8af0507bf6118b054a9765fb2e402f18a8b70c964f420d95b525eb711122d62"},
{file = "pydantic-1.10.26-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dcb5a7318fb43189fde6af6f21ac7149c4bcbcfffc54bc87b5becddc46084847"},
{file = "pydantic-1.10.26-cp313-cp313-win_amd64.whl", hash = "sha256:71cde228bc0600cf8619f0ee62db050d1880dcc477eba0e90b23011b4ee0f314"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6b40730cc81d53d515dc0b8bb5c9b43fadb9bed46de4a3c03bd95e8571616dba"},
{file = "pydantic-1.10.26-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c3bbb9c0eecdf599e4db9b372fa9cc55be12e80a0d9c6d307950a39050cb0e37"},
{file = "pydantic-1.10.26-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc2e3fe7bc4993626ef6b6fa855defafa1d6f8996aa1caef2deb83c5ac4d043a"},
{file = "pydantic-1.10.26-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36d9e46b588aaeb1dcd2409fa4c467fe0b331f3cc9f227b03a7a00643704e962"},
{file = "pydantic-1.10.26-cp314-cp314-win_amd64.whl", hash = "sha256:81ce3c8616d12a7be31b4aadfd3434f78f6b44b75adbfaec2fe1ad4f7f999b8c"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc5c91a3b3106caf07ac6735ec6efad8ba37b860b9eb569923386debe65039ad"},
{file = "pydantic-1.10.26-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:dde599e0388e04778480d57f49355c9cc7916de818bf674de5d5429f2feebfb6"},
{file = "pydantic-1.10.26-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8be08b5cfe88e58198722861c7aab737c978423c3a27300911767931e5311d0d"},
{file = "pydantic-1.10.26-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0141f4bafe5eda539d98c9755128a9ea933654c6ca4306b5059fc87a01a38573"},
{file = "pydantic-1.10.26-cp38-cp38-win_amd64.whl", hash = "sha256:eb664305ffca8a9766a8629303bb596607d77eae35bb5f32ff9245984881b638"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:502b9d30d18a2dfaf81b7302f6ba0e5853474b1c96212449eb4db912cb604b7d"},
{file = "pydantic-1.10.26-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0d8f6087bf697dec3bf7ffcd7fe8362674f16519f3151789f33cbe8f1d19fc15"},
{file = "pydantic-1.10.26-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dd40a99c358419910c85e6f5d22f9c56684c25b5e7abc40879b3b4a52f34ae90"},
{file = "pydantic-1.10.26-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ce3293b86ca9f4125df02ff0a70be91bc7946522467cbd98e7f1493f340616ba"},
{file = "pydantic-1.10.26-cp39-cp39-win_amd64.whl", hash = "sha256:1a4e3062b71ab1d5df339ba12c48f9ed5817c5de6cb92a961dd5c64bb32e7b96"},
{file = "pydantic-1.10.26-py3-none-any.whl", hash = "sha256:c43ad70dc3ce7787543d563792426a16fd7895e14be4b194b5665e36459dd917"},
{file = "pydantic-1.10.26.tar.gz", hash = "sha256:8c6aa39b494c5af092e690127c283d84f363ac36017106a9e66cb33a22ac412e"},
] ]
[package.dependencies] [package.dependencies]
email-validator = {version = ">=1.0.3", optional = true, markers = "extra == \"email\""} annotated-types = ">=0.6.0"
typing-extensions = ">=4.2.0" email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""}
pydantic-core = "2.41.5"
typing-extensions = ">=4.14.1"
typing-inspection = ">=0.4.2"
[package.extras] [package.extras]
dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=2.0.0)"]
email = ["email-validator (>=1.0.3)"] timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
[[package]]
name = "pydantic-core"
version = "2.41.5"
description = "Core functionality for Pydantic validation and serialization"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a"},
{file = "pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556"},
{file = "pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49"},
{file = "pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba"},
{file = "pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6"},
{file = "pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b"},
{file = "pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594"},
{file = "pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e"},
{file = "pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe"},
{file = "pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7"},
{file = "pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c"},
{file = "pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294"},
{file = "pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1"},
{file = "pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815"},
{file = "pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9"},
{file = "pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586"},
{file = "pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e"},
{file = "pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858"},
{file = "pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11"},
{file = "pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"},
{file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"},
{file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"},
{file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"},
{file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"},
{file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"},
{file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"},
{file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"},
{file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"},
{file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf"},
{file = "pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3"},
{file = "pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5"},
{file = "pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3"},
{file = "pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460"},
{file = "pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2"},
{file = "pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56"},
{file = "pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963"},
{file = "pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f"},
{file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"},
{file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"},
]
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]] [[package]]
name = "pygments" name = "pygments"
@@ -4476,6 +4590,21 @@ files = [
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
[[package]]
name = "typing-inspection"
version = "0.4.2"
description = "Runtime typing introspection tools"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
]
[package.dependencies]
typing-extensions = ">=4.12.0"
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"
@@ -5028,4 +5157,4 @@ migration = ["psycopg2-binary"]
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.10,<3.13" python-versions = ">=3.10,<3.13"
content-hash = "42751c05e1fa8250ff90981def5e0ce8d1b284eeb2acc2c7ac9ae5847cf99e17" content-hash = "42433ecd056377e5a537ebf0d9aeb70fc2bc275276ba9721310e8efc7a7d933b"
+8 -7
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.4" version = "1.5.2"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -9,15 +9,15 @@ readme = "README.md"
dependencies = [ dependencies = [
"bech32~=1.2.0", "bech32~=1.2.0",
"click~=8.3.1", "click~=8.3.1",
"fastapi~=0.116.1", "fastapi~=0.135.0",
"starlette~=0.47.1", "starlette~=1.0.0",
"httpx~=0.27.2", "httpx~=0.27.2",
"jinja2~=3.1.6", "jinja2~=3.1.6",
"lnurl~=0.10.0", "lnurl~=2.0.0",
"pydantic~=1.10.26", "pydantic~=2.12.0",
"pyqrcode~=1.2.1", "pyqrcode~=1.2.1",
"shortuuid~=1.0.13", "shortuuid~=1.0.13",
"sse-starlette~=2.3.6", "sse-starlette~=3.3.3",
"typing-extensions~=4.15.0", "typing-extensions~=4.15.0",
"uvicorn~=0.40.0", "uvicorn~=0.40.0",
"sqlalchemy~=1.4.54", "sqlalchemy~=1.4.54",
@@ -37,7 +37,7 @@ dependencies = [
"bolt11~=2.1.1", "bolt11~=2.1.1",
"pyjwt~=2.12.0", "pyjwt~=2.12.0",
"itsdangerous~=2.2.0", "itsdangerous~=2.2.0",
"fastapi-sso~=0.19.0", "fastapi-sso~=0.21.0",
# needed for boltz, lnurldevice, watchonly extensions # needed for boltz, lnurldevice, watchonly extensions
"embit~=0.8.0", "embit~=0.8.0",
# needed for scheduler extension # needed for scheduler extension
@@ -51,6 +51,7 @@ dependencies = [
"pillow~=12.1.0", "pillow~=12.1.0",
"python-dotenv~=1.2.1", "python-dotenv~=1.2.1",
"greenlet~=3.3.0", "greenlet~=3.3.0",
"pydantic-settings>=2.13.1",
] ]
[project.scripts] [project.scripts]
-109
View File
@@ -1,9 +1,6 @@
from pathlib import Path
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings from lnbits.settings import Settings
@@ -52,109 +49,3 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"}, headers={"Authorization": f"Bearer {superuser_token}"},
) )
assert response.status_code == 400 assert response.status_code == 400
@pytest.mark.anyio
async def test_admin_audit_monitor_and_test_email(
client: AsyncClient, superuser_token: str, mocker
):
mocker.patch(
"lnbits.core.views.admin_api.get_balance_delta",
mocker.AsyncMock(
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
),
)
mocker.patch(
"lnbits.core.views.admin_api.send_email_notification",
mocker.AsyncMock(return_value={"status": "queued"}),
)
audit = await client.get(
"/admin/api/v1/audit",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert audit.status_code == 200
assert audit.json()["lnbits_balance_sats"] == 21
monitor = await client.get(
"/admin/api/v1/monitor",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
test_email = await client.get(
"/admin/api/v1/testemail",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert test_email.status_code == 200
assert test_email.json()["status"] == "queued"
@pytest.mark.anyio
async def test_admin_partial_reset_restart_and_backup(
client: AsyncClient,
superuser_token: str,
settings: Settings,
tmp_path,
):
response = await client.patch(
"/admin/api/v1/settings",
json={"lnbits_site_title": "PATCHED TITLE"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["status"] == "Success"
default_value = await client.get(
"/admin/api/v1/settings/default",
params={"field_name": "lnbits_site_title"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert default_value.status_code == 200
assert "default_value" in default_value.json()
backup_path = Path("lnbits-backup.zip")
original_data_folder = settings.lnbits_data_folder
try:
data_folder = tmp_path / "backup_data"
data_folder.mkdir(parents=True, exist_ok=True)
(data_folder / "sample.txt").write_text("backup me")
settings.lnbits_data_folder = str(data_folder)
backup = await client.get(
"/admin/api/v1/backup",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert backup.status_code == 200
assert backup.headers["content-type"] == "application/zip"
assert backup.content.startswith(b"PK")
assert backup_path.is_file()
finally:
settings.lnbits_data_folder = original_data_folder
backup_path.unlink(missing_ok=True)
server_restart.clear()
restart = await client.get(
"/admin/api/v1/restart",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert restart.status_code == 200
assert restart.json()["status"] == "Success"
assert server_restart.is_set() is True
server_restart.clear()
@pytest.mark.anyio
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert server_restart.is_set() is True
server_restart.clear()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status # check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio @pytest.mark.anyio
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to): async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
# check the payment status # check the payment status
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"]) response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
assert isinstance(response, dict) assert isinstance(response, dict)
assert response["paid"] is True assert response["paid"] is True
assert "details" in response assert "details" in response
-135
View File
@@ -1,135 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.assets import get_user_asset
from lnbits.core.services.assets import create_user_asset
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
@pytest.mark.anyio
async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient,
user_headers_from: dict[str, str],
):
upload = await client.post(
"/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.txt", b"hello world", "text/plain")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.txt"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
assert page.status_code == 200
assert any(item["id"] == asset["id"] for item in page.json()["data"])
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.txt"
data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
)
assert data.status_code == 200
assert data.content == b"hello world"
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
updated = await client.put(
f"/api/v1/assets/{asset['id']}",
headers=user_headers_from,
json={"name": "renamed.txt", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.txt"
assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200
assert public_data.content == b"hello world"
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
missing = await client.get(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert missing.status_code == 404
@pytest.mark.anyio
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
client: AsyncClient,
from_user,
to_user,
superuser_token: str,
):
private_asset = await create_user_asset(
from_user.id,
make_upload_file(
get_png_bytes(),
filename=f"private_{uuid4().hex[:8]}.png",
content_type="image/png",
),
is_public=False,
)
other_user_headers = await get_user_token_headers(client, to_user.id)
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert anonymous.status_code == 404
wrong_user = await client.get(
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
)
assert wrong_user.status_code == 404
admin_updated = await client.put(
f"/api/v1/assets/{private_asset.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"is_public": True, "name": "admin-visible.png"},
)
assert admin_updated.status_code == 200
assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200
assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png"
@pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient,
user_headers_from: dict[str, str],
):
invalid = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
)
assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"]
missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from,
)
assert missing.status_code == 404
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
assert missing_thumb.status_code == 404
stored = await create_user_asset(
"missing-user-check",
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
is_public=True,
)
fetched = await get_user_asset("missing-user-check", stored.id)
assert fetched is not None
-64
View File
@@ -1,64 +0,0 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.audit import create_audit_entry
from lnbits.core.models import AuditEntry
@pytest.mark.anyio
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
response = await client.get("/audit/api/v1", headers=user_headers_from)
assert response.status_code == 403
@pytest.mark.anyio
async def test_audit_api_returns_entries_and_stats(
client: AsyncClient,
superuser_token: str,
):
component = f"audit_component_{uuid4().hex[:8]}"
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.1",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="GET",
response_code="200",
duration=0.12,
created_at=datetime.now(timezone.utc),
)
)
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.2",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="POST",
response_code="400",
duration=2.5,
created_at=datetime.now(timezone.utc),
)
)
headers = {"Authorization": f"Bearer {superuser_token}"}
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
assert page.status_code == 200
page_data = page.json()
assert page_data["total"] == 2
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
stats = await client.get(
f"/audit/api/v1/stats?component={component}",
headers=headers,
)
assert stats.status_code == 200
payload = stats.json()
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
assert payload["component"][0]["field"] == component
assert payload["long_duration"][0]["field"] == "/api/v1/test"
-203
View File
@@ -1,203 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.responses import RedirectResponse
from httpx import AsyncClient
from lnbits.core.crud.users import get_account, update_account
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.auth_api import get_account_by_email
from lnbits.settings import Settings
class _FakeSSO:
def __init__(self, userinfo: object | None = None, state: str = ""):
self.userinfo = userinfo
self.state = state
self.redirect_uri: str | None = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
async def get_login_redirect(self, state: str):
self.state = state
return RedirectResponse("https://example.com/sso/login")
async def verify_and_process(self, _request):
return self.userinfo
@pytest.mark.anyio
async def test_auth_api_logout_and_update_ui_customization(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.patch(
f"/api/v1/auth/ui?usr={user.id}",
json={"theme": "amber", "walletLayout": "grid"},
)
assert response.status_code == 200
assert response.json()["ui_customization"]["theme"] == "amber"
assert response.json()["ui_customization"]["walletLayout"] == "grid"
logout = await http_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
assert logout.json()["status"] == "success"
assert "cookie_access_token=" in logout.headers["set-cookie"]
@pytest.mark.anyio
async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
provider = "github"
login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
assert login_sso.state
email = f"sso_{uuid4().hex[:8]}@lnbits.com"
callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="")
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso)
callback = await http_client.get(f"/api/v1/auth/{provider}/token")
assert callback.status_code == 307
assert callback.headers["location"] == "/wallet"
account = await get_account_by_email(email, active_only=False)
assert account is not None
assert account.email == email
assert account.extra.email_verified is True
@pytest.mark.anyio
async def test_auth_api_first_install_success_and_validation(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
first_install_token = f"install_{uuid4().hex[:8]}"
new_username = f"reinstall_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.first_install_token = first_install_token
missing_token = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert missing_token.status_code == 401
assert missing_token.json()["detail"] == "Missing first_install_token."
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
"first_install_token": first_install_token,
},
)
assert success.status_code == 200
assert success.json()["access_token"]
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" not in success.headers["set-cookie"]
updated_superuser = await get_account(settings.super_user, active_only=False)
assert updated_superuser is not None
assert updated_superuser.username == new_username
assert settings.first_install is False
forbidden = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": f"blocked_{uuid4().hex[:8]}",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert forbidden.status_code == 403
assert forbidden.json()["detail"] == "This is not your first install"
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
@pytest.mark.anyio
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_auth_https_only = settings.auth_https_only
new_username = f"secure_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.auth_https_only = True
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert success.status_code == 200
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" in success.headers["set-cookie"]
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.auth_https_only = original_auth_https_only
-175
View File
@@ -1,175 +0,0 @@
import json
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import (
handle_paypal_event,
handle_stripe_event,
)
@pytest.mark.anyio
async def test_callback_api_generic_webhook_handler_routes_providers(
http_client: AsyncClient, mocker
):
stripe_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock()
)
paypal_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
)
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
)
stripe = await http_client.post(
"/api/v1/callback/stripe",
headers={"Stripe-Signature": "sig"},
json={"id": "evt_1", "type": "payment_intent.succeeded"},
)
assert stripe.status_code == 200
assert stripe.json()["success"] is True
stripe_mock.assert_awaited_once()
paypal = await http_client.post(
"/api/v1/callback/paypal",
json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"},
)
assert paypal.status_code == 200
assert paypal.json()["success"] is True
paypal_mock.assert_awaited_once()
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
assert unknown.status_code == 200
assert unknown.json()["success"] is False
@pytest.mark.anyio
async def test_callback_api_handles_paid_events_with_real_payments(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback")
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_stripe",
"type": "payment_intent.succeeded",
"data": {
"object": {
"object": "payment_intent",
"metadata": {"payment_hash": payment.payment_hash},
}
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
assert fiat_status_mock.await_count == 2
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_invoice_paid",
"type": "invoice.paid",
"data": {
"object": {
"id": "invoice_1",
"currency": "usd",
"amount_paid": 500,
"hosted_invoice_url": "https://stripe.example/invoice",
"customer_email": "alice@example.com",
"lines": {"data": [{"description": "Gold Plan"}]},
"parent": {
"type": "subscription_details",
"subscription_details": {
"metadata": {
"alan_action": "subscription",
"wallet_id": wallet.id,
"tag": "gold",
"memo": "Monthly Gold",
"extra": json.dumps({"plan": "gold"}),
}
},
},
}
},
}
)
create_fiat_invoice_mock.assert_awaited()
fiat_status_mock.assert_awaited()
await handle_paypal_event(
{
"id": "evt_sale_completed",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {
"id": "sale_1",
"billing_agreement_id": "agreement_1",
"amount": {"currency": "USD", "total": "7.50"},
"custom_id": json.dumps(
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
),
},
}
)
assert create_fiat_invoice_mock.await_count == 2
with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata."
):
await handle_paypal_event(
{
"id": "evt_bad_sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
}
)
-501
View File
@@ -1,501 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
get_installed_extension,
get_user_extension,
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionRelease,
GitHubRelease,
InstallableExtension,
Manifest,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
api_disable_extension,
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
create_extension_review,
delete_extension_db,
extensions,
get_extension_release,
get_extension_releases,
get_extension_reviews,
get_extension_reviews_tags,
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from tests.helpers import make_extension_release, make_installable_extension
class _MockHTTPResponse:
def __init__(
self,
*,
json_data=None,
text: str = "",
status_code: int = 200,
is_error: bool = False,
):
self._json_data = json_data
self.text = text
self.status_code = status_code
self.is_error = is_error
def json(self):
return self._json_data
def raise_for_status(self):
if self.status_code >= 400:
raise ValueError(self.text or "request failed")
class _MockHTTPClient:
def __init__(self, responses: dict[str, _MockHTTPResponse]):
self.responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, url: str):
return self.responses[url]
async def post(self, url: str, json=None):
return self.responses[url]
@pytest.mark.anyio
async def test_extension_api_install_details_and_release_endpoints(mocker):
ext_id = f"ext_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.views.extension_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
installed = await api_install_extension(create_data)
assert installed.code == ext_id
activate_mock.assert_awaited_once()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=[release]),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_details",
mocker.AsyncMock(return_value={"description": "Extension details"}),
)
details = await api_extension_details(ext_id, release.details_link or "")
assert details["description"] == "Extension details"
assert details["icon"] == release.icon
assert details["repo"] == release.repo
installed_ext = make_installable_extension(
ext_id,
payments=[
ReleasePaymentInfo(
amount=55,
pay_link=release.pay_link,
payment_hash=f"payment_{uuid4().hex[:8]}",
)
],
)
await create_installed_extension(installed_ext)
releases = await get_extension_releases(ext_id)
assert releases[0].paid_sats == 55
config = ExtensionConfig(
name=ext_id,
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
admin_only=True,
super_user_only=True,
)
mocker.patch.object(
ExtensionConfig,
"fetch_github_release_config",
mocker.AsyncMock(return_value=config),
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
assert release_info["admin_only"] is True
assert release_info["super_user_only"] is True
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_account = await get_account(admin_user.id)
assert admin_account is not None
admin_wallet = await create_wallet(
user_id=admin_account.id, wallet_name="extension sales"
)
ext_id = f"paid_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(
required=True, amount=10, wallet=admin_wallet.id
),
)
)
updated = await api_update_pay_to_enable(
ext_id,
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
account=admin_account,
)
assert updated.success is True
stored_extension = await get_installed_extension(ext_id)
assert stored_extension is not None
assert stored_extension.meta is not None
assert stored_extension.meta.pay_to_enable is not None
assert stored_extension.meta.pay_to_enable.amount == 21
enable_invoice = await create_wallet_invoice(
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
)
mocker.patch(
"lnbits.core.views.extension_api.create_invoice",
mocker.AsyncMock(return_value=enable_invoice),
)
invoice_response = await get_pay_to_enable_invoice(
ext_id,
PayToEnableInfo(amount=21),
account_id=AccountId(id=regular_user.id),
)
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.extra is not None
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
)
mocker.patch(
"lnbits.core.views.extension_api.check_transaction_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
assert enabled.success is True
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
assert user_ext.extra == UserExtensionInfo(
payment_hash_to_enable=enable_invoice.payment_hash,
paid_to_enable=True,
)
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled.success is True
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled_again.success is True
assert "already disabled" in disabled_again.message
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
Extension(code="other", is_valid=True),
]
),
)
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
assert [ext.code for ext in visible_extensions] == [ext_id]
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
admin_only=True,
super_user_only=True,
)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(return_value=[catalog_entry]),
)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
assert catalog_item["isAdminOnly"] is True
assert catalog_item["isSuperUserOnly"] is True
@pytest.mark.anyio
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
base_ext = f"base_{uuid4().hex[:8]}"
dependent_ext = f"dependent_{uuid4().hex[:8]}"
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
db_ext = f"db_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(base_ext))
await create_installed_extension(
make_installable_extension(dependent_ext, dependencies=[base_ext])
)
await create_installed_extension(make_installable_extension(uninstall_ext))
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=base_ext, is_valid=True, name="Base"),
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
]
),
)
with pytest.raises(HTTPException, match="depends on this one"):
await api_uninstall_extension(base_ext)
uninstall_mock = mocker.patch(
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
)
uninstalled = await api_uninstall_extension(uninstall_ext)
assert uninstalled.success is True
uninstall_mock.assert_awaited_once_with(uninstall_ext)
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extension",
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
deactivate_mock = mocker.patch(
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
)
activated = await api_activate_extension(base_ext)
assert activated.success is True
deactivated = await api_deactivate_extension(base_ext)
assert deactivated.success is True
activate_mock.assert_awaited_once()
deactivate_mock.assert_awaited_once()
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = owner.wallets[0]
install_invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
)
release = make_extension_release(base_ext, version="2.0.0")
payment_info = ReleasePaymentInfo(
amount=33,
pay_link=release.pay_link,
payment_hash=install_invoice.payment_hash,
payment_request=install_invoice.bolt11,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_payment_info",
mocker.AsyncMock(return_value=payment_info),
)
invoice = await get_pay_to_install_invoice(
base_ext,
CreateExtension(
ext_id=base_ext,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
cost_sats=33,
),
)
assert invoice.payment_hash == install_invoice.payment_hash
await update_migration_version(None, db_ext, 1)
drop_mock = mocker.patch(
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
)
deleted = await delete_extension_db(db_ext)
assert deleted.success is True
drop_mock.assert_awaited_once_with(ext_id=db_ext)
assert await get_db_version(db_ext) is None
@pytest.mark.anyio
async def test_extension_api_review_endpoints(mocker):
ext_id = f"review_{uuid4().hex[:8]}"
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
tags_url = f"{reviews_base}/tags"
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
create_review_url = f"{reviews_base}/reviews"
request = Request(
{
"type": "http",
"method": "GET",
"path": f"/api/v1/extension/reviews/{ext_id}",
"query_string": b"offset=0&limit=5",
"headers": [],
}
)
mock_client = _MockHTTPClient(
{
tags_url: _MockHTTPResponse(
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
),
reviews_url: _MockHTTPResponse(
json_data={
"data": [
{
"id": "1",
"name": "Alice",
"tag": "good",
"rating": 950,
"comment": "solid",
}
],
"total": 1,
}
),
create_review_url: _MockHTTPResponse(
json_data={
"payment_hash": f"hash_{uuid4().hex[:8]}",
"payment_request": "lnbc1review",
}
),
}
)
mocker.patch(
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
)
tags = await get_extension_reviews_tags()
assert tags[0].tag == "good"
reviews = await get_extension_reviews(ext_id, request)
assert reviews.total == 1
assert reviews.data[0].comment == "solid"
payment_request = await create_extension_review(
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
@pytest.mark.anyio
async def test_extension_api_enable_rejects_admin_and_super_only_extensions(
admin_user,
):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_only_ext = f"admin_{uuid4().hex[:8]}"
super_only_ext = f"super_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(admin_only_ext, admin_only=True)
)
await create_installed_extension(
make_installable_extension(super_only_ext, super_user_only=True)
)
with pytest.raises(HTTPException, match="User not authorized"):
await api_enable_extension(admin_only_ext, AccountId(id=regular_user.id))
with pytest.raises(HTTPException, match="User not authorized"):
await api_enable_extension(super_only_ext, AccountId(id=admin_user.id))
@pytest.mark.anyio
async def test_repo_manifest_flags_apply_to_repo_releases(mocker):
ext_id = f"repo_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
manifest = Manifest(
repos=[
GitHubRelease(
id=ext_id,
organisation="lnbits",
repository="tunnel_me_out",
admin_only=True,
super_user_only=True,
)
]
)
mocker.patch.object(
InstallableExtension,
"fetch_manifest",
mocker.AsyncMock(return_value=manifest),
)
mocker.patch.object(
ExtensionRelease,
"get_github_releases",
mocker.AsyncMock(return_value=[release]),
)
releases = await InstallableExtension.get_extension_releases(ext_id)
assert len(releases) == 1
assert releases[0].admin_only is True
assert releases[0].super_user_only is True
-109
View File
@@ -1,109 +0,0 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
from lnbits.core.crud.users import get_account
from lnbits.core.models.extensions import (
Extension,
UserExtension,
)
from lnbits.core.models.users import AccountId
from lnbits.core.views.extensions_builder_api import (
api_build_extension,
api_delete_extension_builder_data,
api_deploy_extension,
api_preview_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data, make_extension_release
@pytest.mark.anyio
async def test_extensions_builder_api_build_preview_and_cleanup(
tmp_path, settings: Settings, mocker, from_user
):
ext_id = f"builder_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
build_dir = tmp_path / "build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "index.txt").write_text("hello")
original_data_folder = settings.lnbits_data_folder
build_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
),
)
clean_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
)
try:
settings.lnbits_data_folder = str(tmp_path)
build_response = await api_build_extension(data)
assert Path(build_response.path).is_file()
assert build_response.filename == f"{ext_id}.zip"
preview = await api_preview_extension(data, AccountId(id=from_user.id))
assert preview.success is True
assert ext_id in preview.message
cleaned = await api_delete_extension_builder_data()
assert cleaned.success is True
clean_mock.assert_called_once()
assert build_mock.await_count == 2
finally:
settings.lnbits_data_folder = original_data_folder
@pytest.mark.anyio
async def test_extensions_builder_api_deploy_updates_user_extension(
tmp_path, settings: Settings, mocker, admin_user
):
ext_id = f"deploy_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
account = await get_account(admin_user.id)
assert account is not None
build_root = tmp_path / "deploy-root" / ext_id
build_root.mkdir(parents=True, exist_ok=True)
(build_root / "manifest.json").write_text("{}")
original_data_folder = settings.lnbits_data_folder
await create_user_extension(
UserExtension(user=account.id, extension=ext_id, active=False)
)
mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
),
)
install_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.activate_extension",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path)
deployed = await api_deploy_extension(data, account=account)
finally:
settings.lnbits_data_folder = original_data_folder
assert deployed.success is True
assert ext_id in deployed.message
install_mock.assert_awaited_once()
activate_mock.assert_awaited_once()
user_ext = await get_user_extension(account.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
-146
View File
@@ -1,146 +0,0 @@
import pytest
from httpx import AsyncClient
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
class _UnsetSecret:
pass
UNSET_SECRET = _UnsetSecret()
class FakeStripeWallet:
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
self._secret = (
"connection-token" if isinstance(secret, _UnsetSecret) else secret
)
async def create_terminal_connection_token(self) -> dict[str, str]:
if self._secret is None:
return {}
return {"secret": self._secret}
@pytest.mark.anyio
async def test_fiat_api_test_provider_and_subscription_lifecycle(
client: AsyncClient,
superuser_token: str,
adminkey_headers_from: dict[str, str],
from_wallet,
mocker: MockerFixture,
):
test_connection = mocker.patch(
"lnbits.core.views.fiat_api.test_connection",
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
)
response = await client.put(
"/api/v1/fiat/check/stripe",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["success"] is True
test_connection.assert_awaited_once_with("stripe")
provider = mocker.Mock()
provider.create_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(
ok=True,
subscription_request_id="sub-1",
checkout_session_url="https://stripe.example/checkout",
)
)
provider.cancel_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
)
get_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=provider),
)
mismatch = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"wallet_id": "wrong-wallet"},
},
)
assert mismatch.status_code == 403
created = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
},
)
assert created.status_code == 200
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
provider.create_subscription.assert_awaited_once()
assert provider.create_subscription.await_args is not None
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
cancelled = await client.delete(
"/api/v1/fiat/stripe/subscription/sub-1",
headers=adminkey_headers_from,
)
assert cancelled.status_code == 200
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
assert get_provider.await_count == 3
@pytest.mark.anyio
async def test_fiat_api_connection_token_validates_provider_configuration(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
headers = {"Authorization": f"Bearer {superuser_token}"}
not_found = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=None),
)
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert missing.status_code == 404
assert not_found.await_count == 1
unsupported_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=object()),
)
unsupported = await client.post(
"/api/v1/fiat/paypal/connection_token", headers=headers
)
assert unsupported.status_code == 400
assert unsupported_provider.await_count == 1
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
bad_wallet = FakeStripeWallet(secret=None)
bad_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=bad_wallet),
)
no_secret = await client.post(
"/api/v1/fiat/stripe/connection_token", headers=headers
)
assert no_secret.status_code == 500
assert no_secret.json()["detail"] == "Failed to create connection token"
assert bad_provider.await_count == 1
good_wallet = FakeStripeWallet(secret="tok_live")
good_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=good_wallet),
)
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
assert good_provider.await_count == 1
-142
View File
@@ -1,142 +0,0 @@
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnurl import (
LnAddress,
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
)
from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
api_lnurlscan,
api_lnurlscan_post,
api_payments_pay_lnurl,
api_perform_lnurlauth,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=pay_response),
)
scanned = await api_lnurlscan("lnurl1example")
assert isinstance(scanned, LnurlPayResponse)
assert scanned.callback == pay_response.callback
scanned_post = await api_lnurlscan_post(
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
)
assert isinstance(scanned_post, LnurlPayResponse)
assert scanned_post.callback == pay_response.callback
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
)
with pytest.raises(HTTPException, match="blocked callback"):
await api_lnurlscan("lnurl1blocked")
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
)
with pytest.raises(HTTPException, match="invalid lnurl"):
await api_lnurlscan("lnurl1invalid")
@pytest.mark.anyio
async def test_lnurl_api_auth_and_pay_flow(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
pay_response = make_lnurl_pay_response()
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
)
auth_response = LnurlAuthResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
k1="k1-value",
)
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(return_value=auth_response),
)
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
assert isinstance(authenticated, LnurlAuthResponse)
assert authenticated.k1 == "k1-value"
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
)
with pytest.raises(HTTPException, match="denied"):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
fetch_mock = mocker.patch(
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
mocker.AsyncMock(return_value=(pay_response, action_response)),
)
pay_mock = mocker.patch(
"lnbits.core.views.lnurl_api.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
paid = await api_payments_pay_lnurl(
CreateLnurlPayment(
res=pay_response, amount=2_000, unit="USD", comment="thanks"
),
wallet_info,
)
assert paid.payment_hash == payment.payment_hash
fetch_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert action_response.successAction is not None
assert pay_mock.await_args.kwargs["extra"] == {
"stored": True,
"success_action": action_response.successAction.json(),
"comment": "thanks",
"fiat_currency": "USD",
"fiat_amount": 2.0,
}
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
-366
View File
@@ -1,366 +0,0 @@
from collections.abc import Callable
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from fastapi import HTTPException
from pytest_mock.plugin import MockerFixture
from lnbits.core.views import node_api
from lnbits.db import Filters, Page
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodePayment,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.settings import Settings
from lnbits.wallets.base import Feature
class FakeNode:
def __init__(self):
self.channel = NodeChannel(
id="chan-1",
short_id="123x1x0",
peer_id="peer-1",
name="Peer One",
color="#ffffff",
state=ChannelState.ACTIVE,
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
fee_ppm=10,
fee_base_msat=1000,
)
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
self.info = NodeInfoResponse(
id="node-id",
backend_name="FakeNode",
alias="Fake Alias",
color="#ffffff",
num_peers=1,
blockheight=1,
channel_stats=ChannelStats(
counts={ChannelState.ACTIVE: 1},
avg_size=3000,
biggest_size=3000,
smallest_size=3000,
total_capacity=3000,
),
addresses=["127.0.0.1:9735"],
onchain_balance_sat=1,
onchain_confirmed_sat=1,
fees=NodeFees(total_msat=0),
balance_msat=3000,
)
self.fees_updated: tuple[str, int | None, int | None] | None = None
async def get_public_info(self) -> PublicNodeInfo:
return PublicNodeInfo(**self.info.dict())
async def get_info(self) -> NodeInfoResponse:
return self.info
async def get_channels(self) -> list[NodeChannel]:
return [self.channel]
async def get_channel(self, channel_id: str) -> NodeChannel | None:
return self.channel if channel_id == self.channel.id else None
async def open_channel(
self,
peer_id: str,
funding_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
assert peer_id == "peer-1"
assert funding_amount == 10_000
assert push_amount == 100
assert fee_rate == 5
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
) -> list[NodeChannel]:
assert short_id == self.channel.short_id
assert point is None
assert force is True
return [self.channel]
async def set_channel_fee(
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
) -> None:
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
return Page(
data=[
NodePayment(
pending=False,
amount=1,
fee=0,
memo="payment",
time=1,
preimage="11" * 32,
payment_hash="22" * 32,
)
],
total=1,
)
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
return Page(
data=[
NodeInvoice(
pending=False,
amount=1,
memo="invoice",
bolt11="lnbc1dummy",
preimage="11" * 32,
payment_hash="33" * 32,
)
],
total=1,
)
async def get_peers(self) -> list[NodePeerInfo]:
return [self.peer]
async def connect_peer(self, uri: str) -> dict[str, str]:
return {"uri": uri}
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
return {"peer_id": peer_id}
async def get_id(self) -> str:
return "fake-node-id"
class FakeFundingSource:
def __init__(
self,
features: list[Feature],
node_factory: Callable[[Any], Any] | None,
):
self.features = features
self.__node_cls__ = node_factory
class MockHTTPResponse:
def __init__(
self, json_data: dict[str, Any], status_error: Exception | None = None
):
self._json_data = json_data
self._status_error = status_error
def raise_for_status(self) -> None:
if self._status_error:
raise self._status_error
def json(self) -> dict[str, Any]:
return self._json_data
class MockHTTPClient:
def __init__(self, response: MockHTTPResponse):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int):
self.calls.append(url)
return self.response
@pytest.mark.anyio
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
original_node_ui = settings.lnbits_node_ui
original_public = settings.lnbits_public_node_ui
try:
settings.lnbits_node_ui = True
funding_source = FakeFundingSource([], None)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=funding_source,
)
with pytest.raises(HTTPException) as excinfo:
node_api.require_node()
assert excinfo.value.status_code == 501
node_enabled_source = FakeFundingSource(
[Feature.nodemanager], lambda wallet: "fake-node"
)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=node_enabled_source,
)
settings.lnbits_node_ui = False
with pytest.raises(HTTPException) as disabled:
node_api.require_node()
assert disabled.value.status_code == 503
settings.lnbits_node_ui = True
assert node_api.require_node() == "fake-node"
settings.lnbits_public_node_ui = False
with pytest.raises(HTTPException) as public_disabled:
node_api.check_public()
assert public_disabled.value.status_code == 503
finally:
settings.lnbits_node_ui = original_node_ui
settings.lnbits_public_node_ui = original_public
@pytest.mark.anyio
async def test_node_api_route_functions_with_fake_node(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = True
rank_response = MockHTTPResponse(
{
"noderank": {
"capacity": 1,
"channelcount": 2,
"age": 3,
"growth": 4,
"availability": 5,
}
}
)
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(rank_response),
)
try:
assert await node_api.api_get_ok() is None
public_info = await node_api.api_get_public_info(node=node)
assert public_info.backend_name == "FakeNode"
info = await node_api.api_get_info(node=node)
assert info is not None
assert info.id == "node-id"
channels = await node_api.api_get_channels(node=node)
assert channels is not None
assert channels[0].id == "chan-1"
channel = await node_api.api_get_channel("chan-1", node=node)
assert channel is not None
assert channel.peer_id == "peer-1"
created = await node_api.api_create_channel(
node=node,
peer_id="peer-1",
funding_amount=10_000,
push_amount=100,
fee_rate=5,
)
assert created.output_index == 0
deleted = await node_api.api_delete_channel(
short_id="123x1x0",
funding_txid=None,
output_index=None,
force=True,
node=node,
)
assert deleted is not None
assert deleted[0].id == "chan-1"
await node_api.api_set_channel_fees(
"chan-1",
node=node,
fee_ppm=42,
fee_base_msat=7,
)
assert fake_node.fees_updated == ("chan-1", 7, 42)
payments = await node_api.api_get_payments(node=node, filters=Filters())
assert payments is not None
assert payments.total == 1
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices is not None
assert invoices.total == 1
peers = await node_api.api_get_peers(node=node)
assert peers[0].id == "peer-1"
connect = await node_api.api_connect_peer(
uri="peer-1@127.0.0.1:9735", node=node
)
assert connect["uri"] == "peer-1@127.0.0.1:9735"
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
assert disconnect["peer_id"] == "peer-1"
rank = await node_api.api_get_1ml_stats(node=node)
assert rank is not None
rank_data = node_api.NodeRank.parse_obj(rank)
assert rank_data.channelcount == 2
finally:
settings.lnbits_node_ui_transactions = original_transactions
@pytest.mark.anyio
async def test_node_api_transactions_and_rank_errors(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = False
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(
MockHTTPResponse(
{},
status_error=httpx.HTTPStatusError(
"not found", request=request, response=httpx.Response(404)
),
)
),
)
try:
with pytest.raises(HTTPException) as payments:
await node_api.api_get_payments(node=node, filters=Filters())
assert payments.value.status_code == 503
with pytest.raises(HTTPException) as invoices:
await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices.value.status_code == 503
with pytest.raises(HTTPException) as rank:
await node_api.api_get_1ml_stats(node=node)
assert rank.value.status_code == 404
assert rank.value.detail == "Node not found on 1ml.com"
finally:
settings.lnbits_node_ui_transactions = original_transactions
-187
View File
@@ -1,187 +0,0 @@
import json
from hashlib import sha256
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnbits.core.crud.payments import create_payment
from lnbits.core.models import Account, CreateInvoice, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
api_all_payments_paginated,
api_payments_cancel,
api_payments_counting_stats,
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_payment_api_stats_and_all_paginated(admin_user):
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
count_stats = await api_payments_counting_stats(
count_by="tag",
filters=Filters(limit=20),
account_id=AccountId(id=first_user.id),
)
assert any(item.field == "coffee" for item in count_stats)
assert all(item.field != "books" for item in count_stats)
wallet_stats = await api_payments_wallets_stats(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
daily_stats = await api_payments_daily_stats(
account_id=AccountId(id=first_user.id),
filters=Filters(limit=20),
)
assert daily_stats
assert daily_stats[0].payments_count >= 1
regular_page = await api_all_payments_paginated(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert regular_page.total >= 2
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
admin_page = await api_all_payments_paginated(
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
)
wallet_ids = {payment.wallet_id for payment in admin_page.data}
assert first_wallet.id in wallet_ids
assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
preimage = "11" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
await _create_payment(
wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
settle_mock = mocker.patch(
"lnbits.core.views.payment_api.settle_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=True,
checking_id="settled",
preimage=preimage,
)
),
)
settled = await api_payments_settle(
SettleInvoice(preimage=preimage),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert settled.success is True
settle_mock.assert_awaited_once()
cancel_hash = (uuid4().hex * 2)[:64]
await _create_payment(
wallet.id,
amount_msat=2_000,
payment_hash=cancel_hash,
status=PaymentState.PENDING,
)
cancel_mock = mocker.patch(
"lnbits.core.views.payment_api.cancel_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=False,
checking_id="cancelled",
error_message="cancelled",
)
),
)
cancelled = await api_payments_cancel(
CancelInvoice(payment_hash=cancel_hash),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert cancelled.failed is True
cancel_mock.assert_awaited_once()
async def _create_payment(
wallet_id: str,
*,
amount_msat: int,
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash or uuid4().hex,
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
),
status=status,
)
return checking_id
-71
View File
@@ -1,71 +0,0 @@
from http import HTTPStatus
import pytest
from httpx import AsyncClient
@pytest.mark.anyio
async def test_tinyurl_api_create_get_redirect_and_delete(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
inkey_headers_from: dict[str, str],
inkey_headers_to: dict[str, str],
):
created = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/landing", "endless": "true"},
headers=adminkey_headers_from,
)
assert created.status_code == HTTPStatus.OK
tinyurl = created.json()
assert tinyurl["url"] == "https://example.com/landing"
assert tinyurl["endless"] is True
fetched = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_from,
)
assert fetched.status_code == HTTPStatus.OK
assert fetched.json()["id"] == tinyurl["id"]
wrong_wallet = await client.get(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=inkey_headers_to,
)
assert wrong_wallet.status_code == HTTPStatus.NOT_FOUND
assert wrong_wallet.json()["detail"] == "Unable to fetch tinyurl"
redirect = await client.get(f"/t/{tinyurl['id']}")
assert redirect.status_code == HTTPStatus.TEMPORARY_REDIRECT
assert redirect.headers["location"] == "https://example.com/landing"
deleted = await client.delete(
f"/api/v1/tinyurl/{tinyurl['id']}",
headers=adminkey_headers_from,
)
assert deleted.status_code == HTTPStatus.OK
assert deleted.json()["deleted"] is True
missing_redirect = await client.get(f"/t/{tinyurl['id']}")
assert missing_redirect.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.anyio
async def test_tinyurl_api_reuses_existing_entries_for_same_wallet(
client: AsyncClient,
adminkey_headers_from: dict[str, str],
):
first = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
second = await client.post(
"/api/v1/tinyurl",
params={"url": "https://example.com/reused"},
headers=adminkey_headers_from,
)
assert first.status_code == HTTPStatus.OK
assert second.status_code == HTTPStatus.OK
assert first.json()["id"] == second.json()["id"]
-102
View File
@@ -1,102 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet, get_wallets
from lnbits.core.models import UpdateBalance
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.user_api import api_users_create_user_wallet
from lnbits.settings import settings
@pytest.mark.anyio
async def test_user_api_toggle_admin_and_update_balance(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
promote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert promote.status_code == 200
assert settings.is_admin_user(user.id) is True
demote = await http_client.put(
f"/users/api/v1/user/{user.id}/admin",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert demote.status_code == 200
assert settings.is_admin_user(user.id) is False
balance = await http_client.put(
"/users/api/v1/balance",
headers={"Authorization": f"Bearer {superuser_token}"},
json=UpdateBalance(id=wallet.id, amount=7).dict(),
)
assert balance.status_code == 200
assert balance.json()["success"] is True
updated_wallet = await get_wallet(wallet.id)
assert updated_wallet is not None
assert updated_wallet.balance == 7
@pytest.mark.anyio
async def test_user_api_get_wallets_and_delete_all_wallets(
http_client: AsyncClient, superuser_token: str
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="spare")
wallets = await http_client.get(
f"/users/api/v1/user/{user.id}/wallet",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert wallets.status_code == 200
wallet_ids = {wallet["id"] for wallet in wallets.json()}
assert extra_wallet.id in wallet_ids
deleted = await http_client.delete(
f"/users/api/v1/user/{user.id}/wallets",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
active_wallets = await get_wallets(user.id, deleted=False)
assert active_wallets == []
@pytest.mark.anyio
async def test_user_api_create_wallet_validates_currency():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
with pytest.raises(ValueError, match="Currency 'INVALID' not allowed."):
await api_users_create_user_wallet(user.id, name="invalid", currency="INVALID")
wallet = await api_users_create_user_wallet(
user.id, name="eur wallet", currency="EUR"
)
assert wallet.currency == "EUR"
-190
View File
@@ -1,190 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.wallets import create_wallet, get_wallet
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
@pytest.mark.anyio
async def test_wallet_api_share_invite_reject_accept_and_delete(
http_client: AsyncClient,
):
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"owner_{uuid4().hex[:8]}",
email=f"owner_{uuid4().hex[:8]}@lnbits.com",
)
)
invited = await create_user_account(
Account(
id=uuid4().hex,
username=f"invited_{uuid4().hex[:8]}",
email=f"invited_{uuid4().hex[:8]}@lnbits.com",
)
)
source_wallet = owner.wallets[0]
owner_headers = _admin_headers(source_wallet.adminkey)
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
assert share_request["request_id"]
reject = await http_client.delete(
f"/api/v1/wallet/share/invite/{share_request['request_id']}?usr={invited.id}"
)
assert reject.status_code == 200
assert reject.json()["success"] is True
removed_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert removed_share.status_code == 200
assert removed_share.json()["success"] is True
invite = await http_client.put(
"/api/v1/wallet/share/invite",
headers=owner_headers,
json={
"username": invited.username,
"permissions": ["view-payments", "receive-payments"],
"status": "invite_sent",
},
)
assert invite.status_code == 200
share_request = invite.json()
create_shared = await http_client.post(
f"/api/v1/wallet?usr={invited.id}",
json={
"name": "shared",
"wallet_type": "lightning-shared",
"shared_wallet_id": source_wallet.id,
},
)
assert create_shared.status_code == 200
mirror_wallet = create_shared.json()
assert mirror_wallet["shared_wallet_id"] == source_wallet.id
approve = await http_client.put(
"/api/v1/wallet/share",
headers=owner_headers,
json={
"username": invited.username,
"shared_with_wallet_id": mirror_wallet["id"],
"permissions": ["view-payments", "receive-payments"],
"status": "approved",
},
)
assert approve.status_code == 200
assert approve.json()["status"] == "approved"
delete_share = await http_client.delete(
f"/api/v1/wallet/share/{share_request['request_id']}",
headers=owner_headers,
)
assert delete_share.status_code == 200
assert delete_share.json()["success"] is True
assert await get_wallet(mirror_wallet["id"]) is None
@pytest.mark.anyio
async def test_wallet_api_paginated_update_reset_and_store_paylinks(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
extra_wallet = await create_wallet(user_id=user.id, wallet_name="second")
first_wallet = user.wallets[0]
page = await http_client.get(f"/api/v1/wallet/paginated?usr={user.id}&limit=10")
assert page.status_code == 200
assert page.json()["total"] >= 2
renamed = await http_client.put(
"/api/v1/wallet/renamed-wallet",
headers=_admin_headers(first_wallet.adminkey),
)
assert renamed.status_code == 200
assert renamed.json()["name"] == "renamed-wallet"
original_admin_key = extra_wallet.adminkey
reset = await http_client.put(
f"/api/v1/wallet/reset/{extra_wallet.id}?usr={user.id}"
)
assert reset.status_code == 200
assert reset.json()["adminkey"] != original_admin_key
stored = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(reset.json()["adminkey"]),
json={
"links": [
{
"lnurl": "alice@example.com",
"label": "Alice",
}
]
},
)
assert stored.status_code == 200
assert stored.json()[0]["lnurl"] == "alice@example.com"
forbidden = await http_client.put(
f"/api/v1/wallet/stored_paylinks/{extra_wallet.id}",
headers=_admin_headers(first_wallet.adminkey),
json={"links": []},
)
assert forbidden.status_code == 403
updated = await http_client.patch(
"/api/v1/wallet",
headers=_admin_headers(first_wallet.adminkey),
json={"icon": "bolt", "color": "amber", "pinned": True},
)
assert updated.status_code == 200
assert updated.json()["extra"]["icon"] == "bolt"
assert updated.json()["extra"]["color"] == "amber"
assert updated.json()["extra"]["pinned"] is True
@pytest.mark.anyio
async def test_wallet_api_shared_wallet_requires_source_id(http_client: AsyncClient):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.post(
f"/api/v1/wallet?usr={user.id}",
json={"wallet_type": "lightning-shared"},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Shared wallet ID is required for shared wallets."
)
def _admin_headers(adminkey: str) -> dict[str, str]:
return {"X-Api-Key": adminkey, "Content-type": "application/json"}
-31
View File
@@ -1,31 +0,0 @@
from unittest.mock import AsyncMock
from pytest_mock.plugin import MockerFixture
def test_websocket_api_connects_and_updates(test_client):
with test_client.websocket_connect("/api/v1/ws/demo-item") as websocket:
response = test_client.post("/api/v1/ws/demo-item", params={"data": "hello"})
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "hello"}
assert websocket.receive_text() == "hello"
response = test_client.get("/api/v1/ws/demo-item/world")
assert response.status_code == 200
assert response.json() == {"sent": True, "data": "world"}
assert websocket.receive_text() == "world"
def test_websocket_api_reports_send_failures(test_client, mocker: MockerFixture):
mocker.patch(
"lnbits.core.views.websocket_api.websocket_manager.send",
AsyncMock(side_effect=RuntimeError("boom")),
)
post_response = test_client.post("/api/v1/ws/demo-item", params={"data": "oops"})
assert post_response.status_code == 200
assert post_response.json() == {"sent": False, "data": "oops"}
get_response = test_client.get("/api/v1/ws/demo-item/oops")
assert get_response.status_code == 200
assert get_response.json() == {"sent": False, "data": "oops"}
+1 -40
View File
@@ -1,6 +1,4 @@
import asyncio import asyncio
import copy
import inspect
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import uuid4 from uuid import uuid4
@@ -25,12 +23,7 @@ from lnbits.core.services import create_user_account, update_wallet_balance
from lnbits.core.services.payments import create_wallet_invoice from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.views.auth_api import first_install from lnbits.core.views.auth_api import first_install
from lnbits.db import DB_TYPE, SQLITE, Database from lnbits.db import DB_TYPE, SQLITE, Database
from lnbits.settings import ( from lnbits.settings import AuthMethods, FiatProviderLimits, Settings
AuthMethods,
EditableSettings,
FiatProviderLimits,
Settings,
)
from lnbits.settings import settings as lnbits_settings from lnbits.settings import settings as lnbits_settings
from lnbits.wallets.fake import FakeWallet from lnbits.wallets.fake import FakeWallet
from tests.helpers import ( from tests.helpers import (
@@ -40,23 +33,6 @@ from tests.helpers import (
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
ADMIN_USER_ID = uuid4().hex ADMIN_USER_ID = uuid4().hex
# Snapshot the initialized module settings instead of a fresh Settings() instance.
# The module settings include runtime-populated values like `version`.
_PURE_SETTINGS = copy.deepcopy(lnbits_settings)
_PURE_SETTINGS_FIELDS = tuple(
sorted(
{
field_name
for field_name in Settings.readonly_fields()
if field_name != "super_user"
}
| {
name
for name in inspect.signature(EditableSettings).parameters
if not name.startswith("_")
}
)
)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -96,7 +72,6 @@ async def app(settings: Settings):
username="superadmin", username="superadmin",
password="secret1234", password="secret1234",
password_repeat="secret1234", password_repeat="secret1234",
first_install_token=settings.first_install_token,
) )
) )
@@ -352,21 +327,7 @@ async def new_user(username: str | None = None) -> User:
return user return user
def _restore_pure_settings(settings: Settings):
for field_name in _PURE_SETTINGS_FIELDS:
setattr(
settings, field_name, copy.deepcopy(getattr(_PURE_SETTINGS, field_name))
)
def _settings_cleanup(settings: Settings): def _settings_cleanup(settings: Settings):
_restore_pure_settings(settings)
settings.auth_https_only = False
settings.lnbits_data_folder = "./tests/data"
settings.bundle_assets = True
settings.lnbits_admin_ui = True
settings.lnbits_extensions_default_install = []
settings.lnbits_extensions_deactivate_all = True
settings.lnbits_allow_new_accounts = True settings.lnbits_allow_new_accounts = True
settings.lnbits_allowed_users = [] settings.lnbits_allowed_users = []
settings.auth_allowed_methods = AuthMethods.all() settings.auth_allowed_methods = AuthMethods.all()
-24
View File
@@ -1,24 +0,0 @@
networks:
lnbits-net:
services:
postgres-db:
container_name: postgres_container_test
image: postgres:14
environment:
POSTGRES_DB: lnbits
POSTGRES_USER: lnbits
POSTGRES_PASSWORD: lnbits
PGDATA: /lnbits/data/postgres
volumes:
- ./data/postgres_test:/lnbits/data/postgres
ports:
- '5444:5432'
restart: unless-stopped
healthcheck:
test: ['CMD-SHELL', 'pg_isready -d lnbits_db']
interval: 5s
timeout: 5s
retries: 5
networks:
- lnbits-net
+1 -150
View File
@@ -1,33 +1,8 @@
import random import random
import string import string
from io import BytesIO
from bolt11.types import MilliSatoshi from pydantic.v1 import BaseModel
from fastapi import UploadFile
from httpx import AsyncClient
from lnurl import LnurlPayResponse
from lnurl.types import CallbackUrl, LnurlPayMetadata
from PIL import Image
from pydantic import BaseModel, parse_obj_as
from starlette.datastructures import Headers
from lnbits.core.models.extensions import (
ExtensionMeta,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
)
from lnbits.core.models.extensions_builder import (
ActionFields,
ClientDataFields,
DataField,
DataFields,
ExtensionData,
OwnerDataFields,
PublicPageFields,
SettingsFields,
)
from lnbits.wallets import get_funding_source, set_funding_source from lnbits.wallets import get_funding_source, set_funding_source
@@ -65,131 +40,7 @@ async def get_random_invoice_data():
return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"} return {"out": False, "amount": 10, "memo": f"test_memo_{get_random_string(10)}"}
def get_png_bytes(*, color: str = "blue", size: tuple[int, int] = (32, 32)) -> bytes:
image = Image.new("RGB", size, color=color)
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def make_upload_file(
contents: bytes,
*,
filename: str,
content_type: str | None,
) -> UploadFile:
headers = (
Headers({"content-type": content_type}) if content_type is not None else None
)
return UploadFile(BytesIO(contents), filename=filename, headers=headers)
async def get_user_token_headers(client: AsyncClient, user_id: str) -> dict[str, str]:
response = await client.post("/api/v1/auth/usr", json={"usr": user_id})
client.cookies.clear()
return {
"Authorization": f"Bearer {response.json()['access_token']}",
"Content-type": "application/json",
}
def make_extension_data(ext_id: str = "demoext") -> ExtensionData:
return ExtensionData(
id=ext_id,
name="Demo Extension",
stub_version="0.1.0",
short_description="Generated extension",
owner_data=DataFields(
name="OwnerData",
fields=[DataField(name="wallet_id", type="wallet")],
),
client_data=DataFields(
name="ClientData",
fields=[DataField(name="amount", type="int")],
),
settings_data=SettingsFields(name="SettingsData", fields=[]),
public_page=PublicPageFields(
owner_data_fields=OwnerDataFields(),
client_data_fields=ClientDataFields(),
action_fields=ActionFields(),
),
)
def make_extension_release(ext_id: str, version: str = "1.0.0") -> ExtensionRelease:
return ExtensionRelease(
name=ext_id,
version=version,
archive=f"https://example.com/{ext_id}.zip",
source_repo="org/repo",
hash=f"hash-{ext_id}",
details_link=f"https://example.com/{ext_id}/details.json",
repo=f"https://github.com/org/{ext_id}",
icon=f"/{ext_id}/static/icon.png",
pay_link=f"https://pay.example/{ext_id}",
is_github_release=False,
is_version_compatible=True,
)
def make_installable_extension(
ext_id: str,
*,
version: str = "1.0.0",
compatible: bool = True,
active: bool = True,
pay_to_enable: PayToEnableInfo | None = None,
dependencies: list[str] | None = None,
payments: list[ReleasePaymentInfo] | None = None,
admin_only: bool = False,
super_user_only: bool = False,
) -> InstallableExtension:
release = make_extension_release(ext_id, version)
release.is_version_compatible = compatible
release.admin_only = admin_only
release.super_user_only = super_user_only
return InstallableExtension(
id=ext_id,
name=f"Extension {ext_id}",
version=version,
active=active,
short_description="Demo extension",
icon=release.icon,
meta=ExtensionMeta(
installed_release=release,
pay_to_enable=pay_to_enable,
dependencies=dependencies or [],
payments=payments or [],
admin_only=admin_only,
super_user_only=super_user_only,
),
)
def make_lnurl_pay_response(
*,
min_sendable_msat: int = 1_000,
max_sendable_msat: int = 10_000,
text: str = "Test payment",
identifier: str = "alice@example.com",
callback: str = "https://example.com/callback",
) -> LnurlPayResponse:
return LnurlPayResponse(
callback=parse_obj_as(CallbackUrl, callback),
minSendable=MilliSatoshi(min_sendable_msat),
maxSendable=MilliSatoshi(max_sendable_msat),
metadata=LnurlPayMetadata(
f"[["
f'"text/plain","{text}"'
f"],["
f'"text/identifier","{identifier}"'
f"]]"
),
)
set_funding_source() set_funding_source()
funding_source = get_funding_source() funding_source = get_funding_source()
is_fake: bool = funding_source.__class__.__name__ == "FakeWallet" is_fake: bool = funding_source.__class__.__name__ == "FakeWallet"
is_regtest: bool = not is_fake is_regtest: bool = not is_fake
+6 -6
View File
@@ -4,7 +4,7 @@ from http import HTTPStatus
import pytest import pytest
from httpx import AsyncClient, Headers from httpx import AsyncClient, Headers
from pydantic import parse_obj_as from pydantic import TypeAdapter
from lnbits import bolt11 from lnbits import bolt11
from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel from lnbits.nodes.base import ChannelPoint, ChannelState, NodeChannel
@@ -111,7 +111,7 @@ async def test_get_channel(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json()) channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice( ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE] [channel for channel in channels if channel.state == ChannelState.ACTIVE]
) )
@@ -121,7 +121,7 @@ async def test_get_channel(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}") response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200 assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json()) channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.id == ch.id assert channel.id == ch.id
@@ -131,7 +131,7 @@ async def test_set_channel_fees(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
channels = parse_obj_as(list[NodeChannel], response.json()) channels = TypeAdapter(list[NodeChannel]).validate_python(response.json())
ch = random.choice( ch = random.choice(
[channel for channel in channels if channel.state == ChannelState.ACTIVE] [channel for channel in channels if channel.state == ChannelState.ACTIVE]
@@ -146,7 +146,7 @@ async def test_set_channel_fees(node_client):
response = await node_client.get(f"/node/api/v1/channels/{ch.id}") response = await node_client.get(f"/node/api/v1/channels/{ch.id}")
assert response.status_code == 200 assert response.status_code == 200
channel = parse_obj_as(NodeChannel, response.json()) channel = TypeAdapter(NodeChannel).validate_python(response.json())
assert channel.fee_ppm == 69 assert channel.fee_ppm == 69
assert channel.fee_base_msat == 42 assert channel.fee_base_msat == 42
@@ -162,7 +162,7 @@ async def test_channel_management(node_client):
await asyncio.sleep(3) await asyncio.sleep(3)
response = await node_client.get("/node/api/v1/channels") response = await node_client.get("/node/api/v1/channels")
assert response.status_code == 200 assert response.status_code == 200
return parse_obj_as(list[NodeChannel], response.json()) return TypeAdapter(list[NodeChannel]).validate_python(response.json())
data = await get_channels() data = await get_channels()
close = random.choice( close = random.choice(

Some files were not shown because too many files have changed in this diff Show More