Compare commits

...
32 Commits
Author SHA1 Message Date
dni ⚡andGitHub 475ae5736e chore: update to v1.0.0-rc9 (#3043) 2025-03-12 12:29:01 +01:00
PatMulliganandGitHub d7f0f4da0c create backwards compatibility for <=v1.0 (#3041)
in v0.12.12, the key is "payment_request" instead of the new "bolt11"
2025-03-12 12:27:24 +01:00
Tiago VasconcelosandGitHub f7b3444be4 fix: unfuck the swap mess (#3023) 2025-03-12 12:14:20 +01:00
ArcandGitHub 28e32a54a4 tweak: use qr code image for favicon as well (#3031)
use qr code image for favicon as well
2025-03-12 12:12:43 +01:00
ArcandGitHub 1833adc0a1 Tweak: moved custom denomination to fakewallet funding source (#3030) 2025-03-12 12:10:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
b759ec7468 chore(deps): bump axios from 1.7.7 to 1.8.2 (#3042)
* chore(deps): bump axios from 1.7.7 to 1.8.2

Bumps [axios](https://github.com/axios/axios) from 1.7.7 to 1.8.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.7...v1.8.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:10:27 +01:00
2661d12ff1 tweak: fixes service fee badge (#3029)
* tweak: fixes service fee badge

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:08:08 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>dni ⚡
ed419f27ac chore(deps): bump vue-i18n from 10.0.5 to 10.0.6 (#3022)
* chore(deps): bump vue-i18n from 10.0.5 to 10.0.6

Bumps [vue-i18n](https://github.com/intlify/vue-i18n/tree/HEAD/packages/vue-i18n) from 10.0.5 to 10.0.6.
- [Release notes](https://github.com/intlify/vue-i18n/releases)
- [Changelog](https://github.com/intlify/vue-i18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/intlify/vue-i18n/commits/v10.0.6/packages/vue-i18n)

---
updated-dependencies:
- dependency-name: vue-i18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* bundle

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: dni  <office@dnilabs.com>
2025-03-12 12:01:18 +01:00
dni ⚡andGitHub c3efd48108 chore: use latest 1.X poetry version (#3039) 2025-03-11 09:54:36 +01:00
ArcandGitHub 4a28c22d79 tweak: fix 'test email' btn (#3026) 2025-03-10 18:37:11 +00:00
ArcandGitHub 0d0eb36de4 tweak: fix funding source hide (#3027)
added to title rather than all the inputs
2025-03-10 18:36:48 +00:00
ArcandGitHub 2aa73bfe75 tweak: punchy bitcoin theme (#3028) 2025-03-10 18:36:23 +00:00
Vlad StanandGitHub ebd080192c feat: return payment status in the websocket response (#3025) 2025-03-10 12:48:40 +02:00
Tiago VasconcelosandGitHub 41abe63f18 hide text on small screen (#3021) 2025-03-07 14:23:46 +00:00
dni ⚡andGitHub f61471b0f2 feat: add test email and improve on email notifications (#3018) 2025-03-05 09:53:40 +02:00
Vlad StanandGitHub 9bd037b6e7 [fix] settings cleanup (#3013) 2025-03-04 16:30:54 +02:00
a4ca88b6a4 tweak: dont show full id on wallet select box (#3017)
* tweak: dont show full id on wallet select box
* chore bundle

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-04 11:23:02 +01:00
ArcandGitHub f2b9aafc51 Tweak: Changed naming for password area (#3016) 2025-03-04 11:13:41 +01:00
Vlad StanandGitHub ec77a00f49 feat: only check the current page (#3015) 2025-03-03 16:28:55 +02:00
991e0db50b Feat: email notifications (#3007)
* Feat: email notifications / email db backup

Because not using a simple smtp connection is silly

* Added form

* broken, and annoying bug

Why wont the input display?!?!?

* make

* Encourage protonmail use, because its great.

* feat: small UI polishing

* chore: make bundle

---------

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2025-03-03 12:59:08 +00:00
fe9b62e8a8 Fix: don't show register if new users not allowed (#3010)
* enforce allow new user flag on UI
* chore: bundle
* ensure we get a boolean

---------

Co-authored-by: dni  <office@dnilabs.com>
2025-03-03 12:53:58 +01:00
dni ⚡andGitHub c5964436b5 fix: python versions were defined incorrectly and remove 3.9 (#3006)
* chore: update requirements to python3.10
* chore: format RUF007 rule for py310
* wrongly specified python version
python3.13 there is a greenlet build error
2025-03-03 12:53:30 +01:00
Vlad StanandGitHub 4ce14e2312 fix: rate lmitter init (#3011) 2025-02-28 12:50:50 +01:00
PatMulliganandGitHub 7021add193 Feat (crypto): Add URL-safe encoding option to AESCipher (#2984) 2025-02-28 11:17:53 +02:00
Tiago VasconcelosandGitHub 4be01a405c compare not assign (#3009)
fix missing = sign
2025-02-28 09:31:49 +02:00
Vlad StanandGitHub ebe9ff700f [fix] admin errors (#3004) 2025-02-27 11:00:15 +02:00
dni ⚡andGitHub a3619d40c6 fix: paying an invoice does not send a webhook. #2472 (#2999)
* fix: paying an invoice does not send a webhook. #2472
closes #2472
2025-02-27 09:23:49 +01:00
Vlad StanandGitHub 048aff3db4 [fix]: various issues discovered during testing (#3002)
* fix: prevent invalid calls to exchange APIs
* fix: audit conditions
* fix: audit filtering
2025-02-27 07:45:29 +01:00
Tiago VasconcelosandGitHub 89d85a0d7e [Fix] Denomination 3 characters (#3001)
* fix: denomination must be 3 chars ISO
2025-02-26 16:34:45 +01:00
5aa1f9b0f8 [feat] filter payments in wallet (#2997)
* fix: search payments
* fix rogue button
* feat: UI polishing
* feat: better charts

---------

Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
2025-02-26 11:34:57 +01:00
Tiago VasconcelosandGitHub 5dc1705fa6 Fix a typo in reset password logic (#2998) 2025-02-25 13:52:03 +02:00
ArcandGitHub 0fc657c55c tweak: embit needs to be declared (#2996) 2025-02-24 15:05:20 +00:00
60 changed files with 1283 additions and 918 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ LNBITS_ADMIN_USERS=""
# SUPER_USER=""
# Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin"
LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
# Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
+1 -1
View File
@@ -7,7 +7,7 @@ inputs:
default: "3.10"
poetry-version:
description: "Poetry Version"
default: "1.7.0"
default: "1.8.5"
node-version:
description: "Node Version"
default: "20.x"
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
poetry run pip install pyinstaller
# Build the LNbits binary
poetry run pyinstaller --onefile --name lnbits --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
poetry run pyinstaller --onefile --name lnbits --hidden-import=embit --collect-all embit --collect-all lnbits --collect-all sqlalchemy --collect-all aiosqlite --hidden-import=passlib.handlers.bcrypt $(poetry run which lnbits)
cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop
+1 -1
View File
@@ -226,7 +226,7 @@ Problems installing? These commands have helped us install LNbits.
sudo apt install pkg-config libffi-dev libpq-dev
# build essentials for debian/ubuntu
sudo apt install python3.9-dev gcc build-essential
sudo apt install python3.10-dev gcc build-essential
# if the secp256k1 build fails:
# if you used poetry
+1
View File
@@ -81,6 +81,7 @@ async def startup(app: FastAPI):
# setup admin settings
await check_admin_settings()
core_app_extra.register_new_ratelimiter()
await check_webpush_settings()
# check extensions after restart
+15 -16
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field
@@ -10,16 +9,16 @@ from lnbits.settings import settings
class AuditEntry(BaseModel):
component: Optional[str] = None
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_type: Optional[str] = None
request_method: Optional[str] = None
request_details: Optional[str] = None
response_code: Optional[str] = None
component: str | None = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_type: str | None = None
request_method: str | None = None
request_details: str | None = None
response_code: str | None = None
duration: float
delete_at: Optional[datetime] = None
delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data):
@@ -42,12 +41,12 @@ class AuditFilters(FilterModel):
"duration",
]
ip_address: Optional[str] = None
user_id: Optional[str] = None
path: Optional[str] = None
request_method: Optional[str] = None
response_code: Optional[str] = None
component: Optional[str] = None
ip_address: str | None = None
user_id: str | None = None
path: str | None = None
request_method: str | None = None
response_code: str | None = None
component: str | None = None
class AuditCountStat(BaseModel):
+62 -64
View File
@@ -7,7 +7,7 @@ import os
import shutil
import zipfile
from pathlib import Path
from typing import Any, Optional
from typing import Any
import httpx
from loguru import logger
@@ -29,17 +29,17 @@ class ExplicitRelease(BaseModel):
archive: str
hash: str
dependencies: list[str] = []
repo: Optional[str]
icon: Optional[str]
short_description: Optional[str]
min_lnbits_version: Optional[str]
max_lnbits_version: Optional[str]
html_url: Optional[str] # todo: release_url
warning: Optional[str]
info_notification: Optional[str]
critical_notification: Optional[str]
details_link: Optional[str]
pay_link: Optional[str]
repo: str | None
icon: str | None
short_description: str | None
min_lnbits_version: str | None
max_lnbits_version: str | None
html_url: str | None # todo: release_url
warning: str | None
info_notification: str | None
critical_notification: str | None
details_link: str | None
pay_link: str | None
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -77,9 +77,9 @@ class ExtensionConfig(BaseModel):
name: str
short_description: str
tile: str = ""
warning: Optional[str] = ""
min_lnbits_version: Optional[str]
max_lnbits_version: Optional[str]
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -87,7 +87,7 @@ class ExtensionConfig(BaseModel):
@classmethod
async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]:
) -> ExtensionConfig | None:
config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
)
@@ -97,28 +97,28 @@ class ExtensionConfig(BaseModel):
class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None
pay_link: Optional[str] = None
payment_hash: Optional[str] = None
payment_request: Optional[str] = None
amount: int | None = None
pay_link: str | None = None
payment_hash: str | None = None
payment_request: str | None = None
class PayToEnableInfo(BaseModel):
amount: int = 0
required: bool = False
wallet: Optional[str] = None
wallet: str | None = None
class UserExtensionInfo(BaseModel):
paid_to_enable: Optional[bool] = False
payment_hash_to_enable: Optional[str] = None
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
class UserExtension(BaseModel):
user: str
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
extra: UserExtensionInfo | None = None
@property
def is_paid(self) -> bool:
@@ -140,10 +140,10 @@ class UserExtension(BaseModel):
class Extension(BaseModel):
code: str
is_valid: bool
name: Optional[str] = None
short_description: Optional[str] = None
tile: Optional[str] = None
upgrade_hash: Optional[str] = ""
name: str | None = None
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
@property
def module_name(self) -> str:
@@ -176,21 +176,21 @@ class ExtensionRelease(BaseModel):
archive: str
source_repo: str
is_github_release: bool = False
hash: Optional[str] = None
min_lnbits_version: Optional[str] = None
max_lnbits_version: Optional[str] = None
is_version_compatible: Optional[bool] = True
html_url: Optional[str] = None
description: Optional[str] = None
warning: Optional[str] = None
repo: Optional[str] = None
icon: Optional[str] = None
details_link: Optional[str] = None
hash: str | None = None
min_lnbits_version: str | None = None
max_lnbits_version: str | None = None
is_version_compatible: bool | None = True
html_url: str | None = None
description: str | None = None
warning: str | None = None
repo: str | None = None
icon: str | None = None
details_link: str | None = None
pay_link: Optional[str] = None
cost_sats: Optional[int] = None
paid_sats: Optional[int] = 0
payment_hash: Optional[str] = None
pay_link: str | None = None
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
@property
def archive_url(self) -> str:
@@ -208,8 +208,8 @@ class ExtensionRelease(BaseModel):
self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info(
self, amount: Optional[int] = None
) -> Optional[ReleasePaymentInfo]:
self, amount: int | None = None
) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info."
try:
@@ -281,7 +281,7 @@ class ExtensionRelease(BaseModel):
return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]:
async def fetch_release_details(cls, details_link: str) -> dict | None:
try:
async with httpx.AsyncClient() as client:
@@ -300,12 +300,12 @@ class ExtensionRelease(BaseModel):
class ExtensionMeta(BaseModel):
installed_release: Optional[ExtensionRelease] = None
latest_release: Optional[ExtensionRelease] = None
pay_to_enable: Optional[PayToEnableInfo] = None
installed_release: ExtensionRelease | None = None
latest_release: ExtensionRelease | None = None
pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = []
archive: Optional[str] = None
archive: str | None = None
featured: bool = False
@@ -313,11 +313,11 @@ class InstallableExtension(BaseModel):
id: str
name: str
version: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
active: bool | None = False
short_description: str | None = None
icon: str | None = None
stars: int = 0
meta: Optional[ExtensionMeta] = None
meta: ExtensionMeta | None = None
@property
def hash(self) -> str:
@@ -452,7 +452,7 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True)
def check_latest_version(self, release: Optional[ExtensionRelease]):
def check_latest_version(self, release: ExtensionRelease | None):
if not release:
return
if not self.meta or not self.meta.latest_release:
@@ -465,9 +465,7 @@ class InstallableExtension(BaseModel):
):
self.meta.latest_release = release
def find_existing_payment(
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
if not pay_link or not self.meta or not self.meta.payments:
return None
return next(
@@ -507,7 +505,7 @@ class InstallableExtension(BaseModel):
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
) -> Optional[InstallableExtension]:
) -> InstallableExtension | None:
try:
repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository
@@ -546,7 +544,7 @@ class InstallableExtension(BaseModel):
)
@classmethod
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]:
def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
@@ -657,7 +655,7 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional[ExtensionRelease]:
) -> ExtensionRelease | None:
all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
)
@@ -708,8 +706,8 @@ class CreateExtension(BaseModel):
archive: str
source_repo: str
version: str
cost_sats: Optional[int] = 0
payment_hash: Optional[str] = None
cost_sats: int | None = 0
payment_hash: str | None = None
class ExtensionDetailsRequest(BaseModel):
@@ -718,7 +716,7 @@ class ExtensionDetailsRequest(BaseModel):
version: str
async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {settings.lnbits_ext_github_token}"
@@ -730,7 +728,7 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
return resp.json()
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
def icon_to_github_url(source_repo: str, path: str | None) -> str:
if not path:
return ""
_, _, *rest = path.split("/")
+38 -38
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from typing import Literal, Optional
from typing import Literal
from fastapi import Query
from pydantic import BaseModel, Field, validator
@@ -28,16 +28,16 @@ class PaymentState(str, Enum):
class PaymentExtra(BaseModel):
comment: Optional[str] = None
success_action: Optional[str] = None
lnurl_response: Optional[str] = None
comment: str | None = None
success_action: str | None = None
lnurl_response: str | None = None
class PayInvoice(BaseModel):
payment_request: str
description: Optional[str] = None
max_sat: Optional[int] = None
extra: Optional[dict] = {}
description: str | None = None
max_sat: int | None = None
extra: dict | None = {}
class CreatePayment(BaseModel):
@@ -46,10 +46,10 @@ class CreatePayment(BaseModel):
bolt11: str
amount_msat: int
memo: str
extra: Optional[dict] = {}
preimage: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
extra: dict | None = {}
preimage: str | None = None
expiry: datetime | None = None
webhook: str | None = None
fee: int = 0
@@ -61,13 +61,13 @@ class Payment(BaseModel):
fee: int
bolt11: str
status: str = PaymentState.PENDING
memo: Optional[str] = None
expiry: Optional[datetime] = None
webhook: Optional[str] = None
webhook_status: Optional[int] = None
preimage: Optional[str] = None
tag: Optional[str] = None
extension: Optional[str] = None
memo: str | None = None
expiry: datetime | None = None
webhook: str | None = None
webhook_status: int | None = None
preimage: str | None = None
tag: str | None = None
extension: str | None = None
time: 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))
@@ -129,16 +129,16 @@ class PaymentFilters(FilterModel):
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: Optional[str]
tag: Optional[str]
checking_id: Optional[str]
status: str | None
tag: str | None
checking_id: str | None
amount: int
fee: int
memo: Optional[str]
memo: str | None
time: datetime
preimage: Optional[str]
payment_hash: Optional[str]
wallet_id: Optional[str]
preimage: str | None
payment_hash: str | None
wallet_id: str | None
class PaymentDataPoint(BaseModel):
@@ -173,11 +173,11 @@ class PaymentWalletStats(BaseModel):
class PaymentDailyStats(BaseModel):
date: datetime
balance: float = 0
balance_in: Optional[float] = 0
balance_out: Optional[float] = 0
balance_in: float | None = 0
balance_out: float | None = 0
payments_count: int = 0
count_in: Optional[int] = 0
count_out: Optional[int] = 0
count_in: int | None = 0
count_out: int | None = 0
fee: float = 0
@@ -190,7 +190,7 @@ class PaymentHistoryPoint(BaseModel):
class DecodePayment(BaseModel):
data: str
filter_fields: Optional[list[str]] = []
filter_fields: list[str] | None = []
class CreateInvoice(BaseModel):
@@ -198,14 +198,14 @@ class CreateInvoice(BaseModel):
internal: bool = False
out: bool = True
amount: float = Query(None, ge=0)
memo: Optional[str] = None
description_hash: Optional[str] = None
unhashed_description: Optional[str] = None
expiry: Optional[int] = None
extra: Optional[dict] = None
webhook: Optional[str] = None
bolt11: Optional[str] = None
lnurl_callback: Optional[str] = None
memo: str | None = None
description_hash: str | None = None
unhashed_description: str | None = None
expiry: int | None = None
extra: dict | None = None
webhook: str | None = None
bolt11: str | None = None
lnurl_callback: str | None = None
@validator("unit")
@classmethod
+43 -44
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import Query
@@ -17,16 +16,16 @@ from .wallets import Wallet
class UserExtra(BaseModel):
email_verified: Optional[bool] = False
first_name: Optional[str] = None
last_name: Optional[str] = None
display_name: Optional[str] = None
picture: Optional[str] = None
email_verified: bool | None = False
first_name: str | None = None
last_name: str | None = None
display_name: str | None = None
picture: str | None = None
# Auth provider, possible values:
# - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider
provider: Optional[str] = "lnbits" # auth provider
provider: str | None = "lnbits" # auth provider
class EndpointAccess(BaseModel):
@@ -50,13 +49,13 @@ class AccessControlList(BaseModel):
endpoints: list[EndpointAccess] = []
token_id_list: list[SimpleItem] = []
def get_endpoint(self, path: str) -> Optional[EndpointAccess]:
def get_endpoint(self, path: str) -> EndpointAccess | None:
for e in self.endpoints:
if e.path == path:
return e
return None
def get_token_by_id(self, token_id: str) -> Optional[SimpleItem]:
def get_token_by_id(self, token_id: str) -> SimpleItem | None:
for t in self.token_id_list:
if t.id == token_id:
return t
@@ -71,7 +70,7 @@ class UserAcls(BaseModel):
access_control_list: list[AccessControlList] = []
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def get_acl_by_id(self, acl_id: str) -> Optional[AccessControlList]:
def get_acl_by_id(self, acl_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.id == acl_id:
return acl
@@ -82,7 +81,7 @@ class UserAcls(BaseModel):
acl for acl in self.access_control_list if acl.id != acl_id
]
def get_acl_by_token_id(self, token_id: str) -> Optional[AccessControlList]:
def get_acl_by_token_id(self, token_id: str) -> AccessControlList | None:
for acl in self.access_control_list:
if acl.get_token_by_id(token_id):
return acl
@@ -91,10 +90,10 @@ class UserAcls(BaseModel):
class Account(BaseModel):
id: str
username: Optional[str] = None
password_hash: Optional[str] = None
pubkey: Optional[str] = None
email: Optional[str] = None
username: str | None = None
password_hash: str | None = None
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -134,10 +133,10 @@ class Account(BaseModel):
class AccountOverview(Account):
transaction_count: Optional[int] = 0
wallet_count: Optional[int] = 0
balance_msat: Optional[int] = 0
last_payment: Optional[datetime] = None
transaction_count: int | None = 0
wallet_count: int | None = 0
balance_msat: int | None = 0
last_payment: datetime | None = None
class AccountFilters(FilterModel):
@@ -151,20 +150,20 @@ class AccountFilters(FilterModel):
"last_payment",
]
email: Optional[str] = None
user: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
wallet_id: Optional[str] = None
email: str | None = None
user: str | None = None
username: str | None = None
pubkey: str | None = None
wallet_id: str | None = None
class User(BaseModel):
id: str
created_at: datetime
updated_at: datetime
email: Optional[str] = None
username: Optional[str] = None
pubkey: Optional[str] = None
email: str | None = None
username: str | None = None
pubkey: str | None = None
extensions: list[str] = []
wallets: list[Wallet] = []
admin: bool = False
@@ -176,7 +175,7 @@ class User(BaseModel):
def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets]
def get_wallet(self, wallet_id: str) -> Optional[Wallet]:
def get_wallet(self, wallet_id: str) -> Wallet | None:
w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None
@@ -192,33 +191,33 @@ class User(BaseModel):
class RegisterUser(BaseModel):
email: Optional[str] = Query(default=None)
email: str | None = Query(default=None)
username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel):
id: Optional[str] = Query(default=None)
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=None, min_length=2, max_length=20)
password: Optional[str] = Query(default=None, min_length=8, max_length=50)
password_repeat: Optional[str] = Query(default=None, min_length=8, max_length=50)
id: str | None = Query(default=None)
email: str | None = Query(default=None)
username: str | None = Query(default=None, min_length=2, max_length=20)
password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: str | None = Query(default=None, min_length=8, max_length=50)
pubkey: str = Query(default=None, max_length=64)
extensions: Optional[list[str]] = None
extra: Optional[UserExtra] = None
extensions: list[str] | None = None
extra: UserExtra | None = None
class UpdateUser(BaseModel):
user_id: str
email: Optional[str] = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None
email: str | None = Query(default=None)
username: str | None = Query(default=..., min_length=2, max_length=20)
extra: UserExtra | None = None
class UpdateUserPassword(BaseModel):
user_id: str
password_old: Optional[str] = None
password_old: str | None = None
password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50)
username: str = Query(default=..., min_length=2, max_length=20)
@@ -252,10 +251,10 @@ class LoginUsernamePassword(BaseModel):
class AccessTokenPayload(BaseModel):
sub: str
usr: Optional[str] = None
email: Optional[str] = None
auth_time: Optional[int] = 0
api_token_id: Optional[str] = None
usr: str | None = None
email: str | None = None
auth_time: int | None = 0
api_token_id: str | None = None
class UpdateBalance(BaseModel):
+2 -3
View File
@@ -5,7 +5,6 @@ import hmac
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field
@@ -37,7 +36,7 @@ class Wallet(BaseModel):
deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: Optional[str] = None
currency: str | None = None
balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra()
@@ -67,7 +66,7 @@ class Wallet(BaseModel):
class CreateWallet(BaseModel):
name: Optional[str] = None
name: str | None = None
class KeyType(Enum):
+2 -5
View File
@@ -3,7 +3,7 @@ from .funding_source import (
switch_to_voidwallet,
)
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification
from .notifications import enqueue_notification, send_payment_notification
from .payments import (
calculate_fiat_amounts,
check_transaction_status,
@@ -12,7 +12,6 @@ from .payments import (
fee_reserve,
fee_reserve_total,
pay_invoice,
send_payment_notification,
service_fee,
update_pending_payments,
update_wallet_balance,
@@ -25,7 +24,6 @@ from .users import (
check_admin_settings,
create_user_account,
create_user_account_no_ckeck,
init_admin_settings,
update_user_account,
update_user_extensions,
)
@@ -40,6 +38,7 @@ __all__ = [
"perform_lnurlauth",
# notifications
"enqueue_notification",
"send_payment_notification",
# payments
"calculate_fiat_amounts",
"check_transaction_status",
@@ -48,7 +47,6 @@ __all__ = [
"fee_reserve",
"fee_reserve_total",
"pay_invoice",
"send_payment_notification",
"service_fee",
"update_pending_payments",
"update_wallet_balance",
@@ -59,7 +57,6 @@ __all__ = [
"check_admin_settings",
"create_user_account",
"create_user_account_no_ckeck",
"init_admin_settings",
"update_user_account",
"update_user_extensions",
# websockets
+205
View File
@@ -1,15 +1,30 @@
import asyncio
import json
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from http import HTTPStatus
from typing import Optional, Tuple
import httpx
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_webpush_subscriptions,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.models import Payment, Wallet
from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES,
NotificationMessage,
NotificationType,
)
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm
from lnbits.core.services.websockets import websocket_manager
from lnbits.helpers import check_callback_url, is_valid_email_address
from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key
@@ -50,6 +65,12 @@ async def send_notification(
logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {e}")
try:
if settings.lnbits_email_notifications_enabled:
await send_email_notification(message)
logger.debug(f"Sent email notification: {message_type}")
except Exception as e:
logger.error(f"Error sending email notification {message_type}: {e}")
async def send_nostr_notification(message: str) -> dict:
@@ -90,6 +111,58 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
return response.json()
async def send_email_notification(
message: str, subject: str = "LNbits Notification"
) -> dict:
if not settings.lnbits_email_notifications_enabled:
return {"status": "error", "message": "Email notifications are disabled"}
try:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_username,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
subject,
message,
)
return {"status": "ok"}
except Exception as e:
logger.error(f"Error sending email notification: {e}")
return {"status": "error", "message": str(e)}
async def send_email(
server: str,
port: int,
username: str,
password: str,
from_email: str,
to_emails: list[str],
subject: str,
message: str,
) -> bool:
if not is_valid_email_address(from_email):
raise ValueError(f"Invalid from email address: {from_email}")
if len(to_emails) == 0:
raise ValueError("No email addresses provided")
for email in to_emails:
if not is_valid_email_address(email):
raise ValueError(f"Invalid email address: {email}")
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
username = username if len(username) > 0 else from_email
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(username, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
return True
def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit
@@ -123,3 +196,135 @@ def _notification_message_to_text(
text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, text
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending websocket payment notification", e)
try:
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending chat payment notification", e)
try:
await send_payment_push_notification(wallet, payment)
except Exception as e:
logger.error("Error sending push payment notification", e)
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
payment_notification = json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending, "status": payment.status}),
payment.payment_hash,
)
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
"wallet_name": wallet.name,
"amount_sats": amount_sats,
"fiat_value_fmt": "",
}
if payment.extra.get("wallet_fiat_currency", None):
amount_fiat = payment.extra.get("wallet_fiat_amount", None)
currency = payment.extra.get("wallet_fiat_currency", None)
values["fiat_value_fmt"] = f"`{amount_fiat}`*{currency}* / "
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
async def send_payment_push_notification(wallet: Wallet, payment: Payment):
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
await send_push_notification(subscription, title, body, url)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
(
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8"))
if settings.lnbits_webpush_privkey
else None
),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
+15 -65
View File
@@ -1,5 +1,4 @@
import asyncio
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Optional
@@ -12,8 +11,6 @@ from loguru import logger
from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError
@@ -45,7 +42,7 @@ from ..models import (
PaymentState,
Wallet,
)
from .websockets import websocket_manager
from .notifications import send_payment_notification
async def pay_invoice(
@@ -181,13 +178,20 @@ async def update_pending_payments(wallet_id: str):
exclude_uncheckable=True,
)
for payment in pending_payments:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
elif status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
await update_pending_payment(payment)
async def update_pending_payment(payment: Payment) -> bool:
status = await payment.check_status()
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment)
return True
if status.success:
payment.status = PaymentState.SUCCESS
await update_payment(payment)
return True
return False
def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
@@ -278,60 +282,6 @@ async def update_wallet_balance(
await internal_invoice_queue.put(payment.checking_id)
async def send_payment_notification(wallet: Wallet, payment: Payment):
try:
await send_ws_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending websocket payment notification", e)
try:
send_chat_payment_notification(wallet, payment)
except Exception as e:
logger.error("Error sending chat payment notification", e)
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
# TODO: websocket message should be a clean payment model
# await websocket_manager.send_data(payment.json(), wallet.inkey)
# TODO: figure out why we send the balance with the payment here.
# cleaner would be to have a separate message for the balance
# and send it with the id of the wallet so wallets can subscribe to it
payment_notification = json.dumps(
{
"wallet_balance": wallet.balance,
# use pydantic json serialization to get the correct datetime format
"payment": json.loads(payment.json()),
},
)
await websocket_manager.send_data(payment_notification, wallet.inkey)
await websocket_manager.send_data(payment_notification, wallet.adminkey)
await websocket_manager.send_data(
json.dumps({"pending": payment.pending}), payment.payment_hash
)
def send_chat_payment_notification(wallet: Wallet, payment: Payment):
amount_sats = abs(payment.sat)
values: dict = {
"wallet_id": wallet.id,
"wallet_name": wallet.name,
"amount_sats": amount_sats,
"fiat_value_fmt": "",
}
if payment.extra.get("wallet_fiat_currency", None):
amount_fiat = payment.extra.get("wallet_fiat_amount", None)
currency = payment.extra.get("wallet_fiat_currency", None)
values["fiat_value_fmt"] = f"`{amount_fiat}`*{currency}* / "
if payment.is_out:
if amount_sats >= settings.lnbits_notification_outgoing_payment_amount_sats:
enqueue_notification(NotificationType.outgoing_payment, values)
else:
if amount_sats >= settings.lnbits_notification_incoming_payment_amount_sats:
enqueue_notification(NotificationType.incoming_payment, values)
async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
):
-9
View File
@@ -8,7 +8,6 @@ from lnbits.core.models.extensions import UserExtension
from lnbits.settings import (
EditableSettings,
SuperSettings,
send_admin_user_to_saas,
settings,
)
@@ -154,14 +153,6 @@ async def check_admin_settings():
with open(Path(settings.lnbits_data_folder) / ".super_user", "w") as file:
file.write(settings.super_user)
# callback for saas
if (
settings.lnbits_saas_callback
and settings.lnbits_saas_secret
and settings.lnbits_saas_instance_id
):
send_admin_user_to_saas()
account = await get_account(settings.super_user)
if account and account.extra and account.extra.provider == "env":
settings.first_install = True
+3 -65
View File
@@ -2,25 +2,19 @@ import asyncio
import traceback
from typing import Callable, Coroutine
import httpx
from loguru import logger
from lnbits.core.crud import (
create_audit_entry,
get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
)
from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count
from lnbits.core.models import AuditEntry, Payment
from lnbits.core.models import AuditEntry
from lnbits.core.models.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
send_payment_notification,
)
from lnbits.core.services.funding_source import (
check_balance_delta_changed,
check_server_balance_against_node,
@@ -29,11 +23,11 @@ from lnbits.core.services.funding_source import (
from lnbits.core.services.notifications import (
enqueue_notification,
process_next_notification,
send_payment_notification,
)
from lnbits.db import Filters
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_unique_task, send_push_notification
from lnbits.tasks import create_unique_task
from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue = asyncio.Queue()
@@ -106,62 +100,6 @@ async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue):
wallet = await get_wallet(payment.wallet_id)
if wallet:
await send_payment_notification(wallet, payment)
# dispatch webhook
if payment.webhook and not payment.webhook_status:
await dispatch_webhook(payment)
# dispatch push notification
await send_payment_push_notification(payment)
async def dispatch_webhook(payment: Payment):
"""
Dispatches the webhook to the webhook url.
"""
logger.debug("sending webhook", payment.webhook)
if not payment.webhook:
return await mark_webhook_sent(payment.payment_hash, -1)
headers = {"User-Agent": settings.user_agent}
async with httpx.AsyncClient(headers=headers) as client:
data = payment.dict()
try:
check_callback_url(payment.webhook)
r = await client.post(payment.webhook, json=data, timeout=40)
r.raise_for_status()
await mark_webhook_sent(payment.payment_hash, r.status_code)
except httpx.HTTPStatusError as exc:
await mark_webhook_sent(payment.payment_hash, exc.response.status_code)
logger.warning(
f"webhook returned a bad status_code: {exc.response.status_code} "
f"while requesting {exc.request.url!r}."
)
except httpx.RequestError:
await mark_webhook_sent(payment.payment_hash, -1)
logger.warning(f"Could not send webhook to {payment.webhook}")
async def send_payment_push_notification(payment: Payment):
wallet = await get_wallet(payment.wallet_id)
if wallet:
subscriptions = await get_webpush_subscriptions_for_user(wallet.user)
amount = int(payment.amount / 1000)
title = f"LNbits: {wallet.name}"
body = f"You just received {amount} sat{'s'[:amount^1]}!"
if payment.memo:
body += f"\r\n{payment.memo}"
for subscription in subscriptions:
# todo: review permissions when user-id-only not allowed
# todo: replace all this logic with websockets?
url = (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
await send_push_notification(subscription, title, body, url)
async def wait_for_audit_data():
@@ -2,7 +2,14 @@
<q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<q-separator class="q-mt-md q-mb-sm"></q-separator>
<div class="row q-col-gutter-md">
<div class="col-sm-12 col-md-6">
@@ -39,12 +46,13 @@
</q-item-section>
<q-item-section>
<q-input
type="password"
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_nostr_notifications_private_key"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
@@ -117,7 +125,7 @@
</q-item-section>
<q-item-section>
<q-input
type="password"
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_telegram_notifications_access_token"
/>
@@ -139,6 +147,174 @@
</q-item-section>
</q-item>
</div>
<div class="col-sm-12">
<q-separator></q-separator>
</div>
<div class="col-12">
<strong v-text="$t('notifications_email_config')"></strong>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_enable_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_enable_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_email_notifications_enabled"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<div v-if="formData.lnbits_email_notifications_enabled" class="row">
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_email"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_username')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_username_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_username"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_password')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_password_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_password"
/>
</q-item-section>
</q-item>
<q-item>
<q-btn
@click="sendTestEmail()"
:label="$t('notifications_send_test_email')"
color="primary"
class="q-mt-md"
></q-btn>
</q-item>
</div>
<div class="col">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_to_emails')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_to_emails_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="emailNotificationAddress"
@keydown.enter="addEmailNotificationAddress"
>
<q-btn
@click="addEmailNotificationAddress()"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="to_email in formData.lnbits_email_notifications_to_emails"
:key="to_email"
removable
@remove="removeEmailNotificationAddress(to_email)"
color="primary"
text-color="white"
><span class="ellipsis" v-text="to_email"></span
></q-chip>
</div>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server_port')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_port_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_port"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label
v-text="$t('notifications_send_email_server')"
></q-item-label>
<q-item-label
caption
v-text="$t('notifications_send_email_server_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
:type="hideInputToggle ? 'password' : 'text'"
filled
v-model="formData.lnbits_email_notifications_server"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
<q-separator> </q-separator>
<h6 class="q-mb-sm">
@@ -53,16 +53,6 @@
:label="$t('lnbits_wallet')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('denomination')"></span></p>
<q-input
filled
type="text"
v-model="formData.lnbits_denomination"
label="sats"
:hint="$t('denomination_hint')"
></q-input>
</div>
<div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input
+5 -5
View File
@@ -45,7 +45,7 @@
<div class="row">
<div class="col">
<h4 class="q-my-none">
<span v-text="$t('password_config')"></span>
<span v-text="$t('password')"></span>
</h4>
</div>
<div class="col">
@@ -102,7 +102,7 @@
unelevated
color="primary"
class="float-right"
:label="$t('change_password')"
:label="$t('update_password')"
>
</q-btn>
</q-card-section>
@@ -110,7 +110,7 @@
<q-card-section>
<div class="col q-mb-sm">
<h4 class="q-my-none">
<span v-text="$t('pubkey')"></span>
Nostr <span v-text="$t('pubkey')"></span>
</h4>
</div>
<q-input
@@ -287,7 +287,7 @@
</q-btn>
<q-btn
@click="showUpdateCredentials()"
:label="$t('update_credentials')"
:label="$t('change_password')"
filled
color="primary"
class="float-right"
@@ -356,7 +356,7 @@
flat
@click="themeChoiceFunc('bitcoin')"
icon="circle"
color="orange"
color="deep-orange"
size="md"
><q-tooltip>bitcoin</q-tooltip>
</q-btn>
+31 -16
View File
@@ -59,6 +59,9 @@
}
}
</style>
<script>
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
</script>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %}
<q-page-container>
@@ -84,18 +87,20 @@
>
{{SITE_TITLE}}
</h5>
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
<template v-if="$q.screen.gt.sm">
<h6
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_TAGLINE}}
</h6>
<p
class="q-my-sm"
v-if="'{{LNBITS_SHOW_HOME_PAGE_ELEMENTS}}' == 'True'"
>
{{SITE_DESCRIPTION}}
</p>
</template>
<!-- <div
class="gt-sm"
v-html="formatDescription"
@@ -145,7 +150,7 @@
{%else%}
<username-password
v-if="authMethod != 'user-id-only'"
:allowed_new_users="{{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }}"
:allowed_new_users="allowedRegister"
:auth-methods="{{ LNBITS_AUTH_METHODS }}"
:auth-action="authAction"
v-model:user-name="username"
@@ -160,7 +165,10 @@
class="text-center text-grey-6"
v-if="authAction !== 'reset'"
>
<p v-if="authAction === 'login'" class="q-mb-none">
<p
v-if="authAction === 'login' && allowedRegister"
class="q-mb-none"
>
Not registered? Create an
<span
class="text-secondary cursor-pointer"
@@ -168,7 +176,14 @@
>Account</span
>
</p>
<p v-else class="q-mb-none">
<p
v-else-if="authAction === 'login' && !allowedRegister"
class="q-mb-none"
>
<span v-text="$t('new_user_not_allowed')"></span>
</p>
<p v-else-if="authAction === 'register'" class="q-mb-none">
Aready have an account?
<span
class="text-secondary cursor-pointer"
@@ -180,7 +195,7 @@
</username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only
:allowed_new_users="{{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }}"
:allowed_new_users="allowedRegister"
v-model:usr="usr"
v-model:wallet="walletName"
:auth-action="authAction"
+7 -7
View File
@@ -726,6 +726,13 @@
</div>
<div v-if="g.fiatTracking">
<div v-if="isFiatPriority">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
<div v-else style="opacity: 0.75">
<div class="text-h5 text-italic">
<span
v-text="parse.invoice.fiatAmount"
@@ -733,13 +740,6 @@
></span>
</div>
</div>
<div v-else style="opacity: 0.75">
<h5 class="q-my-none text-bold">
<span
v-text="walletFormatBalance(parse.invoice.sat)"
></span>
</h5>
</div>
</div>
</div>
<q-separator></q-separator>
@@ -35,6 +35,7 @@
<q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width v-if="g.user.super_user"></q-th>
<q-th auto-width></q-th>
<q-th
auto-width
@@ -47,12 +48,14 @@
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance
:wallet_id="props.row.id"
@credit-value="handleBalanceUpdate"
class="q-mr-md"
></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn
round
icon="menu"
+13
View File
@@ -16,6 +16,7 @@ from lnbits.core.services import (
get_balance_delta,
update_cached_settings,
)
from lnbits.core.services.notifications import send_email_notification
from lnbits.core.services.settings import dict_to_settings
from lnbits.decorators import check_admin, check_super_user
from lnbits.server import server_restart
@@ -50,6 +51,18 @@ async def api_monitor():
}
@admin_router.get(
"/api/v1/testemail",
name="TestEmail",
description="send a test email to the admin",
dependencies=[Depends(check_admin)],
)
async def api_test_email():
return await send_email_notification(
"This is a LNbits test email.", "LNbits Test Email"
)
@admin_router.get("/api/v1/settings", response_model=Optional[AdminSettings])
async def api_get_settings(
user: User = Depends(check_admin),
+1 -2
View File
@@ -1,5 +1,4 @@
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
from urllib.parse import urlencode, urlparse
@@ -37,7 +36,7 @@ generic_router = APIRouter(
@generic_router.get("/favicon.ico", response_class=FileResponse)
async def favicon():
return FileResponse(Path("lnbits", "static", "favicon.ico"))
return RedirectResponse(settings.lnbits_qr_logo)
@generic_router.get("/", response_class=HTMLResponse)
+8 -2
View File
@@ -37,7 +37,10 @@ from lnbits.core.models import (
Wallet,
)
from lnbits.core.models.users import User
from lnbits.core.services.payments import get_payments_daily_stats
from lnbits.core.services.payments import (
get_payments_daily_stats,
update_pending_payment,
)
from lnbits.db import Filters, Page
from lnbits.decorators import (
WalletTypeInfo,
@@ -180,11 +183,14 @@ async def api_payments_paginated(
key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)),
):
await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated(
wallet_id=key_info.wallet.id,
filters=filters,
)
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
return page
+35 -35
View File
@@ -8,7 +8,7 @@ import time
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin
from typing import Any, Generic, Literal, TypeVar, get_origin
from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator
@@ -65,8 +65,8 @@ def get_placeholder(model: Any, field: str) -> str:
class Compat:
type: Optional[str] = "<inherited>"
schema: Optional[str] = "<inherited>"
type: str | None = "<inherited>"
schema: str | None = "<inherited>"
def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}:
@@ -167,8 +167,8 @@ class Connection(Compat):
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
values: dict | None = None,
model: type[TModel] | None = None,
) -> list[TModel]:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -183,8 +183,8 @@ class Connection(Compat):
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
values: dict | None = None,
model: type[TModel] | None = None,
) -> TModel:
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -211,11 +211,11 @@ class Connection(Compat):
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
where: list[str] | None = None,
values: dict | None = None,
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
) -> Page[TModel]:
if not filters:
filters = Filters()
@@ -268,7 +268,7 @@ class Connection(Compat):
total=count,
)
async def execute(self, query: str, values: Optional[dict] = None):
async def execute(self, query: str, values: dict | None = None):
params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params)
await self.conn.commit()
@@ -350,8 +350,8 @@ class Database(Compat):
async def fetchall(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
values: dict | None = None,
model: type[TModel] | None = None,
) -> list[TModel]:
async with self.connect() as conn:
return await conn.fetchall(query, values, model)
@@ -359,8 +359,8 @@ class Database(Compat):
async def fetchone(
self,
query: str,
values: Optional[dict] = None,
model: Optional[type[TModel]] = None,
values: dict | None = None,
model: type[TModel] | None = None,
) -> TModel:
async with self.connect() as conn:
return await conn.fetchone(query, values, model)
@@ -378,16 +378,16 @@ class Database(Compat):
async def fetch_page(
self,
query: str,
where: Optional[list[str]] = None,
values: Optional[dict] = None,
filters: Optional[Filters] = None,
model: Optional[type[TModel]] = None,
group_by: Optional[list[str]] = None,
where: list[str] | None = None,
values: dict | None = None,
filters: Filters | None = None,
model: type[TModel] | None = None,
group_by: list[str] | None = None,
) -> Page[TModel]:
async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: Optional[dict] = None):
async def execute(self, query: str, values: dict | None = None):
async with self.connect() as conn:
return await conn.execute(query, values)
@@ -445,7 +445,7 @@ class Operator(Enum):
class FilterModel(BaseModel):
__search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None
__sort_fields__: list[str] | None = None
T = TypeVar("T")
@@ -461,8 +461,8 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]):
field: str
op: Operator = Operator.EQ
model: Optional[type[TFilterModel]]
values: Optional[dict] = None
model: type[TFilterModel] | None
values: dict | None = None
@classmethod
def parse_query(
@@ -517,15 +517,15 @@ class Filters(BaseModel, Generic[TFilterModel]):
"""
filters: list[Filter[TFilterModel]] = []
search: Optional[str] = None
search: str | None = None
offset: Optional[int] = None
limit: Optional[int] = None
offset: int | None = None
limit: int | None = None
sortby: Optional[str] = None
direction: Optional[Literal["asc", "desc"]] = None
sortby: str | None = None
direction: Literal["asc", "desc"] | None = None
model: Optional[type[TFilterModel]] = None
model: type[TFilterModel] | None = None
@root_validator(pre=True)
def validate_sortby(cls, values):
@@ -547,7 +547,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
stmt += f"OFFSET {self.offset}"
return stmt
def where(self, where_stmts: Optional[list[str]] = None) -> str:
def where(self, where_stmts: list[str] | None = None) -> str:
if not where_stmts:
where_stmts = []
if self.filters:
@@ -567,7 +567,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return ""
def values(self, values: Optional[dict] = None) -> dict:
def values(self, values: dict | None = None) -> dict:
if not values:
values = {}
if self.filters:
@@ -641,7 +641,7 @@ def model_to_dict(model: BaseModel) -> dict:
return _dict
def dict_to_submodel(model: type[TModel], value: Union[dict, str]) -> Optional[TModel]:
def dict_to_submodel(model: type[TModel], value: dict | str) -> TModel | None:
"""convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str):
if value == "null":
+22 -11
View File
@@ -10,7 +10,6 @@ from urllib.parse import urlparse
import jinja2
import jwt
import shortuuid
from fastapi import Request
from fastapi.routing import APIRoute
from packaging import version
from pydantic.schema import field_schema
@@ -225,18 +224,34 @@ def create_access_token(data: dict, token_expire_minutes: Optional[int] = None)
return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
"""Encrypt message with the internal secret key"""
def encrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
"""
Encrypt message with the internal secret key
Args:
m: Message to encrypt
urlsafe: Whether to use URL-safe base64 encoding
"""
if not m:
return None
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode())
return AESCipher(key=settings.auth_secret_key).encrypt(m.encode(), urlsafe=urlsafe)
def decrypt_internal_message(m: Optional[str] = None) -> Optional[str]:
"""Decrypt message with the internal secret key"""
def decrypt_internal_message(
m: Optional[str] = None, urlsafe: bool = False
) -> Optional[str]:
"""
Decrypt message with the internal secret key
Args:
m: Message to decrypt
urlsafe: Whether the message uses URL-safe base64 encoding
"""
if not m:
return None
return AESCipher(key=settings.auth_secret_key).decrypt(m)
return AESCipher(key=settings.auth_secret_key).decrypt(m, urlsafe=urlsafe)
def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
@@ -328,7 +343,3 @@ def path_segments(path: str) -> list[str]:
def normalize_path(path: Optional[str]) -> str:
path = path or ""
return "/" + "/".join(path_segments(path))
def normalized_path(request: Request) -> str:
return "/" + "/".join(path_segments(request.url.path))
+3 -2
View File
@@ -134,8 +134,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
assert response
return response
finally:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration, request_details)
if request_details:
duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration, request_details)
async def _log_audit(
self,
+33 -33
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from pydantic import BaseModel
@@ -15,10 +15,10 @@ if TYPE_CHECKING:
class NodePeerInfo(BaseModel):
id: str
alias: Optional[str] = None
color: Optional[str] = None
last_timestamp: Optional[int] = None
addresses: Optional[list[str]] = None
alias: str | None = None
color: str | None = None
last_timestamp: int | None = None
addresses: list[str] | None = None
class ChannelState(Enum):
@@ -47,20 +47,20 @@ class NodeChannel(BaseModel):
balance: ChannelBalance
state: ChannelState
# could be optional for closing/pending channels on lndrest
id: Optional[str] = None
short_id: Optional[str] = None
point: Optional[ChannelPoint] = None
name: Optional[str] = None
color: Optional[str] = None
fee_ppm: Optional[int] = None
fee_base_msat: Optional[int] = None
id: str | None = None
short_id: str | None = None
point: ChannelPoint | None = None
name: str | None = None
color: str | None = None
fee_ppm: int | None = None
fee_base_msat: int | None = None
class ChannelStats(BaseModel):
counts: dict[ChannelState, int]
avg_size: int
biggest_size: Optional[int]
smallest_size: Optional[int]
biggest_size: int | None
smallest_size: int | None
total_capacity: int
@classmethod
@@ -95,9 +95,9 @@ class ChannelStats(BaseModel):
class NodeFees(BaseModel):
total_msat: int
daily_msat: Optional[int] = None
weekly_msat: Optional[int] = None
monthly_msat: Optional[int] = None
daily_msat: int | None = None
weekly_msat: int | None = None
monthly_msat: int | None = None
class PublicNodeInfo(BaseModel):
@@ -121,25 +121,25 @@ class NodeInfoResponse(PublicNodeInfo):
class NodePayment(BaseModel):
pending: bool
amount: int
fee: Optional[int] = None
memo: Optional[str] = None
fee: int | None = None
memo: str | None = None
time: int
bolt11: Optional[str] = None
preimage: Optional[str]
bolt11: str | None = None
preimage: str | None
payment_hash: str
expiry: Optional[float] = None
destination: Optional[NodePeerInfo] = None
expiry: float | None = None
destination: NodePeerInfo | None = None
class NodeInvoice(BaseModel):
pending: bool
amount: int
memo: Optional[str]
memo: str | None
bolt11: str
preimage: Optional[str]
preimage: str | None
payment_hash: str
paid_at: Optional[int] = None
expiry: Optional[int] = None
paid_at: int | None = None
expiry: int | None = None
class NodeInvoiceFilters(FilterModel):
@@ -154,7 +154,7 @@ class Node(ABC):
def __init__(self, wallet: Wallet):
self.wallet = wallet
self.id: Optional[str] = None
self.id: str | None = None
@property
def name(self):
@@ -203,22 +203,22 @@ class Node(ABC):
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
raise NotImplementedError
@abstractmethod
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
):
raise NotImplementedError
@abstractmethod
async def get_channel(self, channel_id: str) -> Optional[NodeChannel]:
async def get_channel(self, channel_id: str) -> NodeChannel | None:
raise NotImplementedError
@abstractmethod
+6 -6
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from http import HTTPStatus
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from fastapi import HTTPException
@@ -119,8 +119,8 @@ class CoreLightningNode(Node):
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
try:
result = await self.ln_rpc(
@@ -173,8 +173,8 @@ class CoreLightningNode(Node):
@catch_rpc_errors
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
):
if not short_id:
@@ -229,7 +229,7 @@ class CoreLightningNode(Node):
await self.ln_rpc("setchannel", channel_id, feebase=base_msat, feeppm=ppm)
@catch_rpc_errors
async def get_channel(self, channel_id: str) -> Optional[NodeChannel]:
async def get_channel(self, channel_id: str) -> NodeChannel | None:
channels = await self.get_channels()
for channel in channels:
if channel.id == channel_id:
+7 -9
View File
@@ -4,7 +4,7 @@ import asyncio
import base64
import json
from http import HTTPStatus
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
from fastapi import HTTPException
from httpx import HTTPStatusError
@@ -60,9 +60,7 @@ def _parse_channel_point(raw: str) -> ChannelPoint:
class LndRestNode(Node):
wallet: LndRestWallet
async def request(
self, method: str, path: str, json: Optional[dict] = None, **kwargs
):
async def request(self, method: str, path: str, json: dict | None = None, **kwargs):
response = await self.wallet.client.request(
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
)
@@ -131,8 +129,8 @@ class LndRestNode(Node):
self,
peer_id: str,
local_amount: int,
push_amount: Optional[int] = None,
fee_rate: Optional[int] = None,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
response = await self.request(
"POST",
@@ -176,8 +174,8 @@ class LndRestNode(Node):
async def close_channel(
self,
short_id: Optional[str] = None,
point: Optional[ChannelPoint] = None,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
):
if short_id:
@@ -218,7 +216,7 @@ class LndRestNode(Node):
},
)
async def get_channel(self, channel_id: str) -> Optional[NodeChannel]:
async def get_channel(self, channel_id: str) -> NodeChannel | None:
channel_info = await self.get(f"/v1/graph/edge/{channel_id}")
info = await self.get("/v1/getinfo")
if info["identity_pubkey"] == channel_info["node1_pub"]:
+152 -160
View File
@@ -4,16 +4,16 @@ import importlib
import importlib.metadata
import inspect
import json
import os
import re
from datetime import datetime, timezone
from enum import Enum
from hashlib import sha256
from os import path
from pathlib import Path
from time import gmtime, strftime, time
from typing import Any, Optional
from typing import Any
from uuid import uuid4
import httpx
from loguru import logger
from pydantic import BaseModel, BaseSettings, Extra, Field, validator
@@ -77,7 +77,7 @@ class RedirectPath(BaseModel):
other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.header_filters.items()))
def find_in_conflict(self, others: list[RedirectPath]) -> Optional[RedirectPath]:
def find_in_conflict(self, others: list[RedirectPath]) -> RedirectPath | None:
for other in others:
if self.in_conflict(other):
return other
@@ -153,7 +153,7 @@ class InstalledExtensionsSettings(LNbitsSettings):
def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]:
) -> RedirectPath | None:
headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next(
(
@@ -167,8 +167,8 @@ class InstalledExtensionsSettings(LNbitsSettings):
def activate_extension_paths(
self,
ext_id: str,
upgrade_hash: Optional[str] = None,
ext_redirects: Optional[list[dict]] = None,
upgrade_hash: str | None = None,
ext_redirects: list[dict] | None = None,
):
self.lnbits_deactivated_extensions.discard(ext_id)
@@ -231,12 +231,12 @@ class ExchangeHistorySettings(LNbitsSettings):
class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet")
lnbits_site_description: Optional[str] = Field(
lnbits_site_description: str | None = Field(
default="The world's most powerful suite of bitcoin tools."
)
lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
lnbits_custom_badge: Optional[str] = Field(default=None)
lnbits_custom_badge: str | None = Field(default=None)
lnbits_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
default=[
@@ -251,18 +251,16 @@ class ThemesSettings(LNbitsSettings):
"bitcoin",
]
)
lnbits_custom_logo: Optional[str] = Field(default=None)
lnbits_custom_image: Optional[str] = Field(
default="/static/images/logos/lnbits.svg"
)
lnbits_custom_logo: str | None = Field(default=None)
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: Optional[str] = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/favicon.ico")
lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border")
@@ -273,16 +271,15 @@ class ThemesSettings(LNbitsSettings):
class OpsSettings(LNbitsSettings):
lnbits_baseurl: str = Field(default="http://127.0.0.1:5000/")
lnbits_hide_api: bool = Field(default=False)
lnbits_denomination: str = Field(default="sats")
class FeeSettings(LNbitsSettings):
lnbits_reserve_fee_min: int = Field(default=2000)
lnbits_reserve_fee_percent: float = Field(default=1.0)
lnbits_service_fee: float = Field(default=0)
lnbits_reserve_fee_min: int = Field(default=2000, ge=0)
lnbits_reserve_fee_percent: float = Field(default=1.0, ge=0)
lnbits_service_fee: float = Field(default=0, ge=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0)
lnbits_service_fee_wallet: Optional[str] = Field(default=None)
lnbits_service_fee_wallet: str | None = Field(default=None)
# WARN: this same value must be used for balance check and passed to
# funding_source.pay_invoice(), it may cause a vulnerability if the values differ
@@ -295,9 +292,9 @@ class FeeSettings(LNbitsSettings):
class ExchangeProvidersSettings(LNbitsSettings):
lnbits_exchange_rate_cache_seconds: int = Field(default=30)
lnbits_exchange_history_size: int = Field(default=60)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300)
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
lnbits_exchange_history_size: int = Field(default=60, ge=0)
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
default=[
@@ -362,7 +359,7 @@ class ExchangeProvidersSettings(LNbitsSettings):
class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_no: str = Field(default="200")
lnbits_rate_limit_no: int = Field(default=200, ge=0)
lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_ips: list[str] = Field(default=[])
lnbits_blocked_ips: list[str] = Field(default=[])
@@ -370,16 +367,16 @@ class SecuritySettings(LNbitsSettings):
default=["^(?!\\d+\\.\\d+\\.\\d+\\.\\d+$)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,}$"]
)
lnbits_wallet_limit_max_balance: int = Field(default=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0)
lnbits_wallet_limit_max_balance: int = Field(default=0, ge=0)
lnbits_wallet_limit_daily_max_withdraw: int = Field(default=0, ge=0)
lnbits_wallet_limit_secs_between_trans: int = Field(default=0, ge=0)
lnbits_only_allow_incoming_payments: bool = Field(default=False)
lnbits_watchdog_switch_to_voidwallet: bool = Field(default=False)
lnbits_watchdog_interval_minutes: int = Field(default=60)
lnbits_watchdog_delta: int = Field(default=1_000_000)
lnbits_watchdog_interval_minutes: int = Field(default=60, gt=0)
lnbits_watchdog_delta: int = Field(default=1_000_000, gt=0)
lnbits_max_outgoing_payment_amount_sats: int = Field(default=10_000_000)
lnbits_max_incoming_payment_amount_sats: int = Field(default=10_000_000)
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)
def is_wallet_max_balance_exceeded(self, amount):
return (
@@ -396,142 +393,154 @@ class NotificationsSettings(LNbitsSettings):
lnbits_telegram_notifications_enabled: bool = Field(default=False)
lnbits_telegram_notifications_access_token: str = Field(default="")
lnbits_telegram_notifications_chat_id: str = Field(default="")
lnbits_email_notifications_enabled: bool = Field(default=False)
lnbits_email_notifications_email: str = Field(default="")
lnbits_email_notifications_username: str = Field(default="")
lnbits_email_notifications_password: str = Field(default="")
lnbits_email_notifications_server: str = Field(default="smtp.protonmail.ch")
lnbits_email_notifications_port: int = Field(default=587)
lnbits_email_notifications_to_emails: list[str] = Field(default=[])
lnbits_notification_settings_update: bool = Field(default=True)
lnbits_notification_credit_debit: bool = Field(default=True)
notification_balance_delta_changed: bool = Field(default=True)
lnbits_notification_server_start_stop: bool = Field(default=True)
lnbits_notification_watchdog: bool = Field(default=False)
lnbits_notification_server_status_hours: int = Field(default=24)
lnbits_notification_incoming_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_outgoing_payment_amount_sats: int = Field(default=1_000_000)
lnbits_notification_server_status_hours: int = Field(default=24, gt=0)
lnbits_notification_incoming_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
lnbits_notification_outgoing_payment_amount_sats: int = Field(
default=1_000_000, ge=0
)
class FakeWalletFundingSource(LNbitsSettings):
fake_wallet_secret: str = Field(default="ToTheMoon1")
lnbits_denomination: str = Field(default="sats")
class LNbitsFundingSource(LNbitsSettings):
lnbits_endpoint: str = Field(default="https://demo.lnbits.com")
lnbits_key: Optional[str] = Field(default=None)
lnbits_admin_key: Optional[str] = Field(default=None)
lnbits_invoice_key: Optional[str] = Field(default=None)
lnbits_key: str | None = Field(default=None)
lnbits_admin_key: str | None = Field(default=None)
lnbits_invoice_key: str | None = Field(default=None)
class ClicheFundingSource(LNbitsSettings):
cliche_endpoint: Optional[str] = Field(default=None)
cliche_endpoint: str | None = Field(default=None)
class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: Optional[str] = Field(default=None)
corelightning_rpc: str | None = Field(default=None)
corelightning_pay_command: str = Field(default="pay")
clightning_rpc: Optional[str] = Field(default=None)
clightning_rpc: str | None = Field(default=None)
class CoreLightningRestFundingSource(LNbitsSettings):
corelightning_rest_url: Optional[str] = Field(default=None)
corelightning_rest_macaroon: Optional[str] = Field(default=None)
corelightning_rest_cert: Optional[str] = Field(default=None)
corelightning_rest_url: str | None = Field(default=None)
corelightning_rest_macaroon: str | None = Field(default=None)
corelightning_rest_cert: str | None = Field(default=None)
class EclairFundingSource(LNbitsSettings):
eclair_url: Optional[str] = Field(default=None)
eclair_pass: Optional[str] = Field(default=None)
eclair_url: str | None = Field(default=None)
eclair_pass: str | None = Field(default=None)
class LndRestFundingSource(LNbitsSettings):
lnd_rest_endpoint: Optional[str] = Field(default=None)
lnd_rest_cert: Optional[str] = Field(default=None)
lnd_rest_macaroon: Optional[str] = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_rest_endpoint: str | None = Field(default=None)
lnd_rest_cert: str | None = Field(default=None)
lnd_rest_macaroon: str | None = Field(default=None)
lnd_rest_macaroon_encrypted: str | None = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True)
lnd_rest_allow_self_payment: bool = Field(default=False)
lnd_cert: Optional[str] = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None)
lnd_rest_admin_macaroon: Optional[str] = Field(default=None)
lnd_rest_invoice_macaroon: Optional[str] = Field(default=None)
lnd_cert: str | None = Field(default=None)
lnd_admin_macaroon: str | None = Field(default=None)
lnd_invoice_macaroon: str | None = Field(default=None)
lnd_rest_admin_macaroon: str | None = Field(default=None)
lnd_rest_invoice_macaroon: str | None = Field(default=None)
class LndGrpcFundingSource(LNbitsSettings):
lnd_grpc_endpoint: Optional[str] = Field(default=None)
lnd_grpc_cert: Optional[str] = Field(default=None)
lnd_grpc_port: Optional[int] = Field(default=None)
lnd_grpc_admin_macaroon: Optional[str] = Field(default=None)
lnd_grpc_invoice_macaroon: Optional[str] = Field(default=None)
lnd_grpc_macaroon: Optional[str] = Field(default=None)
lnd_grpc_macaroon_encrypted: Optional[str] = Field(default=None)
lnd_grpc_endpoint: str | None = Field(default=None)
lnd_grpc_cert: str | None = Field(default=None)
lnd_grpc_port: int | None = Field(default=None)
lnd_grpc_admin_macaroon: str | None = Field(default=None)
lnd_grpc_invoice_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon_encrypted: str | None = Field(default=None)
class LnPayFundingSource(LNbitsSettings):
lnpay_api_endpoint: Optional[str] = Field(default=None)
lnpay_api_key: Optional[str] = Field(default=None)
lnpay_wallet_key: Optional[str] = Field(default=None)
lnpay_admin_key: Optional[str] = Field(default=None)
lnpay_api_endpoint: str | None = Field(default=None)
lnpay_api_key: str | None = Field(default=None)
lnpay_wallet_key: str | None = Field(default=None)
lnpay_admin_key: str | None = Field(default=None)
class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None)
blink_api_endpoint: str | None = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: str | None = Field(default="wss://ws.blink.sv/graphql")
blink_token: str | None = Field(default=None)
class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None)
zbd_api_endpoint: str | None = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: str | None = Field(default=None)
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: Optional[str] = Field(default="http://localhost:9740/")
phoenixd_api_password: Optional[str] = Field(default=None)
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings):
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/")
alby_access_token: Optional[str] = Field(default=None)
alby_api_endpoint: str | None = Field(default="https://api.getalby.com/")
alby_access_token: str | None = Field(default=None)
class OpenNodeFundingSource(LNbitsSettings):
opennode_api_endpoint: Optional[str] = Field(default=None)
opennode_key: Optional[str] = Field(default=None)
opennode_admin_key: Optional[str] = Field(default=None)
opennode_invoice_key: Optional[str] = Field(default=None)
opennode_api_endpoint: str | None = Field(default=None)
opennode_key: str | None = Field(default=None)
opennode_admin_key: str | None = Field(default=None)
opennode_invoice_key: str | None = Field(default=None)
class SparkFundingSource(LNbitsSettings):
spark_url: Optional[str] = Field(default=None)
spark_token: Optional[str] = Field(default=None)
spark_url: str | None = Field(default=None)
spark_token: str | None = Field(default=None)
class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: Optional[str] = Field(default=None)
lntips_api_key: Optional[str] = Field(default=None)
lntips_admin_key: Optional[str] = Field(default=None)
lntips_invoice_key: Optional[str] = Field(default=None)
lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None)
lntips_admin_key: str | None = Field(default=None)
lntips_invoice_key: str | None = Field(default=None)
class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None)
nwc_pairing_url: str | None = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None)
breez_api_key: str | None = Field(default=None)
breez_greenlight_seed: str | None = Field(default=None)
breez_greenlight_invite_code: str | None = Field(default=None)
breez_greenlight_device_key: str | None = Field(default=None)
breez_greenlight_device_cert: str | None = Field(default=None)
breez_use_trampoline: bool = Field(default=True)
class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None)
boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_cert: str | None = Field(default=None)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600)
lightning_invoice_expiry: int = Field(default=3600, gt=0)
class FundingSourcesSettings(
@@ -558,12 +567,12 @@ class FundingSourcesSettings(
lnbits_backend_wallet_class: str = Field(default="VoidWallet")
# How long to wait for the payment to be confirmed before returning a pending status
# It will not fail the payment, it will make it return pending after the timeout
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5)
lnbits_funding_source_pay_invoice_wait_seconds: int = Field(default=5, ge=0)
class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: Optional[str] = Field(default=None)
lnbits_webpush_privkey: Optional[str] = Field(default=None)
lnbits_webpush_pubkey: str | None = Field(default=None)
lnbits_webpush_privkey: str | None = Field(default=None)
class NodeUISettings(LNbitsSettings):
@@ -597,7 +606,7 @@ class AuthMethods(Enum):
class AuthSettings(LNbitsSettings):
auth_token_expire_minutes: int = Field(default=525600)
auth_token_expire_minutes: int = Field(default=525600, gt=0)
auth_all_methods = [a.value for a in AuthMethods]
auth_allowed_methods: list[str] = Field(
default=[
@@ -607,7 +616,7 @@ class AuthSettings(LNbitsSettings):
)
# How many seconds after login the user is allowed to update its credentials.
# A fresh login is required afterwards.
auth_credetials_update_threshold: int = Field(default=120)
auth_credetials_update_threshold: int = Field(default=120, gt=0)
def is_auth_method_allowed(self, method: AuthMethods):
return method.value in self.auth_allowed_methods
@@ -639,7 +648,7 @@ class AuditSettings(LNbitsSettings):
lnbits_audit_enabled: bool = Field(default=True)
# number of days to keep the audit entry
lnbits_audit_retention_days: int = Field(default=7)
lnbits_audit_retention_days: int = Field(default=7, ge=0)
lnbits_audit_log_ip_address: bool = Field(default=False)
lnbits_audit_log_path_params: bool = Field(default=True)
@@ -669,9 +678,9 @@ class AuditSettings(LNbitsSettings):
def audit_http_request(
self,
http_method: Optional[str] = None,
path: Optional[str] = None,
http_response_code: Optional[str] = None,
http_method: str | None = None,
path: str | None = None,
http_response_code: str | None = None,
) -> bool:
if not self.lnbits_audit_enabled:
return False
@@ -689,24 +698,24 @@ class AuditSettings(LNbitsSettings):
return True
def _is_http_request_path_auditable(self, path: Optional[str]):
def _is_http_request_path_auditable(self, path: str | None):
if len(self.lnbits_audit_exclude_paths) != 0 and path:
for exclude_path in self.lnbits_audit_exclude_paths:
if _re_fullmatch_safe(exclude_path, path):
return False
if len(self.lnbits_audit_include_paths) != 0:
if not path:
return False
for include_path in self.lnbits_audit_include_paths:
if _re_fullmatch_safe(include_path, path):
return True
if len(self.lnbits_audit_include_paths) == 0:
return True
if not path:
return False
for include_path in self.lnbits_audit_include_paths:
if _re_fullmatch_safe(include_path, path):
return True
return False
def _is_http_response_code_auditable(
self, http_response_code: Optional[str]
) -> bool:
def _is_http_response_code_auditable(self, http_response_code: str | None) -> bool:
if not http_response_code:
# No response code means only request filters should apply
return True
@@ -776,7 +785,7 @@ class EnvSettings(LNbitsSettings):
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000)
port: int = Field(default=5000, gt=0)
forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".")
@@ -788,24 +797,27 @@ class EnvSettings(LNbitsSettings):
enable_log_to_file: bool = Field(default=True)
log_rotation: str = Field(default="100 MB")
log_retention: str = Field(default="3 months")
server_startup_time: int = Field(default=time())
cleanup_wallets_days: int = Field(default=90)
funding_source_max_retries: int = Field(default=4)
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: Optional[str] = Field(default=None)
lnbits_saas_secret: Optional[str] = Field(default=None)
lnbits_saas_instance_id: Optional[str] = Field(default=None)
def check_auth_secret_key(self):
if self.auth_secret_key:
return
if not os.path.isdir(settings.lnbits_data_folder):
os.mkdir(settings.lnbits_data_folder)
auth_key_file = Path(settings.lnbits_data_folder, ".lnbits_auth_key")
if auth_key_file.is_file():
with open(auth_key_file) as file:
self.auth_secret_key = file.readline()
return
self.auth_secret_key = uuid4().hex
with open(auth_key_file, "w+") as file:
file.write(self.auth_secret_key)
class PersistenceSettings(LNbitsSettings):
@@ -857,6 +869,13 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
lnbits_all_extensions_ids: set[str] = Field(default=[])
server_startup_time: int = Field(default=time())
@property
def lnbits_server_up_time(self) -> str:
up_time = int(time() - self.server_startup_time)
return strftime("%H:%M:%S", gmtime(up_time))
@classmethod
def readonly_fields(cls):
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
@@ -865,7 +884,6 @@ class TransientSettings(InstalledExtensionsSettings, ExchangeHistorySettings):
class ReadOnlySettings(
EnvSettings,
ExtensionsInstallSettings,
SaaSSettings,
PersistenceSettings,
SuperUserSettings,
):
@@ -903,7 +921,7 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user
)
def is_super_user(self, user_id: Optional[str] = None) -> bool:
def is_super_user(self, user_id: str | None = None) -> bool:
return user_id == self.super_user
def is_admin_user(self, user_id: str) -> bool:
@@ -922,12 +940,12 @@ class SuperSettings(EditableSettings):
class AdminSettings(EditableSettings):
is_super_user: bool
lnbits_allowed_funding_sources: Optional[list[str]]
lnbits_allowed_funding_sources: list[str] | None
class SettingsField(BaseModel):
id: str
value: Optional[Any]
value: Any | None
tag: str = "core"
@@ -944,31 +962,6 @@ def set_cli_settings(**kwargs):
setattr(settings, key, value)
def send_admin_user_to_saas():
if settings.lnbits_saas_callback:
with httpx.Client() as client:
headers = {
"Content-Type": "application/json; charset=utf-8",
"X-API-KEY": settings.lnbits_saas_secret,
}
payload = {
"instance_id": settings.lnbits_saas_instance_id,
"adminuser": settings.super_user,
}
try:
client.post(
settings.lnbits_saas_callback,
headers=headers,
json=payload,
)
logger.success("sent super_user to saas application")
except Exception as e:
logger.error(
"error sending super_user to saas:"
f" {settings.lnbits_saas_callback}. Error: {e!s}"
)
readonly_variables = ReadOnlySettings.readonly_fields()
transient_variables = TransientSettings.readonly_fields()
@@ -977,9 +970,8 @@ settings = Settings()
settings.lnbits_path = str(path.dirname(path.realpath(__file__)))
settings.version = importlib.metadata.version("lnbits")
settings.auth_secret_key = (
settings.auth_secret_key or sha256(settings.super_user.encode("utf-8")).hexdigest()
)
settings.check_auth_secret_key()
if not settings.user_agent:
settings.user_agent = f"LNbits/{settings.version}"
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+7 -5
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -110,16 +110,16 @@ body[data-theme=bitcoin].body--light {
}
[data-theme=bitcoin] .bg-primary {
background: #ff9853 !important;
background: #ea611d !important;
}
[data-theme=bitcoin] .text-primary {
color: #ff9853 !important;
color: #ea611d !important;
}
[data-theme=bitcoin] .bg-secondary {
background: #ff7353 !important;
background: #e56f35 !important;
}
[data-theme=bitcoin] .text-secondary {
color: #ff7353 !important;
color: #e56f35 !important;
}
[data-theme=bitcoin] .bg-dark {
background: #2d293b !important;
@@ -134,10 +134,10 @@ body[data-theme=bitcoin].body--light {
color: #333646 !important;
}
[data-theme=bitcoin] .bg-marginal-bg {
background: #2d293b !important;
background: #000000 !important;
}
[data-theme=bitcoin] .text-marginal-bg {
color: #2d293b !important;
color: #000000 !important;
}
[data-theme=bitcoin] .bg-marginal-text {
background: #fff !important;
+33 -5
View File
@@ -65,8 +65,9 @@ window.localisation.en = {
view_github: 'View on GitHub',
voidwallet_active: 'VoidWallet is active! Payments disabled',
use_with_caution: 'USE WITH CAUTION - {name} wallet is still in BETA',
service_fee: 'Service fee: {amount} % per transaction',
service_fee_max: 'Service fee: {amount} % per transaction (max {max} sats)',
service_fee_badge: 'Service fee: {amount} % per transaction',
service_fee_max_badge:
'Service fee: {amount} % per transaction (max {max} {denom})',
service_fee_tooltip:
'Service fee charged by the LNbits server admin per outgoing transaction',
toggle_darkmode: 'Toggle Dark Mode',
@@ -82,6 +83,7 @@ window.localisation.en = {
create_invoice: 'Create Invoice',
camera_tooltip: 'Use camera to scan an invoice/QR',
export_csv: 'Export to CSV',
export_csv_details: 'Export to CSV with details',
chart_tooltip: 'Show chart',
pending: 'Pending',
copy_invoice: 'Copy invoice',
@@ -174,6 +176,7 @@ window.localisation.en = {
payment_proof: 'Payment Proof',
update: 'Update',
update_available: 'Update {version} available!',
funding_sources: 'Funding Sources',
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
@@ -195,6 +198,26 @@ window.localisation.en = {
notifications_chat_id: 'Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to',
notifications_email_config: 'Email Configuration',
notifications_enable_email: 'Enable Email',
notifications_enable_email_desc: 'Send notfications over email',
notifications_send_test_email: 'Send test email',
notifications_send_email: 'Send email',
notifications_send_email_desc: 'Email you will send from',
notifications_send_email_username: 'Username',
notifications_send_email_username_desc:
'Username, will use the email if not set',
notifications_send_email_password: 'Send email password',
notifications_send_email_password_desc:
'Password for the email you will send from',
notifications_send_email_server_port: 'Send email SMTP port',
notifications_send_email_server_port_desc: 'Port for the SMTP server',
notifications_send_email_server: 'Send email SMTP server',
notifications_send_email_server_desc:
'SMTP server for the email you will send from',
notifications_send_to_emails: 'Emails to send to',
notifications_send_to_emails_desc: 'Emails notifications will be sent to',
notification_settings_update: 'Settings updated',
notification_settings_update_desc:
'Notify when server settings have been updated',
@@ -293,6 +316,7 @@ window.localisation.en = {
password: 'Password',
password_config: 'Password Config',
password_repeat: 'Password repeat',
update_password: 'Update Password',
change_password: 'Change Password',
update_credentials: 'Update Credentials',
update_pubkey: 'Update Public Key',
@@ -482,8 +506,9 @@ window.localisation.en = {
lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
ui_qr_code_logo: 'QR Code Logo',
ui_qr_code_logo_hint: 'URL to logo image in QR code',
denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: 'Image showed at homepage/login',
@@ -512,6 +537,7 @@ window.localisation.en = {
allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
@@ -539,5 +565,7 @@ window.localisation.en = {
reset_wallet_keys_desc:
'Reset the API keys for this wallet. This will invalidate the current keys and generate new ones.',
view_list: 'View wallets as list',
view_column: 'View wallets as rows'
view_column: 'View wallets as rows',
filter_payments: 'Filter payments',
filter_date: 'Filter by date'
}
+43 -2
View File
@@ -53,8 +53,10 @@ window.AdminPageLogic = {
chartReady: false,
formAddAdmin: '',
formAddUser: '',
hideInputToggle: true,
formAddExtensionsManifest: '',
nostrNotificationIdentifier: '',
emailNotificationAddress: '',
formAllowedIPs: '',
formCallbackUrlRule: '',
formBlockedIPs: '',
@@ -270,6 +272,23 @@ window.AdminPageLogic = {
m => m !== identifer
)
},
addEmailNotificationAddress() {
const email = this.emailNotificationAddress.trim()
const emails = this.formData.lnbits_email_notifications_to_emails
if (email && email.length && !emails.includes(email)) {
this.formData.lnbits_email_notifications_to_emails = [...emails, email]
this.emailNotificationAddress = ''
}
},
removeEmailNotificationAddress(email) {
const emails = this.formData.lnbits_email_notifications_to_emails
this.formData.lnbits_email_notifications_to_emails = emails.filter(
m => m !== email
)
},
hideInputsToggle() {
this.hideInputToggle = !this.hideInputToggle
},
async toggleServerLog() {
this.serverlogEnabled = !this.serverlogEnabled
if (this.serverlogEnabled) {
@@ -419,7 +438,7 @@ window.AdminPageLogic = {
LNbits.api
.request('GET', '/admin/api/v1/restart/')
.then(response => {
Quasar.Notify.create({
this.$q.notify({
type: 'positive',
message: 'Success! Restarted Server',
icon: null
@@ -431,7 +450,29 @@ window.AdminPageLogic = {
formatDate(date) {
return moment(date * 1000).fromNow()
},
sendTestEmail() {
LNbits.api
.request(
'GET',
'/admin/api/v1/testemail',
this.g.user.wallets[0].adminkey
)
.then(response => {
if (response.data.status === 'error') {
throw new Error(response.data.message)
}
this.$q.notify({
message: 'Test email sent!',
color: 'positive'
})
})
.catch(error => {
this.$q.notify({
message: error.message,
color: 'negative'
})
})
},
getAudit() {
LNbits.api
.request('GET', '/admin/api/v1/audit', this.g.user.wallets[0].adminkey)
+3 -2
View File
@@ -209,7 +209,7 @@ window.LNbits = {
})
obj.walletOptions = obj.wallets.map(obj => {
return {
label: [obj.name, ' - ', obj.id].join(''),
label: [obj.name, ' - ', obj.id.substring(0, 5), '...'].join(''),
value: obj.id
}
})
@@ -357,9 +357,10 @@ window.LNbits = {
}
},
prepareFilterQuery(tableConfig, props) {
tableConfig.filter = tableConfig.filter || {}
if (props) {
tableConfig.pagination = props.pagination
tableConfig.filter = {...tableConfig.filter, ...props.filter}
Object.assign(tableConfig.filter, props.filter)
}
const pagination = tableConfig.pagination
tableConfig.loading = true
+1 -2
View File
@@ -583,7 +583,7 @@ window.app.component('username-password', {
},
reset() {
this.$emit('update:resetKey', this.reset_key)
this.$emit('update:passeord_1', this.password)
this.$emit('update:password_1', this.password)
this.$emit('update:password_2', this.passwordRepeat)
this.$emit('reset')
},
@@ -595,7 +595,6 @@ window.app.component('username-password', {
},
async signInWithNostr() {
try {
console.log('### signInWithNostr')
const nostrToken = await this.createNostrToken()
if (!nostrToken) {
return
@@ -37,7 +37,8 @@ window.app.component('lnbits-funding-sources', {
'FakeWallet',
'Fake Wallet',
{
fake_wallet_secret: 'Secret'
fake_wallet_secret: 'Secret',
lnbits_denomination: '"sats" or 3 Letter Custom Denomination'
}
],
[
+38 -10
View File
@@ -6,7 +6,6 @@ window.app.component('payment-list', {
data() {
return {
denomination: LNBITS_DENOMINATION,
failedPaymentsToggle: false,
payments: [],
paymentsTable: {
columns: [
@@ -39,6 +38,13 @@ window.app.component('payment-list', {
loading: false
},
searchDate: {from: null, to: null},
searchStatus: {
success: true,
pending: true,
failed: false,
incoming: true,
outgoing: true
},
exportTagName: '',
exportPaymentTagList: [],
paymentsCSV: {
@@ -263,18 +269,40 @@ window.app.component('payment-list', {
console.error(e)
return `${amount} ???`
}
},
handleFilterChanged() {
const {success, pending, failed, incoming, outgoing} = this.searchStatus
delete this.paymentsTable.filter['status[ne]']
delete this.paymentsTable.filter['status[eq]']
if (success && pending && failed) {
// No status filter
} else if (success && pending) {
this.paymentsTable.filter['status[ne]'] = 'failed'
} else if (success && failed) {
this.paymentsTable.filter['status[ne]'] = 'pending'
} else if (failed && pending) {
this.paymentsTable.filter['status[ne]'] = 'success'
} else if (success) {
this.paymentsTable.filter['status[eq]'] = 'success'
} else if (pending) {
this.paymentsTable.filter['status[eq]'] = 'pending'
} else if (failed) {
this.paymentsTable.filter['status[eq]'] = 'failed'
}
delete this.paymentsTable.filter['amount[ge]']
delete this.paymentsTable.filter['amount[le]']
if (incoming && outgoing) {
// do nothing
} else if (incoming) {
this.paymentsTable.filter['amount[ge]'] = 0
} else if (outgoing) {
this.paymentsTable.filter['amount[le]'] = 0
}
}
},
watch: {
failedPaymentsToggle(newVal) {
if (newVal === false) {
this.paymentsTable.filter['status[ne]'] = 'failed'
} else {
delete this.paymentsTable.filter['status[ne]']
}
this.paymentsTable.pagination.page = 1
this.fetchPayments()
},
'paymentsTable.search': {
handler() {
const props = {}
+7 -3
View File
@@ -9,9 +9,7 @@ window.app = Vue.createApp({
description: ''
},
isUserAuthorized: false,
authAction: Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
? 'login'
: 'register',
authAction: 'login',
authMethod: 'username-password',
usr: '',
username: '',
@@ -104,6 +102,12 @@ window.app = Vue.createApp({
},
created() {
this.description = SITE_DESCRIPTION
this.allowedRegister = ALLOWED_REGISTER
this.authAction =
!this.allowedRegister ||
Quasar.LocalStorage.getItem('lnbits.disclaimerShown')
? 'login'
: 'register'
this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
const _acccess_cookies_for_safari_refresh_do_not_delete = document.cookie
if (this.isUserAuthorized) {
-3
View File
@@ -352,9 +352,6 @@ window.UsersPageLogic = {
})
.catch(LNbits.utils.notifyApiError)
},
exportUsers() {
console.log('export users')
},
async showAccountPage(user_id) {
this.activeUser.showPassword = false
this.activeUser.showUserId = false
+17 -1
View File
@@ -854,7 +854,9 @@ window.WalletPageLogic = {
handleFilterChange(value = {}) {
if (
this.paymentsFilter['time[ge]'] !== value['time[ge]'] ||
this.paymentsFilter['time[le]'] !== value['time[le]']
this.paymentsFilter['time[le]'] !== value['time[le]'] ||
this.paymentsFilter['amount[ge]'] !== value['amount[ge]'] ||
this.paymentsFilter['amount[le]'] !== value['amount[le]']
) {
this.refreshCharts()
}
@@ -884,6 +886,19 @@ window.WalletPageLogic = {
filterChartData(data) {
const timeFrom = this.paymentsFilter['time[ge]'] + 'T00:00:00'
const timeTo = this.paymentsFilter['time[le]'] + 'T23:59:59'
let totalBalance = 0
data = data.map(p => {
if (this.paymentsFilter['amount[ge]'] !== undefined) {
totalBalance += p.balance_in
return {...p, balance: totalBalance, balance_out: 0, count_out: 0}
}
if (this.paymentsFilter['amount[le]'] !== undefined) {
totalBalance -= p.balance_out
return {...p, balance: totalBalance, balance_in: 0, count_in: 0}
}
return {...p}
})
data = data.filter(p => {
if (
this.paymentsFilter['time[ge]'] &&
@@ -899,6 +914,7 @@ window.WalletPageLogic = {
}
return true
})
const labels = data.map(s =>
new Date(s.date).toLocaleString('default', {
month: 'short',
+3 -3
View File
@@ -8,11 +8,11 @@ $themes: (
marginal-text: #fff
),
'bitcoin': (
primary: #ff9853,
secondary: #ff7353,
primary: #ea611d,
secondary: #e56f35,
dark: #2d293b,
info: #333646,
marginal-bg: #2d293b,
marginal-bg: #000000,
marginal-text: #fff
),
'freedom': (
+45 -85
View File
@@ -1,4 +1,4 @@
// Axios v1.7.7 Copyright (c) 2024 Matt Zabriskie and contributors
/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
@@ -1266,23 +1266,6 @@
var toFiniteNumber = function toFiniteNumber(value, defaultValue) {
return value != null && Number.isFinite(value = +value) ? value : defaultValue;
};
var ALPHA = 'abcdefghijklmnopqrstuvwxyz';
var DIGIT = '0123456789';
var ALPHABET = {
DIGIT: DIGIT,
ALPHA: ALPHA,
ALPHA_DIGIT: ALPHA + ALPHA.toUpperCase() + DIGIT
};
var generateString = function generateString() {
var size = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 16;
var alphabet = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ALPHABET.ALPHA_DIGIT;
var str = '';
var length = alphabet.length;
while (size--) {
str += alphabet[Math.random() * length | 0];
}
return str;
};
/**
* If the thing is a FormData object, return true, otherwise return false.
@@ -1399,8 +1382,6 @@
findKey: findKey,
global: _global,
isContextDefined: isContextDefined,
ALPHABET: ALPHABET,
generateString: generateString,
isSpecCompliantForm: isSpecCompliantForm,
toJSONObject: toJSONObject,
isAsyncFn: isAsyncFn,
@@ -1735,7 +1716,7 @@
*
* @param {string} url The base of the url (e.g., http://www.google.com)
* @param {object} [params] The params to be appended
* @param {?object} options
* @param {?(object|Function)} options
*
* @returns {string} The formatted url
*/
@@ -1745,6 +1726,11 @@
return url;
}
var _encode = options && options.encode || encode;
if (utils$1.isFunction(options)) {
options = {
serialize: options
};
}
var serializeFn = options && options.serialize;
var serializedParams;
if (serializeFn) {
@@ -2642,60 +2628,14 @@
};
};
var isURLSameOrigin = platform.hasStandardBrowserEnv ?
// Standard browser envs have full support of the APIs needed to test
// whether the request URL is of the same origin as current location.
function standardBrowserEnv() {
var msie = platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent);
var urlParsingNode = document.createElement('a');
var originURL;
/**
* Parse a URL to discover its components
*
* @param {String} url The URL to be parsed
* @returns {Object}
*/
function resolveURL(url) {
var href = url;
if (msie) {
// IE needs attribute set twice to normalize properties
urlParsingNode.setAttribute('href', href);
href = urlParsingNode.href;
}
urlParsingNode.setAttribute('href', href);
// urlParsingNode provides the UrlUtils interface - http://url.spec.whatwg.org/#urlutils
return {
href: urlParsingNode.href,
protocol: urlParsingNode.protocol ? urlParsingNode.protocol.replace(/:$/, '') : '',
host: urlParsingNode.host,
search: urlParsingNode.search ? urlParsingNode.search.replace(/^\?/, '') : '',
hash: urlParsingNode.hash ? urlParsingNode.hash.replace(/^#/, '') : '',
hostname: urlParsingNode.hostname,
port: urlParsingNode.port,
pathname: urlParsingNode.pathname.charAt(0) === '/' ? urlParsingNode.pathname : '/' + urlParsingNode.pathname
};
}
originURL = resolveURL(window.location.href);
/**
* Determine if a URL shares the same origin as the current location
*
* @param {String} requestURL The URL to test
* @returns {boolean} True if URL shares the same origin, otherwise false
*/
return function isURLSameOrigin(requestURL) {
var parsed = utils$1.isString(requestURL) ? resolveURL(requestURL) : requestURL;
return parsed.protocol === originURL.protocol && parsed.host === originURL.host;
var isURLSameOrigin = platform.hasStandardBrowserEnv ? function (origin, isMSIE) {
return function (url) {
url = new URL(url, platform.origin);
return origin.protocol === url.protocol && origin.host === url.host && (isMSIE || origin.port === url.port);
};
}() :
// Non standard browser envs (web workers, react-native) lack needed support.
function nonStandardBrowserEnv() {
return function isURLSameOrigin() {
return true;
};
}();
}(new URL(platform.origin), platform.navigator && /(msie|trident)/i.test(platform.navigator.userAgent)) : function () {
return true;
};
var cookies = platform.hasStandardBrowserEnv ?
// Standard browser envs support document.cookie
@@ -2761,8 +2701,9 @@
*
* @returns {string} The combined full path
*/
function buildFullPath(baseURL, requestedURL) {
if (baseURL && !isAbsoluteURL(requestedURL)) {
function buildFullPath(baseURL, requestedURL, allowAbsoluteUrls) {
var isRelativeUrl = !isAbsoluteURL(requestedURL);
if (baseURL && isRelativeUrl || allowAbsoluteUrls == false) {
return combineURLs(baseURL, requestedURL);
}
return requestedURL;
@@ -2785,7 +2726,7 @@
// eslint-disable-next-line no-param-reassign
config2 = config2 || {};
var config = {};
function getMergedValue(target, source, caseless) {
function getMergedValue(target, source, prop, caseless) {
if (utils$1.isPlainObject(target) && utils$1.isPlainObject(source)) {
return utils$1.merge.call({
caseless: caseless
@@ -2799,11 +2740,11 @@
}
// eslint-disable-next-line consistent-return
function mergeDeepProperties(a, b, caseless) {
function mergeDeepProperties(a, b, prop, caseless) {
if (!utils$1.isUndefined(b)) {
return getMergedValue(a, b, caseless);
return getMergedValue(a, b, prop, caseless);
} else if (!utils$1.isUndefined(a)) {
return getMergedValue(undefined, a, caseless);
return getMergedValue(undefined, a, prop, caseless);
}
}
@@ -2860,8 +2801,8 @@
socketPath: defaultToConfig2,
responseEncoding: defaultToConfig2,
validateStatus: mergeDirectKeys,
headers: function headers(a, b) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), true);
headers: function headers(a, b, prop) {
return mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true);
}
};
utils$1.forEach(Object.keys(Object.assign({}, config1, config2)), function computeConfigValue(prop) {
@@ -3717,7 +3658,7 @@
});
}
var VERSION = "1.7.7";
var VERSION = "1.8.2";
var validators$1 = {};
@@ -3756,6 +3697,13 @@
return validator ? validator(value, opt, opts) : true;
};
};
validators$1.spelling = function spelling(correctSpelling) {
return function (value, opt) {
// eslint-disable-next-line no-console
console.warn("".concat(opt, " is likely a misspelling of ").concat(correctSpelling));
return true;
};
};
/**
* Assert object's properties type
@@ -3838,7 +3786,8 @@
_context.prev = 6;
_context.t0 = _context["catch"](0);
if (_context.t0 instanceof Error) {
Error.captureStackTrace ? Error.captureStackTrace(dummy = {}) : dummy = new Error();
dummy = {};
Error.captureStackTrace ? Error.captureStackTrace(dummy) : dummy = new Error();
// slice off the Error: ... line
stack = dummy.stack ? dummy.stack.replace(/^.+\n/, '') : '';
@@ -3901,6 +3850,17 @@
}
}
// Set config.allowAbsoluteUrls
if (config.allowAbsoluteUrls !== undefined) ; else if (this.defaults.allowAbsoluteUrls !== undefined) {
config.allowAbsoluteUrls = this.defaults.allowAbsoluteUrls;
} else {
config.allowAbsoluteUrls = true;
}
validator.assertOptions(config, {
baseUrl: validators.spelling('baseURL'),
withXsrfToken: validators.spelling('withXSRFToken')
}, true);
// Set config.method
config.method = (config.method || this.defaults.method || 'get').toLowerCase();
@@ -3968,7 +3928,7 @@
key: "getUri",
value: function getUri(config) {
config = mergeConfig(this.defaults, config);
var fullPath = buildFullPath(config.baseURL, config.url);
var fullPath = buildFullPath(config.baseURL, config.url, config.allowAbsoluteUrls);
return buildURL(fullPath, config.params, config.paramsSerializer);
}
}]);
File diff suppressed because one or more lines are too long
-30
View File
@@ -1,9 +1,7 @@
import asyncio
import json
import time
import traceback
import uuid
from http import HTTPStatus
from typing import (
Callable,
Coroutine,
@@ -13,11 +11,8 @@ from typing import (
)
from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments,
get_standalone_payment,
update_payment,
@@ -216,28 +211,3 @@ async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = Fals
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
async def send_push_notification(subscription, title, body, url=""):
vapid = Vapid()
try:
logger.debug("sending push notification")
webpush(
json.loads(subscription.data),
json.dumps({"title": title, "body": body, "url": url}),
(
vapid.from_pem(bytes(settings.lnbits_webpush_privkey, "utf-8"))
if settings.lnbits_webpush_privkey
else None
),
{"aud": "", "sub": "mailto:alan@lnbits.com"},
)
except WebPushException as e:
if e.response and e.response.status_code == HTTPStatus.GONE:
# cleanup unsubscribed or expired push subscriptions
await delete_webpush_subscriptions(subscription.endpoint)
else:
logger.error(
f"failed sending push notification: "
f"{e.response.text if e.response else e}"
)
+2 -2
View File
@@ -89,11 +89,11 @@
>
{% if LNBITS_SERVICE_FEE_MAX > 0 %}
<span
v-text='$t("service_fee_max", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}"})'
v-text='$t("service_fee_max_badge", { amount: "{{ LNBITS_SERVICE_FEE }}", max: "{{ LNBITS_SERVICE_FEE_MAX }}", denom: "{{ LNBITS_DENOMINATION }}"})'
></span>
{%else%}
<span
v-text='$t("service_fee", { amount: "{{ LNBITS_SERVICE_FEE }}" })'
v-text='$t("service_fee_badge", { amount: "{{ LNBITS_SERVICE_FEE}}"})'
></span>
{%endif%}
<q-tooltip
+102 -61
View File
@@ -419,7 +419,6 @@
dense
use-input
use-chips
multiple
hide-dropdown-icon
></q-select>
<div v-else-if="o.type === 'bool'">
@@ -639,16 +638,100 @@
</q-input>
</div>
<div class="gt-sm col-auto">
<q-btn icon="event" flat color="grey">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
></q-badge>
<q-tooltip>
<span v-text="$t('filter_date')"></span>
</q-tooltip>
</q-btn>
<q-btn color="grey" icon="filter_alt" flat>
<q-menu>
<q-item dense>
<q-checkbox
v-model="searchStatus.success"
@click="handleFilterChanged"
label="Success Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="searchStatus.pending"
@click="handleFilterChanged"
label="Pending Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="searchStatus.failed"
@click="handleFilterChanged"
label="Failed Payments"
></q-checkbox>
</q-item>
<q-separator></q-separator>
<q-item dense>
<q-checkbox
v-model="searchStatus.incoming"
@click="handleFilterChanged"
label="Incoming Payments"
></q-checkbox>
</q-item>
<q-item dense>
<q-checkbox
v-model="searchStatus.outgoing"
@click="handleFilterChanged"
label="Outgoing Payments"
></q-checkbox>
</q-item>
</q-menu>
<q-tooltip>
<span v-text="$t('filter_payments')"></span>
</q-tooltip>
</q-btn>
<q-btn-dropdown
dense
outline
persistent
dense
icon="archive"
split
class="q-mr-sm"
color="grey"
label="Export"
split
@click="exportCSV(false)"
>
<q-tooltip>
<span v-text="$t('export_csv')"></span>
</q-tooltip>
<q-list>
<q-item>
<q-item-section>
@@ -688,59 +771,12 @@
outline
color="grey"
@click="exportCSV(true)"
label="Export to CSV with details"
:label="$t('export_csv_details')"
></q-btn>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn icon="event" outline flat color="grey">
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
<q-date v-model="searchDate" mask="YYYY-MM-DD" range />
<div class="row">
<div class="col-6">
<q-btn
label="Search"
@click="searchByDate()"
color="primary"
flat
class="float-left"
v-close-popup
/>
</div>
<div class="col-6">
<q-btn
v-close-popup
@click="clearDateSeach()"
label="Clear"
class="float-right"
color="grey"
flat
/>
</div>
</div>
</q-popup-proxy>
<q-badge
v-if="searchDate?.to || searchDate?.from"
class="q-mt-lg q-mr-md"
color="primary"
rounded
floating
style="border-radius: 6px"
/>
</q-btn>
<q-checkbox
v-model="failedPaymentsToggle"
checked-icon="warning"
unchecked-icon="warning_off"
:color="failedPaymentsToggle ? 'yellow' : 'grey'"
size="xs"
>
<q-tooltip>
<span v-text="`Include failed payments`"></span>
</q-tooltip>
</q-checkbox>
</div>
</div>
<div class="row q-my-md"></div>
@@ -1021,7 +1057,16 @@
<template id="lnbits-funding-sources">
<div class="funding-sources">
<h6 class="q-mt-xl q-mb-md">Funding Sources</h6>
<h6 class="q-my-none q-mb-sm">
<span v-text="$t('funding_sources')"></span>
<q-btn
round
flat
@click="this.hideInput = !this.hideInput"
:icon="this.hideInput ? 'visibility_off' : 'visibility'"
></q-btn>
</h6>
<div class="row">
<div class="col-12">
<p>Active Funding<small> (Requires server restart)</small></p>
@@ -1059,13 +1104,6 @@
:label="prop.label"
:hint="prop.hint"
>
<template v-slot:append>
<q-icon
:name="hideInput ? 'visibility_off' : 'visibility'"
class="cursor-pointer"
@click="this.hideInput = !this.hideInput"
></q-icon>
</template>
</q-input>
</div>
</div>
@@ -1223,7 +1261,7 @@
</q-form>
</q-card-section>
<!-- REGISTER -->
<q-card-section v-if="authAction === 'register'">
<q-card-section v-if="allowed_new_users && authAction === 'register'">
<q-form @submit="register" class="q-gutter-sm">
<q-input
dense
@@ -1266,6 +1304,9 @@
</div>
</q-form>
</q-card-section>
<q-card-section v-else-if="!allowed_new_users && authAction === 'register'">
<h5 class="text-center" v-text="$t('new_user_not_allowed')"></h5>
</q-card-section>
<slot></slot>
<!-- RESET -->
<q-card-section v-if="authAction === 'reset'">
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from time import time
from typing import Any, NamedTuple, Optional
from typing import Any, NamedTuple
from loguru import logger
@@ -23,7 +23,7 @@ class Cache:
self.interval = interval
self._values: dict[Any, Cached] = {}
def get(self, key: str, default=None) -> Optional[Any]:
def get(self, key: str, default=None) -> Any | None:
cached = self._values.get(key)
if cached is not None:
if cached.expiry > time():
@@ -35,7 +35,7 @@ class Cache:
def set(self, key: str, value: Any, expiry: float = 10):
self._values[key] = Cached(value, time() + expiry)
def pop(self, key: str, default=None) -> Optional[Any]:
def pop(self, key: str, default=None) -> Any | None:
cached = self._values.pop(key, None)
if cached and cached.expiry > time():
return cached.value
+11 -5
View File
@@ -63,10 +63,15 @@ class AESCipher:
final_key += key
return final_key[:output]
def decrypt(self, encrypted: str) -> str:
def decrypt(self, encrypted: str, urlsafe: bool = False) -> str:
"""Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase
encrypted_bytes = base64.b64decode(encrypted)
if urlsafe:
encrypted_bytes = base64.urlsafe_b64decode(encrypted)
else:
encrypted_bytes = base64.b64decode(encrypted)
assert encrypted_bytes[0:8] == b"Salted__"
salt = encrypted_bytes[8:16]
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
@@ -78,13 +83,14 @@ class AESCipher:
except UnicodeDecodeError as exc:
raise ValueError("Wrong passphrase") from exc
def encrypt(self, message: bytes) -> str:
def encrypt(self, message: bytes, urlsafe: bool = False) -> str:
passphrase = self.passphrase
salt = Random.new().read(8)
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
key = key_iv[:32]
iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv)
return base64.b64encode(
b"Salted__" + salt + aes.encrypt(self.pad(message))
encoded = b"Salted__" + salt + aes.encrypt(self.pad(message))
return (
base64.urlsafe_b64encode(encoded) if urlsafe else base64.b64encode(encoded)
).decode()
+4 -1
View File
@@ -176,7 +176,7 @@ currencies = {
}
def allowed_currencies():
def allowed_currencies() -> list[str]:
if len(settings.lnbits_allowed_currencies) > 0:
return [
item
@@ -187,6 +187,9 @@ def allowed_currencies():
async def btc_rates(currency: str) -> list[tuple[str, float]]:
if currency.upper() not in allowed_currencies():
raise ValueError(f"Currency '{currency}' not allowed.")
def replacements(ticker: str):
return {
"FROM": "BTC",
+1 -2
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import importlib
from typing import Optional
from lnbits.nodes import set_node_class
from lnbits.settings import settings
@@ -33,7 +32,7 @@ from .void import VoidWallet
from .zbd import ZBDWallet
def set_funding_source(class_name: Optional[str] = None):
def set_funding_source(class_name: str | None = None):
backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
funding_source_constructor = getattr(wallets_module, backend_wallet_class)
global funding_source
+17 -17
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple, Optional
from typing import TYPE_CHECKING, AsyncGenerator, Coroutine, NamedTuple
from loguru import logger
@@ -11,15 +11,15 @@ if TYPE_CHECKING:
class StatusResponse(NamedTuple):
error_message: Optional[str]
error_message: str | None
balance_msat: int
class InvoiceResponse(NamedTuple):
ok: bool
checking_id: Optional[str] = None # payment_hash, rpc_id
payment_request: Optional[str] = None
error_message: Optional[str] = None
checking_id: str | None = None # payment_hash, rpc_id
payment_request: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
@@ -36,11 +36,11 @@ class InvoiceResponse(NamedTuple):
class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded
ok: Optional[bool] = None
checking_id: Optional[str] = None # payment_hash, rcp_id
fee_msat: Optional[int] = None
preimage: Optional[str] = None
error_message: Optional[str] = None
ok: bool | None = None
checking_id: str | None = None # payment_hash, rcp_id
fee_msat: int | None = None
preimage: str | None = None
error_message: str | None = None
@property
def success(self) -> bool:
@@ -56,9 +56,9 @@ class PaymentResponse(NamedTuple):
class PaymentStatus(NamedTuple):
paid: Optional[bool] = None
fee_msat: Optional[int] = None
preimage: Optional[str] = None
paid: bool | None = None
fee_msat: int | None = None
preimage: str | None = None
@property
def success(self) -> bool:
@@ -94,7 +94,7 @@ class PaymentPendingStatus(PaymentStatus):
class Wallet(ABC):
__node_cls__: Optional[type[Node]] = None
__node_cls__: type[Node] | None = None
def __init__(self) -> None:
self.pending_invoices: list[str] = []
@@ -111,9 +111,9 @@ class Wallet(ABC):
def create_invoice(
self,
amount: int,
memo: Optional[str] = None,
description_hash: Optional[bytes] = None,
unhashed_description: Optional[bytes] = None,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> Coroutine[None, None, InvoiceResponse]:
pass
+4 -2
View File
@@ -87,13 +87,15 @@ class LNbitsWallet(Wallet):
r.raise_for_status()
data = r.json()
if r.is_error or "bolt11" not in data:
# Backwards compatibility for pre-v1 which used the key "payment_request"
payment_str = data.get("bolt11") or data.get("payment_request")
if r.is_error or not payment_str:
error_message = data["detail"] if "detail" in data else r.text
return InvoiceResponse(
False, None, None, f"Server error: '{error_message}'"
)
return InvoiceResponse(True, data["checking_id"], data["bolt11"], None)
return InvoiceResponse(True, data["checking_id"], payment_str, None)
except json.JSONDecodeError:
return InvoiceResponse(
False, None, None, "Server error: 'invalid json response'"
+27 -22
View File
@@ -6,7 +6,7 @@
"": {
"name": "lnbits",
"dependencies": {
"axios": "^1.7.7",
"axios": "^1.8.2",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"nostr-tools": "^2.7.2",
@@ -15,7 +15,7 @@
"showdown": "^2.1.0",
"underscore": "^1.13.7",
"vue": "3.5.8",
"vue-i18n": "^10.0.5",
"vue-i18n": "^10.0.6",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
@@ -72,12 +72,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.5.tgz",
"integrity": "sha512-F3snDTQs0MdvnnyzTDTVkOYVAZOE/MHwRvF7mn7Jw1yuih4NrFYLNYIymGlLmq4HU2iIdzYsZ7f47bOcwY73XQ==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.6.tgz",
"integrity": "sha512-/NINGvy7t8qSCyyuqMIPmHS6CBQjqPIPVOps0Rb7xWrwwkwHJKtahiFnW1HC4iQVhzoYwEW6Js0923zTScLDiA==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "10.0.5",
"@intlify/shared": "10.0.5"
"@intlify/message-compiler": "10.0.6",
"@intlify/shared": "10.0.6"
},
"engines": {
"node": ">= 16"
@@ -87,11 +88,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.5.tgz",
"integrity": "sha512-6GT1BJ852gZ0gItNZN2krX5QAmea+cmdjMvsWohArAZ3GmHdnNANEcF9JjPXAMRtQ6Ux5E269ymamg/+WU6tQA==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.6.tgz",
"integrity": "sha512-QcUYprK+e4X2lU6eJDxLuf/mUtCuVPj2RFBoFRlJJxK3wskBejzlRvh1Q0lQCi9tDOnD4iUK1ftcGylE3X3idA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "10.0.5",
"@intlify/shared": "10.0.6",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -102,9 +104,10 @@
}
},
"node_modules/@intlify/shared": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.5.tgz",
"integrity": "sha512-bmsP4L2HqBF6i6uaMqJMcFBONVjKt+siGluRq4Ca4C0q7W2eMaVZr8iCgF9dKbcVXutftkC7D6z2SaSMmLiDyA==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.6.tgz",
"integrity": "sha512-2xqwm05YPpo7TM//+v0bzS0FWiTzsjpSMnWdt7ZXs5/ZfQIedSuBXIrskd8HZ7c/cZzo1G9ALHTksnv/74vk/Q==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
@@ -399,9 +402,10 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@@ -1301,12 +1305,13 @@
}
},
"node_modules/vue-i18n": {
"version": "10.0.5",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.5.tgz",
"integrity": "sha512-9/gmDlCblz3i8ypu/afiIc/SUIfTTE1mr0mZhb9pk70xo2csHAM9mp2gdQ3KD2O0AM3Hz/5ypb+FycTj/lHlPQ==",
"version": "10.0.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.6.tgz",
"integrity": "sha512-pQPspK5H4srzlu+47+HEY2tmiY3GyYIvSPgSBdQaYVWv7t1zj1t9p1FvHlxBXyJ17t9stG/Vxj+pykrvPWBLeQ==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "10.0.5",
"@intlify/shared": "10.0.5",
"@intlify/core-base": "10.0.6",
"@intlify/shared": "10.0.6",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
+2 -2
View File
@@ -20,7 +20,7 @@
"sass": "^1.78.0"
},
"dependencies": {
"axios": "^1.7.7",
"axios": "^1.8.2",
"chart.js": "^4.4.4",
"moment": "^2.30.1",
"qrcode.vue": "^3.4.1",
@@ -29,7 +29,7 @@
"nostr-tools": "^2.7.2",
"underscore": "^1.13.7",
"vue": "3.5.8",
"vue-i18n": "^10.0.5",
"vue-i18n": "^10.0.6",
"vue-qrcode-reader": "^5.5.10",
"vue-router": "4.4.5",
"vuex": "4.1.0"
Generated
+2 -21
View File
@@ -1395,9 +1395,6 @@ files = [
{file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"},
]
[package.dependencies]
zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff", "zipp (>=3.17)"]
@@ -2916,7 +2913,6 @@ files = [
[package.dependencies]
anyio = ">=3.4.0,<5"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
@@ -3400,26 +3396,11 @@ files = [
{file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"},
]
[[package]]
name = "zipp"
version = "3.19.2"
description = "Backport of pathlib-compatible object wrapper for zip files"
optional = false
python-versions = ">=3.8"
files = [
{file = "zipp-3.19.2-py3-none-any.whl", hash = "sha256:f091755f667055f2d02b32c53771a7a6c8b47e1fdbc4b72a8b9072b3eef8015c"},
{file = "zipp-3.19.2.tar.gz", hash = "sha256:bf1dcf6450f873a13e952a29504887c89e6de7506209e5b1bcc3460135d4de19"},
]
[package.extras]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
[extras]
breez = ["breez-sdk"]
liquid = ["wallycore"]
[metadata]
lock-version = "2.0"
python-versions = "^3.13 | ^3.12 | ^3.11 | ^3.10 | ^3.9"
content-hash = "e263865649975ea7e977b3cbf6cb453c3653de115523d026e3863605ab48a463"
python-versions = "~3.12 | ~3.11 | ~3.10"
content-hash = "96dd180aaa4fbfeb34fa6f9647c8684fce183a72b1b41d22101a9dd4b962fa2e"
+3 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "lnbits"
version = "1.0.0-rc8"
version = "1.0.0-rc9"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = ["Alan Bits <alan@lnbits.com>"]
readme = "README.md"
@@ -12,7 +12,7 @@ packages = [
]
[tool.poetry.dependencies]
python = "^3.13 | ^3.12 | ^3.11 | ^3.10 | ^3.9"
python = "~3.12 | ~3.11 | ~3.10"
bech32 = "1.2.0"
click = "8.1.7"
ecdsa = "0.19.0"
@@ -192,7 +192,7 @@ extend-exclude = [
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
# RUF012: mutable-class-default
ignore = ["UP007", "RUF012"]
ignore = ["RUF012"]
# Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]