Compare commits

..
Author SHA1 Message Date
arcbtc 395ba3aae6 init 2025-03-04 09:25:20 +00: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
45 changed files with 1478 additions and 652 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ LNBITS_ADMIN_USERS=""
# SUPER_USER="" # SUPER_USER=""
# Extensions only admin can access # Extensions only admin can access
LNBITS_ADMIN_EXTENSIONS="ngrok, admin" LNBITS_ADMIN_EXTENSIONS="ngrok, nostrclient"
# Extensions enabled by default when a user is created # Extensions enabled by default when a user is created
LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp" LNBITS_USER_DEFAULT_EXTENSIONS="lnurlp"
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
poetry run pip install pyinstaller poetry run pip install pyinstaller
# Build the LNbits binary # 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 ../../../../.. cd ../../../../..
chmod +x packaging/linux/AppDir/AppRun chmod +x packaging/linux/AppDir/AppRun
chmod +x packaging/linux/AppDir/lnbits.desktop 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 sudo apt install pkg-config libffi-dev libpq-dev
# build essentials for debian/ubuntu # 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 the secp256k1 build fails:
# if you used poetry # if you used poetry
+1
View File
@@ -81,6 +81,7 @@ async def startup(app: FastAPI):
# setup admin settings # setup admin settings
await check_admin_settings() await check_admin_settings()
core_app_extra.register_new_ratelimiter()
await check_webpush_settings() await check_webpush_settings()
# check extensions after restart # check extensions after restart
+15 -16
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -10,16 +9,16 @@ from lnbits.settings import settings
class AuditEntry(BaseModel): class AuditEntry(BaseModel):
component: Optional[str] = None component: str | None = None
ip_address: Optional[str] = None ip_address: str | None = None
user_id: Optional[str] = None user_id: str | None = None
path: Optional[str] = None path: str | None = None
request_type: Optional[str] = None request_type: str | None = None
request_method: Optional[str] = None request_method: str | None = None
request_details: Optional[str] = None request_details: str | None = None
response_code: Optional[str] = None response_code: str | None = None
duration: float duration: float
delete_at: Optional[datetime] = None delete_at: datetime | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
def __init__(self, **data): def __init__(self, **data):
@@ -42,12 +41,12 @@ class AuditFilters(FilterModel):
"duration", "duration",
] ]
ip_address: Optional[str] = None ip_address: str | None = None
user_id: Optional[str] = None user_id: str | None = None
path: Optional[str] = None path: str | None = None
request_method: Optional[str] = None request_method: str | None = None
response_code: Optional[str] = None response_code: str | None = None
component: Optional[str] = None component: str | None = None
class AuditCountStat(BaseModel): class AuditCountStat(BaseModel):
+62 -64
View File
@@ -7,7 +7,7 @@ import os
import shutil import shutil
import zipfile import zipfile
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
@@ -29,17 +29,17 @@ class ExplicitRelease(BaseModel):
archive: str archive: str
hash: str hash: str
dependencies: list[str] = [] dependencies: list[str] = []
repo: Optional[str] repo: str | None
icon: Optional[str] icon: str | None
short_description: Optional[str] short_description: str | None
min_lnbits_version: Optional[str] min_lnbits_version: str | None
max_lnbits_version: Optional[str] max_lnbits_version: str | None
html_url: Optional[str] # todo: release_url html_url: str | None # todo: release_url
warning: Optional[str] warning: str | None
info_notification: Optional[str] info_notification: str | None
critical_notification: Optional[str] critical_notification: str | None
details_link: Optional[str] details_link: str | None
pay_link: Optional[str] pay_link: str | None
def is_version_compatible(self): def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -77,9 +77,9 @@ class ExtensionConfig(BaseModel):
name: str name: str
short_description: str short_description: str
tile: str = "" tile: str = ""
warning: Optional[str] = "" warning: str | None = ""
min_lnbits_version: Optional[str] min_lnbits_version: str | None
max_lnbits_version: Optional[str] max_lnbits_version: str | None
def is_version_compatible(self) -> bool: def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -87,7 +87,7 @@ class ExtensionConfig(BaseModel):
@classmethod @classmethod
async def fetch_github_release_config( async def fetch_github_release_config(
cls, org: str, repo: str, tag_name: str cls, org: str, repo: str, tag_name: str
) -> Optional[ExtensionConfig]: ) -> ExtensionConfig | None:
config_url = ( config_url = (
f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json" f"https://raw.githubusercontent.com/{org}/{repo}/{tag_name}/config.json"
) )
@@ -97,28 +97,28 @@ class ExtensionConfig(BaseModel):
class ReleasePaymentInfo(BaseModel): class ReleasePaymentInfo(BaseModel):
amount: Optional[int] = None amount: int | None = None
pay_link: Optional[str] = None pay_link: str | None = None
payment_hash: Optional[str] = None payment_hash: str | None = None
payment_request: Optional[str] = None payment_request: str | None = None
class PayToEnableInfo(BaseModel): class PayToEnableInfo(BaseModel):
amount: int = 0 amount: int = 0
required: bool = False required: bool = False
wallet: Optional[str] = None wallet: str | None = None
class UserExtensionInfo(BaseModel): class UserExtensionInfo(BaseModel):
paid_to_enable: Optional[bool] = False paid_to_enable: bool | None = False
payment_hash_to_enable: Optional[str] = None payment_hash_to_enable: str | None = None
class UserExtension(BaseModel): class UserExtension(BaseModel):
user: str user: str
extension: str extension: str
active: bool active: bool
extra: Optional[UserExtensionInfo] = None extra: UserExtensionInfo | None = None
@property @property
def is_paid(self) -> bool: def is_paid(self) -> bool:
@@ -140,10 +140,10 @@ class UserExtension(BaseModel):
class Extension(BaseModel): class Extension(BaseModel):
code: str code: str
is_valid: bool is_valid: bool
name: Optional[str] = None name: str | None = None
short_description: Optional[str] = None short_description: str | None = None
tile: Optional[str] = None tile: str | None = None
upgrade_hash: Optional[str] = "" upgrade_hash: str | None = ""
@property @property
def module_name(self) -> str: def module_name(self) -> str:
@@ -176,21 +176,21 @@ class ExtensionRelease(BaseModel):
archive: str archive: str
source_repo: str source_repo: str
is_github_release: bool = False is_github_release: bool = False
hash: Optional[str] = None hash: str | None = None
min_lnbits_version: Optional[str] = None min_lnbits_version: str | None = None
max_lnbits_version: Optional[str] = None max_lnbits_version: str | None = None
is_version_compatible: Optional[bool] = True is_version_compatible: bool | None = True
html_url: Optional[str] = None html_url: str | None = None
description: Optional[str] = None description: str | None = None
warning: Optional[str] = None warning: str | None = None
repo: Optional[str] = None repo: str | None = None
icon: Optional[str] = None icon: str | None = None
details_link: Optional[str] = None details_link: str | None = None
pay_link: Optional[str] = None pay_link: str | None = None
cost_sats: Optional[int] = None cost_sats: int | None = None
paid_sats: Optional[int] = 0 paid_sats: int | None = 0
payment_hash: Optional[str] = None payment_hash: str | None = None
@property @property
def archive_url(self) -> str: def archive_url(self) -> str:
@@ -208,8 +208,8 @@ class ExtensionRelease(BaseModel):
self.cost_sats = payment_info.amount if payment_info else None self.cost_sats = payment_info.amount if payment_info else None
async def fetch_release_payment_info( async def fetch_release_payment_info(
self, amount: Optional[int] = None self, amount: int | None = None
) -> Optional[ReleasePaymentInfo]: ) -> ReleasePaymentInfo | None:
url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link url = f"{self.pay_link}?amount={amount}" if amount else self.pay_link
assert url, "Missing URL for payment info." assert url, "Missing URL for payment info."
try: try:
@@ -281,7 +281,7 @@ class ExtensionRelease(BaseModel):
return [GitHubRepoRelease.parse_obj(r) for r in releases] return [GitHubRepoRelease.parse_obj(r) for r in releases]
@classmethod @classmethod
async def fetch_release_details(cls, details_link: str) -> Optional[dict]: async def fetch_release_details(cls, details_link: str) -> dict | None:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@@ -300,12 +300,12 @@ class ExtensionRelease(BaseModel):
class ExtensionMeta(BaseModel): class ExtensionMeta(BaseModel):
installed_release: Optional[ExtensionRelease] = None installed_release: ExtensionRelease | None = None
latest_release: Optional[ExtensionRelease] = None latest_release: ExtensionRelease | None = None
pay_to_enable: Optional[PayToEnableInfo] = None pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = [] payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = [] dependencies: list[str] = []
archive: Optional[str] = None archive: str | None = None
featured: bool = False featured: bool = False
@@ -313,11 +313,11 @@ class InstallableExtension(BaseModel):
id: str id: str
name: str name: str
version: str version: str
active: Optional[bool] = False active: bool | None = False
short_description: Optional[str] = None short_description: str | None = None
icon: Optional[str] = None icon: str | None = None
stars: int = 0 stars: int = 0
meta: Optional[ExtensionMeta] = None meta: ExtensionMeta | None = None
@property @property
def hash(self) -> str: def hash(self) -> str:
@@ -452,7 +452,7 @@ class InstallableExtension(BaseModel):
shutil.rmtree(self.ext_upgrade_dir, True) 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: if not release:
return return
if not self.meta or not self.meta.latest_release: if not self.meta or not self.meta.latest_release:
@@ -465,9 +465,7 @@ class InstallableExtension(BaseModel):
): ):
self.meta.latest_release = release self.meta.latest_release = release
def find_existing_payment( def find_existing_payment(self, pay_link: str | None) -> ReleasePaymentInfo | None:
self, pay_link: Optional[str]
) -> Optional[ReleasePaymentInfo]:
if not pay_link or not self.meta or not self.meta.payments: if not pay_link or not self.meta or not self.meta.payments:
return None return None
return next( return next(
@@ -507,7 +505,7 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def from_github_release( async def from_github_release(
cls, github_release: GitHubRelease cls, github_release: GitHubRelease
) -> Optional[InstallableExtension]: ) -> InstallableExtension | None:
try: try:
repo, latest_release, config = await cls.fetch_github_repo_info( repo, latest_release, config = await cls.fetch_github_repo_info(
github_release.organisation, github_release.repository github_release.organisation, github_release.repository
@@ -546,7 +544,7 @@ class InstallableExtension(BaseModel):
) )
@classmethod @classmethod
def from_ext_dir(cls, ext_id: str) -> Optional[InstallableExtension]: def from_ext_dir(cls, ext_id: str) -> InstallableExtension | None:
try: try:
conf_path = Path( conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json" settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
@@ -657,7 +655,7 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_release( async def get_extension_release(
cls, ext_id: str, source_repo: str, archive: str, version: str cls, ext_id: str, source_repo: str, archive: str, version: str
) -> Optional[ExtensionRelease]: ) -> ExtensionRelease | None:
all_releases: list[ExtensionRelease] = ( all_releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
) )
@@ -708,8 +706,8 @@ class CreateExtension(BaseModel):
archive: str archive: str
source_repo: str source_repo: str
version: str version: str
cost_sats: Optional[int] = 0 cost_sats: int | None = 0
payment_hash: Optional[str] = None payment_hash: str | None = None
class ExtensionDetailsRequest(BaseModel): class ExtensionDetailsRequest(BaseModel):
@@ -718,7 +716,7 @@ class ExtensionDetailsRequest(BaseModel):
version: str 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} headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token: if settings.lnbits_ext_github_token:
headers["Authorization"] = f"Bearer {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() 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: if not path:
return "" return ""
_, _, *rest = path.split("/") _, _, *rest = path.split("/")
+38 -38
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Literal, Optional from typing import Literal
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
@@ -28,16 +28,16 @@ class PaymentState(str, Enum):
class PaymentExtra(BaseModel): class PaymentExtra(BaseModel):
comment: Optional[str] = None comment: str | None = None
success_action: Optional[str] = None success_action: str | None = None
lnurl_response: Optional[str] = None lnurl_response: str | None = None
class PayInvoice(BaseModel): class PayInvoice(BaseModel):
payment_request: str payment_request: str
description: Optional[str] = None description: str | None = None
max_sat: Optional[int] = None max_sat: int | None = None
extra: Optional[dict] = {} extra: dict | None = {}
class CreatePayment(BaseModel): class CreatePayment(BaseModel):
@@ -46,10 +46,10 @@ class CreatePayment(BaseModel):
bolt11: str bolt11: str
amount_msat: int amount_msat: int
memo: str memo: str
extra: Optional[dict] = {} extra: dict | None = {}
preimage: Optional[str] = None preimage: str | None = None
expiry: Optional[datetime] = None expiry: datetime | None = None
webhook: Optional[str] = None webhook: str | None = None
fee: int = 0 fee: int = 0
@@ -61,13 +61,13 @@ class Payment(BaseModel):
fee: int fee: int
bolt11: str bolt11: str
status: str = PaymentState.PENDING status: str = PaymentState.PENDING
memo: Optional[str] = None memo: str | None = None
expiry: Optional[datetime] = None expiry: datetime | None = None
webhook: Optional[str] = None webhook: str | None = None
webhook_status: Optional[int] = None webhook_status: int | None = None
preimage: Optional[str] = None preimage: str | None = None
tag: Optional[str] = None tag: str | None = None
extension: Optional[str] = None extension: str | None = None
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -129,16 +129,16 @@ class PaymentFilters(FilterModel):
__sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"] __sort_fields__ = ["created_at", "amount", "fee", "memo", "time", "tag"]
status: Optional[str] status: str | None
tag: Optional[str] tag: str | None
checking_id: Optional[str] checking_id: str | None
amount: int amount: int
fee: int fee: int
memo: Optional[str] memo: str | None
time: datetime time: datetime
preimage: Optional[str] preimage: str | None
payment_hash: Optional[str] payment_hash: str | None
wallet_id: Optional[str] wallet_id: str | None
class PaymentDataPoint(BaseModel): class PaymentDataPoint(BaseModel):
@@ -173,11 +173,11 @@ class PaymentWalletStats(BaseModel):
class PaymentDailyStats(BaseModel): class PaymentDailyStats(BaseModel):
date: datetime date: datetime
balance: float = 0 balance: float = 0
balance_in: Optional[float] = 0 balance_in: float | None = 0
balance_out: Optional[float] = 0 balance_out: float | None = 0
payments_count: int = 0 payments_count: int = 0
count_in: Optional[int] = 0 count_in: int | None = 0
count_out: Optional[int] = 0 count_out: int | None = 0
fee: float = 0 fee: float = 0
@@ -190,7 +190,7 @@ class PaymentHistoryPoint(BaseModel):
class DecodePayment(BaseModel): class DecodePayment(BaseModel):
data: str data: str
filter_fields: Optional[list[str]] = [] filter_fields: list[str] | None = []
class CreateInvoice(BaseModel): class CreateInvoice(BaseModel):
@@ -198,14 +198,14 @@ class CreateInvoice(BaseModel):
internal: bool = False internal: bool = False
out: bool = True out: bool = True
amount: float = Query(None, ge=0) amount: float = Query(None, ge=0)
memo: Optional[str] = None memo: str | None = None
description_hash: Optional[str] = None description_hash: str | None = None
unhashed_description: Optional[str] = None unhashed_description: str | None = None
expiry: Optional[int] = None expiry: int | None = None
extra: Optional[dict] = None extra: dict | None = None
webhook: Optional[str] = None webhook: str | None = None
bolt11: Optional[str] = None bolt11: str | None = None
lnurl_callback: Optional[str] = None lnurl_callback: str | None = None
@validator("unit") @validator("unit")
@classmethod @classmethod
+43 -44
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional
from uuid import UUID from uuid import UUID
from fastapi import Query from fastapi import Query
@@ -17,16 +16,16 @@ from .wallets import Wallet
class UserExtra(BaseModel): class UserExtra(BaseModel):
email_verified: Optional[bool] = False email_verified: bool | None = False
first_name: Optional[str] = None first_name: str | None = None
last_name: Optional[str] = None last_name: str | None = None
display_name: Optional[str] = None display_name: str | None = None
picture: Optional[str] = None picture: str | None = None
# Auth provider, possible values: # Auth provider, possible values:
# - "env": the user was created automatically by the system # - "env": the user was created automatically by the system
# - "lnbits": the user was created via register form (username/pass or user_id only) # - "lnbits": the user was created via register form (username/pass or user_id only)
# - "google | github | ...": the user was created using an SSO provider # - "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): class EndpointAccess(BaseModel):
@@ -50,13 +49,13 @@ class AccessControlList(BaseModel):
endpoints: list[EndpointAccess] = [] endpoints: list[EndpointAccess] = []
token_id_list: list[SimpleItem] = [] 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: for e in self.endpoints:
if e.path == path: if e.path == path:
return e return e
return None 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: for t in self.token_id_list:
if t.id == token_id: if t.id == token_id:
return t return t
@@ -71,7 +70,7 @@ class UserAcls(BaseModel):
access_control_list: list[AccessControlList] = [] access_control_list: list[AccessControlList] = []
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) 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: for acl in self.access_control_list:
if acl.id == acl_id: if acl.id == acl_id:
return acl return acl
@@ -82,7 +81,7 @@ class UserAcls(BaseModel):
acl for acl in self.access_control_list if acl.id != acl_id 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: for acl in self.access_control_list:
if acl.get_token_by_id(token_id): if acl.get_token_by_id(token_id):
return acl return acl
@@ -91,10 +90,10 @@ class UserAcls(BaseModel):
class Account(BaseModel): class Account(BaseModel):
id: str id: str
username: Optional[str] = None username: str | None = None
password_hash: Optional[str] = None password_hash: str | None = None
pubkey: Optional[str] = None pubkey: str | None = None
email: Optional[str] = None email: str | None = None
extra: UserExtra = UserExtra() extra: UserExtra = UserExtra()
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@@ -134,10 +133,10 @@ class Account(BaseModel):
class AccountOverview(Account): class AccountOverview(Account):
transaction_count: Optional[int] = 0 transaction_count: int | None = 0
wallet_count: Optional[int] = 0 wallet_count: int | None = 0
balance_msat: Optional[int] = 0 balance_msat: int | None = 0
last_payment: Optional[datetime] = None last_payment: datetime | None = None
class AccountFilters(FilterModel): class AccountFilters(FilterModel):
@@ -151,20 +150,20 @@ class AccountFilters(FilterModel):
"last_payment", "last_payment",
] ]
email: Optional[str] = None email: str | None = None
user: Optional[str] = None user: str | None = None
username: Optional[str] = None username: str | None = None
pubkey: Optional[str] = None pubkey: str | None = None
wallet_id: Optional[str] = None wallet_id: str | None = None
class User(BaseModel): class User(BaseModel):
id: str id: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
email: Optional[str] = None email: str | None = None
username: Optional[str] = None username: str | None = None
pubkey: Optional[str] = None pubkey: str | None = None
extensions: list[str] = [] extensions: list[str] = []
wallets: list[Wallet] = [] wallets: list[Wallet] = []
admin: bool = False admin: bool = False
@@ -176,7 +175,7 @@ class User(BaseModel):
def wallet_ids(self) -> list[str]: def wallet_ids(self) -> list[str]:
return [wallet.id for wallet in self.wallets] 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] w = [wallet for wallet in self.wallets if wallet.id == wallet_id]
return w[0] if w else None return w[0] if w else None
@@ -192,33 +191,33 @@ class User(BaseModel):
class RegisterUser(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) username: str = Query(default=..., min_length=2, max_length=20)
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: str = Query(default=..., min_length=8, max_length=50) password_repeat: str = Query(default=..., min_length=8, max_length=50)
class CreateUser(BaseModel): class CreateUser(BaseModel):
id: Optional[str] = Query(default=None) id: str | None = Query(default=None)
email: Optional[str] = Query(default=None) email: str | None = Query(default=None)
username: Optional[str] = Query(default=None, min_length=2, max_length=20) username: str | None = Query(default=None, min_length=2, max_length=20)
password: Optional[str] = Query(default=None, min_length=8, max_length=50) password: str | None = Query(default=None, min_length=8, max_length=50)
password_repeat: Optional[str] = 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) pubkey: str = Query(default=None, max_length=64)
extensions: Optional[list[str]] = None extensions: list[str] | None = None
extra: Optional[UserExtra] = None extra: UserExtra | None = None
class UpdateUser(BaseModel): class UpdateUser(BaseModel):
user_id: str user_id: str
email: Optional[str] = Query(default=None) email: str | None = Query(default=None)
username: Optional[str] = Query(default=..., min_length=2, max_length=20) username: str | None = Query(default=..., min_length=2, max_length=20)
extra: Optional[UserExtra] = None extra: UserExtra | None = None
class UpdateUserPassword(BaseModel): class UpdateUserPassword(BaseModel):
user_id: str user_id: str
password_old: Optional[str] = None password_old: str | None = None
password: str = Query(default=..., min_length=8, max_length=50) password: str = Query(default=..., min_length=8, max_length=50)
password_repeat: 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) username: str = Query(default=..., min_length=2, max_length=20)
@@ -252,10 +251,10 @@ class LoginUsernamePassword(BaseModel):
class AccessTokenPayload(BaseModel): class AccessTokenPayload(BaseModel):
sub: str sub: str
usr: Optional[str] = None usr: str | None = None
email: Optional[str] = None email: str | None = None
auth_time: Optional[int] = 0 auth_time: int | None = 0
api_token_id: Optional[str] = None api_token_id: str | None = None
class UpdateBalance(BaseModel): class UpdateBalance(BaseModel):
+2 -3
View File
@@ -5,7 +5,6 @@ import hmac
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Optional
from ecdsa import SECP256k1, SigningKey from ecdsa import SECP256k1, SigningKey
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -37,7 +36,7 @@ class Wallet(BaseModel):
deleted: bool = False deleted: bool = False
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
currency: Optional[str] = None currency: str | None = None
balance_msat: int = Field(default=0, no_database=True) balance_msat: int = Field(default=0, no_database=True)
extra: WalletExtra = WalletExtra() extra: WalletExtra = WalletExtra()
@@ -67,7 +66,7 @@ class Wallet(BaseModel):
class CreateWallet(BaseModel): class CreateWallet(BaseModel):
name: Optional[str] = None name: str | None = None
class KeyType(Enum): class KeyType(Enum):
+2 -3
View File
@@ -3,7 +3,7 @@ from .funding_source import (
switch_to_voidwallet, switch_to_voidwallet,
) )
from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw from .lnurl import perform_lnurlauth, redeem_lnurl_withdraw
from .notifications import enqueue_notification from .notifications import enqueue_notification, send_payment_notification
from .payments import ( from .payments import (
calculate_fiat_amounts, calculate_fiat_amounts,
check_transaction_status, check_transaction_status,
@@ -12,7 +12,6 @@ from .payments import (
fee_reserve, fee_reserve,
fee_reserve_total, fee_reserve_total,
pay_invoice, pay_invoice,
send_payment_notification,
service_fee, service_fee,
update_pending_payments, update_pending_payments,
update_wallet_balance, update_wallet_balance,
@@ -40,6 +39,7 @@ __all__ = [
"perform_lnurlauth", "perform_lnurlauth",
# notifications # notifications
"enqueue_notification", "enqueue_notification",
"send_payment_notification",
# payments # payments
"calculate_fiat_amounts", "calculate_fiat_amounts",
"check_transaction_status", "check_transaction_status",
@@ -48,7 +48,6 @@ __all__ = [
"fee_reserve", "fee_reserve",
"fee_reserve_total", "fee_reserve_total",
"pay_invoice", "pay_invoice",
"send_payment_notification",
"service_fee", "service_fee",
"update_pending_payments", "update_pending_payments",
"update_wallet_balance", "update_wallet_balance",
+189
View File
@@ -1,15 +1,30 @@
import asyncio 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 from typing import Optional, Tuple
import httpx import httpx
from loguru import logger 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 ( from lnbits.core.models.notifications import (
NOTIFICATION_TEMPLATES, NOTIFICATION_TEMPLATES,
NotificationMessage, NotificationMessage,
NotificationType, NotificationType,
) )
from lnbits.core.services.nostr import fetch_nip5_details, send_nostr_dm 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
from lnbits.settings import settings from lnbits.settings import settings
from lnbits.utils.nostr import normalize_private_key from lnbits.utils.nostr import normalize_private_key
@@ -50,6 +65,12 @@ async def send_notification(
logger.debug(f"Sent nostr notification: {message_type}") logger.debug(f"Sent nostr notification: {message_type}")
except Exception as e: except Exception as e:
logger.error(f"Error sending nostr notification {message_type}: {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: async def send_nostr_notification(message: str) -> dict:
@@ -90,6 +111,43 @@ async def send_telegram_message(token: str, chat_id: str, message: str) -> dict:
return response.json() return response.json()
async def send_email_notification(message: str) -> dict:
await send_email(
settings.lnbits_email_notifications_server,
settings.lnbits_email_notifications_port,
settings.lnbits_email_notifications_password,
settings.lnbits_email_notifications_email,
settings.lnbits_email_notifications_to_emails,
"LNbits Notification",
message,
)
return {"status": "ok"}
async def send_email(
server: str,
port: int,
password: str,
from_email: str,
to_emails: list,
subject: str,
message: str,
):
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg.attach(MIMEText(message, "plain"))
try:
with smtplib.SMTP(server, port) as smtp_server:
smtp_server.starttls()
smtp_server.login(from_email, password)
smtp_server.sendmail(from_email, to_emails, msg.as_string())
logger.debug(f"Emails sent successfully to: {', '.join(to_emails)}")
except Exception as e:
logger.debug(f"Failed to send email: {e}")
def is_message_type_enabled(message_type: NotificationType) -> bool: def is_message_type_enabled(message_type: NotificationType) -> bool:
if message_type == NotificationType.balance_update: if message_type == NotificationType.balance_update:
return settings.lnbits_notification_credit_debit return settings.lnbits_notification_credit_debit
@@ -123,3 +181,134 @@ def _notification_message_to_text(
text = meesage_value text = meesage_value
text = f"""[{settings.lnbits_site_title}]\n{text}""" text = f"""[{settings.lnbits_site_title}]\n{text}"""
return message_type, 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}), 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 asyncio
import json
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
@@ -12,8 +11,6 @@ from loguru import logger
from lnbits.core.crud.payments import get_daily_stats from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.notifications import enqueue_notification
from lnbits.db import Connection, Filters from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError from lnbits.exceptions import InvoiceError, PaymentError
@@ -45,7 +42,7 @@ from ..models import (
PaymentState, PaymentState,
Wallet, Wallet,
) )
from .websockets import websocket_manager from .notifications import send_payment_notification
async def pay_invoice( async def pay_invoice(
@@ -181,13 +178,20 @@ async def update_pending_payments(wallet_id: str):
exclude_uncheckable=True, exclude_uncheckable=True,
) )
for payment in pending_payments: for payment in pending_payments:
status = await payment.check_status() await update_pending_payment(payment)
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment) async def update_pending_payment(payment: Payment) -> bool:
elif status.success: status = await payment.check_status()
payment.status = PaymentState.SUCCESS if status.failed:
await update_payment(payment) 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: 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) 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( async def check_wallet_limits(
wallet_id: str, amount_msat: int, conn: Optional[Connection] = None wallet_id: str, amount_msat: int, conn: Optional[Connection] = None
): ):
+3 -65
View File
@@ -2,25 +2,19 @@ import asyncio
import traceback import traceback
from typing import Callable, Coroutine from typing import Callable, Coroutine
import httpx
from loguru import logger from loguru import logger
from lnbits.core.crud import ( from lnbits.core.crud import (
create_audit_entry, create_audit_entry,
get_wallet, get_wallet,
get_webpush_subscriptions_for_user,
mark_webhook_sent,
) )
from lnbits.core.crud.audit import delete_expired_audit_entries from lnbits.core.crud.audit import delete_expired_audit_entries
from lnbits.core.crud.payments import get_payments_status_count from lnbits.core.crud.payments import get_payments_status_count
from lnbits.core.crud.users import get_accounts from lnbits.core.crud.users import get_accounts
from lnbits.core.crud.wallets import get_wallets_count 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.extensions import InstallableExtension
from lnbits.core.models.notifications import NotificationType from lnbits.core.models.notifications import NotificationType
from lnbits.core.services import (
send_payment_notification,
)
from lnbits.core.services.funding_source import ( from lnbits.core.services.funding_source import (
check_balance_delta_changed, check_balance_delta_changed,
check_server_balance_against_node, check_server_balance_against_node,
@@ -29,11 +23,11 @@ from lnbits.core.services.funding_source import (
from lnbits.core.services.notifications import ( from lnbits.core.services.notifications import (
enqueue_notification, enqueue_notification,
process_next_notification, process_next_notification,
send_payment_notification,
) )
from lnbits.db import Filters from lnbits.db import Filters
from lnbits.helpers import check_callback_url
from lnbits.settings import settings 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 from lnbits.utils.exchange_rates import btc_rates
audit_queue: asyncio.Queue = asyncio.Queue() 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) wallet = await get_wallet(payment.wallet_id)
if wallet: if wallet:
await send_payment_notification(wallet, payment) 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(): async def wait_for_audit_data():
@@ -2,7 +2,14 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('notifications_configure')"></span> <span v-text="$t('notifications_configure')"></span>
<q-btn
round
flat
@click="hideInputsToggle()"
:icon="hideInputToggle ? 'visibility_off' : 'visibility'"
></q-btn>
</h6> </h6>
<q-separator class="q-mt-md q-mb-sm"></q-separator> <q-separator class="q-mt-md q-mb-sm"></q-separator>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-sm-12 col-md-6"> <div class="col-sm-12 col-md-6">
@@ -39,12 +46,13 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-input <q-input
type="password" :type="hideInputToggle ? 'password' : 'text'"
filled filled
v-model="formData.lnbits_nostr_notifications_private_key" v-model="formData.lnbits_nostr_notifications_private_key"
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label <q-item-label
@@ -117,7 +125,7 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-input <q-input
type="password" :type="hideInputToggle ? 'password' : 'text'"
filled filled
v-model="formData.lnbits_telegram_notifications_access_token" v-model="formData.lnbits_telegram_notifications_access_token"
/> />
@@ -139,6 +147,150 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </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_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 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>
</div>
<div class="col">
<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> </div>
<q-separator> </q-separator> <q-separator> </q-separator>
<h6 class="q-mb-sm"> <h6 class="q-mb-sm">
@@ -61,6 +61,7 @@
v-model="formData.lnbits_denomination" v-model="formData.lnbits_denomination"
label="sats" label="sats"
:hint="$t('denomination_hint')" :hint="$t('denomination_hint')"
:rules="[(val) => !val || val.length == 3 || val == 'sats' || $t('denomination_error')]"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
+17 -4
View File
@@ -59,6 +59,9 @@
} }
} }
</style> </style>
<script>
const ALLOWED_REGISTER = {{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }};
</script>
<script src="{{ static_url_for('static', 'js/index.js') }}"></script> <script src="{{ static_url_for('static', 'js/index.js') }}"></script>
{% endblock %} {% block page_container %} {% endblock %} {% block page_container %}
<q-page-container> <q-page-container>
@@ -145,7 +148,7 @@
{%else%} {%else%}
<username-password <username-password
v-if="authMethod != 'user-id-only'" v-if="authMethod != 'user-id-only'"
:allowed_new_users="{{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }}" :allowed_new_users="allowedRegister"
:auth-methods="{{ LNBITS_AUTH_METHODS }}" :auth-methods="{{ LNBITS_AUTH_METHODS }}"
:auth-action="authAction" :auth-action="authAction"
v-model:user-name="username" v-model:user-name="username"
@@ -160,7 +163,10 @@
class="text-center text-grey-6" class="text-center text-grey-6"
v-if="authAction !== 'reset'" 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 Not registered? Create an
<span <span
class="text-secondary cursor-pointer" class="text-secondary cursor-pointer"
@@ -168,7 +174,14 @@
>Account</span >Account</span
> >
</p> </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? Aready have an account?
<span <span
class="text-secondary cursor-pointer" class="text-secondary cursor-pointer"
@@ -180,7 +193,7 @@
</username-password> </username-password>
{% if "user-id-only" in LNBITS_AUTH_METHODS %} {% if "user-id-only" in LNBITS_AUTH_METHODS %}
<user-id-only <user-id-only
:allowed_new_users="{{ LNBITS_NEW_ACCOUNTS_ALLOWED | tojson }}" :allowed_new_users="allowedRegister"
v-model:usr="usr" v-model:usr="usr"
v-model:wallet="walletName" v-model:wallet="walletName"
:auth-action="authAction" :auth-action="authAction"
@@ -35,6 +35,7 @@
<q-table :rows="wallets" :columns="walletTable.columns"> <q-table :rows="wallets" :columns="walletTable.columns">
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="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></q-th>
<q-th <q-th
auto-width auto-width
@@ -47,12 +48,14 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width> <q-td auto-width v-if="g.user.super_user">
<lnbits-update-balance <lnbits-update-balance
:wallet_id="props.row.id" :wallet_id="props.row.id"
@credit-value="handleBalanceUpdate" @credit-value="handleBalanceUpdate"
class="q-mr-md" class="q-mr-md"
></lnbits-update-balance> ></lnbits-update-balance>
</q-td>
<q-td auto-width>
<q-btn <q-btn
round round
icon="menu" icon="menu"
+8 -2
View File
@@ -37,7 +37,10 @@ from lnbits.core.models import (
Wallet, Wallet,
) )
from lnbits.core.models.users import User 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.db import Filters, Page
from lnbits.decorators import ( from lnbits.decorators import (
WalletTypeInfo, WalletTypeInfo,
@@ -180,11 +183,14 @@ async def api_payments_paginated(
key_info: WalletTypeInfo = Depends(require_invoice_key), key_info: WalletTypeInfo = Depends(require_invoice_key),
filters: Filters = Depends(parse_filters(PaymentFilters)), filters: Filters = Depends(parse_filters(PaymentFilters)),
): ):
await update_pending_payments(key_info.wallet.id)
page = await get_payments_paginated( page = await get_payments_paginated(
wallet_id=key_info.wallet.id, wallet_id=key_info.wallet.id,
filters=filters, filters=filters,
) )
for payment in page.data:
if payment.pending:
await update_pending_payment(payment)
return page return page
+35 -35
View File
@@ -8,7 +8,7 @@ import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from typing import Any, Generic, Literal, Optional, TypeVar, Union, get_origin from typing import Any, Generic, Literal, TypeVar, get_origin
from loguru import logger from loguru import logger
from pydantic import BaseModel, ValidationError, root_validator from pydantic import BaseModel, ValidationError, root_validator
@@ -65,8 +65,8 @@ def get_placeholder(model: Any, field: str) -> str:
class Compat: class Compat:
type: Optional[str] = "<inherited>" type: str | None = "<inherited>"
schema: Optional[str] = "<inherited>" schema: str | None = "<inherited>"
def interval_seconds(self, seconds: int) -> str: def interval_seconds(self, seconds: int) -> str:
if self.type in {POSTGRES, COCKROACH}: if self.type in {POSTGRES, COCKROACH}:
@@ -167,8 +167,8 @@ class Connection(Compat):
async def fetchall( async def fetchall(
self, self,
query: str, query: str,
values: Optional[dict] = None, values: dict | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
) -> list[TModel]: ) -> list[TModel]:
params = self.rewrite_values(values) if values else {} params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params) result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -183,8 +183,8 @@ class Connection(Compat):
async def fetchone( async def fetchone(
self, self,
query: str, query: str,
values: Optional[dict] = None, values: dict | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
) -> TModel: ) -> TModel:
params = self.rewrite_values(values) if values else {} params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params) result = await self.conn.execute(text(self.rewrite_query(query)), params)
@@ -211,11 +211,11 @@ class Connection(Compat):
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: list[str] | None = None,
values: Optional[dict] = None, values: dict | None = None,
filters: Optional[Filters] = None, filters: Filters | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
group_by: Optional[list[str]] = None, group_by: list[str] | None = None,
) -> Page[TModel]: ) -> Page[TModel]:
if not filters: if not filters:
filters = Filters() filters = Filters()
@@ -268,7 +268,7 @@ class Connection(Compat):
total=count, 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 {} params = self.rewrite_values(values) if values else {}
result = await self.conn.execute(text(self.rewrite_query(query)), params) result = await self.conn.execute(text(self.rewrite_query(query)), params)
await self.conn.commit() await self.conn.commit()
@@ -350,8 +350,8 @@ class Database(Compat):
async def fetchall( async def fetchall(
self, self,
query: str, query: str,
values: Optional[dict] = None, values: dict | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
) -> list[TModel]: ) -> list[TModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetchall(query, values, model) return await conn.fetchall(query, values, model)
@@ -359,8 +359,8 @@ class Database(Compat):
async def fetchone( async def fetchone(
self, self,
query: str, query: str,
values: Optional[dict] = None, values: dict | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
) -> TModel: ) -> TModel:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetchone(query, values, model) return await conn.fetchone(query, values, model)
@@ -378,16 +378,16 @@ class Database(Compat):
async def fetch_page( async def fetch_page(
self, self,
query: str, query: str,
where: Optional[list[str]] = None, where: list[str] | None = None,
values: Optional[dict] = None, values: dict | None = None,
filters: Optional[Filters] = None, filters: Filters | None = None,
model: Optional[type[TModel]] = None, model: type[TModel] | None = None,
group_by: Optional[list[str]] = None, group_by: list[str] | None = None,
) -> Page[TModel]: ) -> Page[TModel]:
async with self.connect() as conn: async with self.connect() as conn:
return await conn.fetch_page(query, where, values, filters, model, group_by) return await conn.fetch_page(query, where, values, filters, model, group_by)
async def execute(self, query: str, values: Optional[dict] = None): async def execute(self, query: str, values: dict | None = None):
async with self.connect() as conn: async with self.connect() as conn:
return await conn.execute(query, values) return await conn.execute(query, values)
@@ -445,7 +445,7 @@ class Operator(Enum):
class FilterModel(BaseModel): class FilterModel(BaseModel):
__search_fields__: list[str] = [] __search_fields__: list[str] = []
__sort_fields__: Optional[list[str]] = None __sort_fields__: list[str] | None = None
T = TypeVar("T") T = TypeVar("T")
@@ -461,8 +461,8 @@ class Page(BaseModel, Generic[T]):
class Filter(BaseModel, Generic[TFilterModel]): class Filter(BaseModel, Generic[TFilterModel]):
field: str field: str
op: Operator = Operator.EQ op: Operator = Operator.EQ
model: Optional[type[TFilterModel]] model: type[TFilterModel] | None
values: Optional[dict] = None values: dict | None = None
@classmethod @classmethod
def parse_query( def parse_query(
@@ -517,15 +517,15 @@ class Filters(BaseModel, Generic[TFilterModel]):
""" """
filters: list[Filter[TFilterModel]] = [] filters: list[Filter[TFilterModel]] = []
search: Optional[str] = None search: str | None = None
offset: Optional[int] = None offset: int | None = None
limit: Optional[int] = None limit: int | None = None
sortby: Optional[str] = None sortby: str | None = None
direction: Optional[Literal["asc", "desc"]] = None direction: Literal["asc", "desc"] | None = None
model: Optional[type[TFilterModel]] = None model: type[TFilterModel] | None = None
@root_validator(pre=True) @root_validator(pre=True)
def validate_sortby(cls, values): def validate_sortby(cls, values):
@@ -547,7 +547,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
stmt += f"OFFSET {self.offset}" stmt += f"OFFSET {self.offset}"
return stmt 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: if not where_stmts:
where_stmts = [] where_stmts = []
if self.filters: if self.filters:
@@ -567,7 +567,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
return f"ORDER BY {self.sortby} {self.direction or 'asc'}" return f"ORDER BY {self.sortby} {self.direction or 'asc'}"
return "" return ""
def values(self, values: Optional[dict] = None) -> dict: def values(self, values: dict | None = None) -> dict:
if not values: if not values:
values = {} values = {}
if self.filters: if self.filters:
@@ -641,7 +641,7 @@ def model_to_dict(model: BaseModel) -> dict:
return _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""" """convert a dictionary or JSON string to a Pydantic model"""
if isinstance(value, str): if isinstance(value, str):
if value == "null": if value == "null":
+22 -6
View File
@@ -225,18 +225,34 @@ def create_access_token(data: dict, token_expire_minutes: Optional[int] = None)
return jwt.encode(to_encode, settings.auth_secret_key, "HS256") return jwt.encode(to_encode, settings.auth_secret_key, "HS256")
def encrypt_internal_message(m: Optional[str] = None) -> Optional[str]: def encrypt_internal_message(
"""Encrypt message with the internal secret key""" 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: if not m:
return None 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]: def decrypt_internal_message(
"""Decrypt message with the internal secret key""" 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: if not m:
return None 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: def filter_dict_keys(data: dict, filter_keys: Optional[list[str]]) -> dict:
+3 -2
View File
@@ -134,8 +134,9 @@ class AuditMiddleware(BaseHTTPMiddleware):
assert response assert response
return response return response
finally: finally:
duration = (datetime.now(timezone.utc) - start_time).total_seconds() if request_details:
await self._log_audit(request, response, duration, request_details) duration = (datetime.now(timezone.utc) - start_time).total_seconds()
await self._log_audit(request, response, duration, request_details)
async def _log_audit( async def _log_audit(
self, self,
+33 -33
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from enum import Enum from enum import Enum
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,10 +15,10 @@ if TYPE_CHECKING:
class NodePeerInfo(BaseModel): class NodePeerInfo(BaseModel):
id: str id: str
alias: Optional[str] = None alias: str | None = None
color: Optional[str] = None color: str | None = None
last_timestamp: Optional[int] = None last_timestamp: int | None = None
addresses: Optional[list[str]] = None addresses: list[str] | None = None
class ChannelState(Enum): class ChannelState(Enum):
@@ -47,20 +47,20 @@ class NodeChannel(BaseModel):
balance: ChannelBalance balance: ChannelBalance
state: ChannelState state: ChannelState
# could be optional for closing/pending channels on lndrest # could be optional for closing/pending channels on lndrest
id: Optional[str] = None id: str | None = None
short_id: Optional[str] = None short_id: str | None = None
point: Optional[ChannelPoint] = None point: ChannelPoint | None = None
name: Optional[str] = None name: str | None = None
color: Optional[str] = None color: str | None = None
fee_ppm: Optional[int] = None fee_ppm: int | None = None
fee_base_msat: Optional[int] = None fee_base_msat: int | None = None
class ChannelStats(BaseModel): class ChannelStats(BaseModel):
counts: dict[ChannelState, int] counts: dict[ChannelState, int]
avg_size: int avg_size: int
biggest_size: Optional[int] biggest_size: int | None
smallest_size: Optional[int] smallest_size: int | None
total_capacity: int total_capacity: int
@classmethod @classmethod
@@ -95,9 +95,9 @@ class ChannelStats(BaseModel):
class NodeFees(BaseModel): class NodeFees(BaseModel):
total_msat: int total_msat: int
daily_msat: Optional[int] = None daily_msat: int | None = None
weekly_msat: Optional[int] = None weekly_msat: int | None = None
monthly_msat: Optional[int] = None monthly_msat: int | None = None
class PublicNodeInfo(BaseModel): class PublicNodeInfo(BaseModel):
@@ -121,25 +121,25 @@ class NodeInfoResponse(PublicNodeInfo):
class NodePayment(BaseModel): class NodePayment(BaseModel):
pending: bool pending: bool
amount: int amount: int
fee: Optional[int] = None fee: int | None = None
memo: Optional[str] = None memo: str | None = None
time: int time: int
bolt11: Optional[str] = None bolt11: str | None = None
preimage: Optional[str] preimage: str | None
payment_hash: str payment_hash: str
expiry: Optional[float] = None expiry: float | None = None
destination: Optional[NodePeerInfo] = None destination: NodePeerInfo | None = None
class NodeInvoice(BaseModel): class NodeInvoice(BaseModel):
pending: bool pending: bool
amount: int amount: int
memo: Optional[str] memo: str | None
bolt11: str bolt11: str
preimage: Optional[str] preimage: str | None
payment_hash: str payment_hash: str
paid_at: Optional[int] = None paid_at: int | None = None
expiry: Optional[int] = None expiry: int | None = None
class NodeInvoiceFilters(FilterModel): class NodeInvoiceFilters(FilterModel):
@@ -154,7 +154,7 @@ class Node(ABC):
def __init__(self, wallet: Wallet): def __init__(self, wallet: Wallet):
self.wallet = wallet self.wallet = wallet
self.id: Optional[str] = None self.id: str | None = None
@property @property
def name(self): def name(self):
@@ -203,22 +203,22 @@ class Node(ABC):
self, self,
peer_id: str, peer_id: str,
local_amount: int, local_amount: int,
push_amount: Optional[int] = None, push_amount: int | None = None,
fee_rate: Optional[int] = None, fee_rate: int | None = None,
) -> ChannelPoint: ) -> ChannelPoint:
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def close_channel( async def close_channel(
self, self,
short_id: Optional[str] = None, short_id: str | None = None,
point: Optional[ChannelPoint] = None, point: ChannelPoint | None = None,
force: bool = False, force: bool = False,
): ):
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
async def get_channel(self, channel_id: str) -> Optional[NodeChannel]: async def get_channel(self, channel_id: str) -> NodeChannel | None:
raise NotImplementedError raise NotImplementedError
@abstractmethod @abstractmethod
+6 -6
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
@@ -119,8 +119,8 @@ class CoreLightningNode(Node):
self, self,
peer_id: str, peer_id: str,
local_amount: int, local_amount: int,
push_amount: Optional[int] = None, push_amount: int | None = None,
fee_rate: Optional[int] = None, fee_rate: int | None = None,
) -> ChannelPoint: ) -> ChannelPoint:
try: try:
result = await self.ln_rpc( result = await self.ln_rpc(
@@ -173,8 +173,8 @@ class CoreLightningNode(Node):
@catch_rpc_errors @catch_rpc_errors
async def close_channel( async def close_channel(
self, self,
short_id: Optional[str] = None, short_id: str | None = None,
point: Optional[ChannelPoint] = None, point: ChannelPoint | None = None,
force: bool = False, force: bool = False,
): ):
if not short_id: if not short_id:
@@ -229,7 +229,7 @@ class CoreLightningNode(Node):
await self.ln_rpc("setchannel", channel_id, feebase=base_msat, feeppm=ppm) await self.ln_rpc("setchannel", channel_id, feebase=base_msat, feeppm=ppm)
@catch_rpc_errors @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() channels = await self.get_channels()
for channel in channels: for channel in channels:
if channel.id == channel_id: if channel.id == channel_id:
+7 -9
View File
@@ -4,7 +4,7 @@ import asyncio
import base64 import base64
import json import json
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING
from fastapi import HTTPException from fastapi import HTTPException
from httpx import HTTPStatusError from httpx import HTTPStatusError
@@ -60,9 +60,7 @@ def _parse_channel_point(raw: str) -> ChannelPoint:
class LndRestNode(Node): class LndRestNode(Node):
wallet: LndRestWallet wallet: LndRestWallet
async def request( async def request(self, method: str, path: str, json: dict | None = None, **kwargs):
self, method: str, path: str, json: Optional[dict] = None, **kwargs
):
response = await self.wallet.client.request( response = await self.wallet.client.request(
method, f"{self.wallet.endpoint}{path}", json=json, **kwargs method, f"{self.wallet.endpoint}{path}", json=json, **kwargs
) )
@@ -131,8 +129,8 @@ class LndRestNode(Node):
self, self,
peer_id: str, peer_id: str,
local_amount: int, local_amount: int,
push_amount: Optional[int] = None, push_amount: int | None = None,
fee_rate: Optional[int] = None, fee_rate: int | None = None,
) -> ChannelPoint: ) -> ChannelPoint:
response = await self.request( response = await self.request(
"POST", "POST",
@@ -176,8 +174,8 @@ class LndRestNode(Node):
async def close_channel( async def close_channel(
self, self,
short_id: Optional[str] = None, short_id: str | None = None,
point: Optional[ChannelPoint] = None, point: ChannelPoint | None = None,
force: bool = False, force: bool = False,
): ):
if short_id: 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}") channel_info = await self.get(f"/v1/graph/edge/{channel_id}")
info = await self.get("/v1/getinfo") info = await self.get("/v1/getinfo")
if info["identity_pubkey"] == channel_info["node1_pub"]: if info["identity_pubkey"] == channel_info["node1_pub"]:
+477
View File
@@ -0,0 +1,477 @@
from __future__ import annotations
import asyncio
import base64
import json
from http import HTTPStatus
from typing import TYPE_CHECKING
from fastapi import HTTPException
from httpx import HTTPStatusError
from loguru import logger
from lnbits.db import Filters, Page
from lnbits.nodes import Node
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodeInvoiceFilters,
NodePayment,
NodePaymentsFilters,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.utils.cache import cache
if TYPE_CHECKING:
from lnbits.wallets import PhoenixdWallet
def msat(raw: str) -> int:
return int(raw) * 1000
def _decode_bytes(data: str) -> str:
return base64.b64decode(data).hex()
def _encode_bytes(data: str) -> str:
return base64.b64encode(bytes.fromhex(data)).decode()
def _encode_urlsafe_bytes(data: str) -> str:
return base64.urlsafe_b64encode(bytes.fromhex(data)).decode()
def _parse_channel_point(raw: str) -> ChannelPoint:
funding_tx, output_index = raw.split(":")
return ChannelPoint(
funding_txid=funding_tx,
output_index=int(output_index),
)
class PhoenixdNode(Node):
wallet: PhoenixdWallet
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
)
try:
response.raise_for_status()
except HTTPStatusError as exc:
json = exc.response.json()
if json:
error = json.get("error") or json
raise HTTPException(
exc.response.status_code, detail=error.get("message")
) from exc
return response.json()
def get(self, path: str, **kwargs):
return self.request("GET", path, **kwargs)
async def _get_id(self) -> str:
info = await self.get("/v1/getinfo")
return info["identity_pubkey"]
async def get_peer_ids(self) -> list[str]:
response = await self.get("/v1/peers")
return [p["pub_key"] for p in response["peers"]]
async def connect_peer(self, uri: str):
try:
pubkey, host = uri.split("@")
except ValueError as exc:
raise HTTPException(400, detail="Invalid peer URI") from exc
await self.request(
"POST",
"/v1/peers",
json={
"addr": {"pubkey": pubkey, "host": host},
"perm": True,
"timeout": 30,
},
)
async def disconnect_peer(self, peer_id: str):
try:
await self.request("DELETE", "/v1/peers/" + peer_id)
except HTTPException as exc:
if "unable to disconnect" in exc.detail:
raise HTTPException(
HTTPStatus.BAD_REQUEST, detail="Peer is not connected"
) from exc
raise
async def _get_peer_info(self, peer_id: str) -> NodePeerInfo:
try:
response = await self.get("/v1/graph/node/" + peer_id)
except HTTPException:
return NodePeerInfo(id=peer_id)
node = response["node"]
return NodePeerInfo(
id=peer_id,
alias=node["alias"],
color=node["color"].strip("#"),
last_timestamp=node["last_update"],
addresses=[a["addr"] for a in node["addresses"]],
)
async def open_channel(
self,
peer_id: str,
local_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
response = await self.request(
"POST",
"/v1/channels",
json={
"node_pubkey": _encode_bytes(peer_id),
"sat_per_vbyte": fee_rate,
"local_funding_amount": local_amount,
"push_sat": push_amount,
},
)
return ChannelPoint(
# WHY IS THIS REVERSED?!
funding_txid=bytes(
reversed(base64.b64decode(response["funding_txid_bytes"]))
).hex(),
output_index=response["output_index"],
)
async def _close_channel(
self,
point: ChannelPoint,
force: bool = False,
):
async with self.wallet.client.stream(
"DELETE",
f"{self.wallet.endpoint}/v1/channels/{point.funding_txid}/{point.output_index}",
params={"force": force},
timeout=None,
) as stream:
async for chunk in stream.aiter_text():
if not chunk:
continue
chunk = json.loads(chunk)
if "error" in chunk:
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=chunk["error"].get("message"),
)
logger.info(f"LND Channel close update: {chunk.get('result')}")
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
):
if short_id:
logger.debug(f"Closing channel with short_id: {short_id}")
if not point:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
)
asyncio.create_task(self._close_channel(point, force)) # noqa: RUF006
async def set_channel_fee(self, channel_id: str, base_msat: int, ppm: int):
# https://lightning.engineering/api-docs/api/lnd/lightning/update-channel-policy/
channel = await self.get_channel(channel_id)
if not channel:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Channel not found"
)
if not channel.point:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="Channel point required"
)
await self.request(
"POST",
"/v1/chanpolicy",
json={
"base_fee_msat": base_msat,
"fee_rate_ppm": ppm,
"chan_point": {
"funding_txid_str": channel.point.funding_txid,
"output_index": channel.point.output_index,
},
# https://docs.lightning.engineering/lightning-network-tools/lnd/optimal-configuration-of-a-routing-node#channel-defaults
"time_lock_delta": 80,
# 'max_htlc_msat': <uint64>,
# 'min_htlc_msat': <uint64>,
# 'inbound_fee': <InboundFee>,
},
)
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"]:
my_node_key = "node1"
peer_node_key = "node2"
else:
my_node_key = "node2"
peer_node_key = "node1"
peer_id = channel_info[f"{peer_node_key}_pub"]
peer_b64 = _encode_urlsafe_bytes(peer_id)
channels = await self.get(f"/v1/channels?peer={peer_b64}")
if "error" in channel_info and "error" in channels:
logger.debug("LND get_channel", channels)
return None
if len(channels["channels"]) == 0:
logger.debug(f"LND get_channel no channels founds with id {peer_b64}")
return None
for channel in channels["channels"]:
if channel["chan_id"] == channel_id:
peer_info = await self.get_peer_info(peer_id)
return NodeChannel(
id=channel.get("chan_id"),
peer_id=peer_info.id,
name=peer_info.alias,
color=peer_info.color,
state=(
ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE
),
fee_ppm=channel_info[f"{my_node_key}_policy"][
"fee_rate_milli_msat"
],
fee_base_msat=channel_info[f"{my_node_key}_policy"][
"fee_base_msat"
],
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
)
return None
async def get_channels(self) -> list[NodeChannel]:
normal, pending, closed = await asyncio.gather(
self.get("/v1/channels"),
self.get("/v1/channels/pending"),
self.get("/v1/channels/closed"),
)
channels = []
async def parse_pending(raw_channels, state):
for channel in raw_channels:
channel = channel["channel"]
info = await self.get_peer_info(channel["remote_node_pub"])
channels.append(
NodeChannel(
peer_id=info.id,
state=state,
name=info.alias,
color=info.color,
id=channel.get("chan_id", "node is for pending channels"),
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
)
)
await parse_pending(pending["pending_open_channels"], ChannelState.PENDING)
await parse_pending(
pending["pending_force_closing_channels"], ChannelState.CLOSED
)
await parse_pending(pending["waiting_close_channels"], ChannelState.CLOSED)
for channel in closed["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
id=channel.get("chan_id", "node is for closing channels"),
peer_id=info.id,
state=ChannelState.CLOSED,
name=info.alias,
color=info.color,
point=_parse_channel_point(channel["channel_point"]),
balance=ChannelBalance(
local_msat=0,
remote_msat=0,
total_msat=msat(channel["capacity"]),
),
)
)
for channel in normal["channels"]:
info = await self.get_peer_info(channel["remote_pubkey"])
channels.append(
NodeChannel(
id=channel["chan_id"],
short_id=channel["chan_id"],
point=_parse_channel_point(channel["channel_point"]),
peer_id=channel["remote_pubkey"],
balance=ChannelBalance(
local_msat=msat(channel["local_balance"]),
remote_msat=msat(channel["remote_balance"]),
total_msat=msat(channel["capacity"]),
),
state=(
ChannelState.ACTIVE
if channel["active"]
else ChannelState.INACTIVE
),
# name=channel['peer_alias'],
name=info.alias,
color=info.color,
)
)
return channels
async def get_public_info(self) -> PublicNodeInfo:
info = await self.get("/v1/getinfo")
channels = await self.get_channels()
return PublicNodeInfo(
backend_name="LND",
id=info["identity_pubkey"],
color=info["color"].lstrip("#"),
alias=info["alias"],
num_peers=info["num_peers"],
blockheight=info["block_height"],
addresses=info["uris"],
channel_stats=ChannelStats.from_list(channels),
)
async def get_info(self) -> NodeInfoResponse:
public = await self.get_public_info()
onchain = await self.get("/v1/balance/blockchain")
fee_report = await self.get("/v1/fees")
balance = await self.get("/v1/balance/channels")
return NodeInfoResponse(
**public.dict(),
onchain_balance_sat=onchain["total_balance"],
onchain_confirmed_sat=onchain["confirmed_balance"],
balance_msat=balance["local_balance"]["msat"],
fees=NodeFees(
total_msat=0,
daily_msat=fee_report["day_fee_sum"],
weekly_msat=fee_report["week_fee_sum"],
monthly_msat=fee_report["month_fee_sum"],
),
)
async def get_payments(
self, filters: Filters[NodePaymentsFilters]
) -> Page[NodePayment]:
count_key = "node:payments_count"
payments_count = cache.get(count_key)
if not payments_count and filters.offset:
# this forces fetching the payments count
await self.get_payments(Filters(limit=1))
payments_count = cache.get(count_key)
if filters.offset and payments_count:
index_offset = max(payments_count + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/payments",
params={
"index_offset": index_offset,
"max_payments": filters.limit,
"include_incomplete": True,
"reversed": True,
"count_total_payments": not index_offset,
},
)
if not filters.offset:
payments_count = int(response["total_num_payments"])
cache.set(count_key, payments_count)
payments = [
NodePayment(
payment_hash=payment["payment_hash"],
pending=payment["status"] == "IN_FLIGHT",
amount=payment["value_msat"],
fee=payment["fee_msat"],
time=payment["creation_date"],
destination=(
await self.get_peer_info(
payment["htlcs"][0]["route"]["hops"][-1]["pub_key"]
)
if payment["htlcs"]
else None
),
bolt11=payment["payment_request"],
preimage=payment["payment_preimage"],
)
for payment in response["payments"]
]
payments.sort(key=lambda p: p.time, reverse=True)
return Page(data=payments, total=payments_count or 0)
async def get_invoices(
self, filters: Filters[NodeInvoiceFilters]
) -> Page[NodeInvoice]:
last_invoice_key = "node:last_invoice_index"
last_invoice_index = cache.get(last_invoice_key)
if not last_invoice_index and filters.offset:
# this forces fetching the last invoice index so
await self.get_invoices(Filters(limit=1))
last_invoice_index = cache.get(last_invoice_key)
if filters.offset and last_invoice_index:
index_offset = max(last_invoice_index + 1 - filters.offset, 0)
else:
index_offset = 0
response = await self.get(
"/v1/invoices",
params={
"index_offset": index_offset,
"num_max_invoices": filters.limit,
"reversed": True,
},
)
if not filters.offset:
last_invoice_index = int(response["last_index_offset"])
cache.set(last_invoice_key, last_invoice_index)
invoices = [
NodeInvoice(
payment_hash=_decode_bytes(invoice["r_hash"]),
amount=invoice["value_msat"],
memo=invoice["memo"],
pending=invoice["state"] == "OPEN",
paid_at=invoice["settle_date"],
expiry=int(invoice["creation_date"]) + int(invoice["expiry"]),
preimage=_decode_bytes(invoice["r_preimage"]),
bolt11=invoice["payment_request"],
)
for invoice in reversed(response["invoices"])
]
return Page(
data=invoices,
total=last_invoice_index or 0,
)
+98 -94
View File
@@ -11,7 +11,7 @@ from hashlib import sha256
from os import path from os import path
from pathlib import Path from pathlib import Path
from time import gmtime, strftime, time from time import gmtime, strftime, time
from typing import Any, Optional from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
@@ -77,7 +77,7 @@ class RedirectPath(BaseModel):
other.from_path, list(other.header_filters.items()) other.from_path, list(other.header_filters.items())
) or other.redirect_matches(self.from_path, list(self.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: for other in others:
if self.in_conflict(other): if self.in_conflict(other):
return other return other
@@ -153,7 +153,7 @@ class InstalledExtensionsSettings(LNbitsSettings):
def find_extension_redirect( def find_extension_redirect(
self, path: str, req_headers: list[tuple[bytes, bytes]] self, path: str, req_headers: list[tuple[bytes, bytes]]
) -> Optional[RedirectPath]: ) -> RedirectPath | None:
headers = [(k.decode(), v.decode()) for k, v in req_headers] headers = [(k.decode(), v.decode()) for k, v in req_headers]
return next( return next(
( (
@@ -167,8 +167,8 @@ class InstalledExtensionsSettings(LNbitsSettings):
def activate_extension_paths( def activate_extension_paths(
self, self,
ext_id: str, ext_id: str,
upgrade_hash: Optional[str] = None, upgrade_hash: str | None = None,
ext_redirects: Optional[list[dict]] = None, ext_redirects: list[dict] | None = None,
): ):
self.lnbits_deactivated_extensions.discard(ext_id) self.lnbits_deactivated_extensions.discard(ext_id)
@@ -231,12 +231,12 @@ class ExchangeHistorySettings(LNbitsSettings):
class ThemesSettings(LNbitsSettings): class ThemesSettings(LNbitsSettings):
lnbits_site_title: str = Field(default="LNbits") lnbits_site_title: str = Field(default="LNbits")
lnbits_site_tagline: str = Field(default="free and open-source lightning wallet") 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." default="The world's most powerful suite of bitcoin tools."
) )
lnbits_show_home_page_elements: bool = Field(default=True) lnbits_show_home_page_elements: bool = Field(default=True)
lnbits_default_wallet_name: str = Field(default="LNbits wallet") 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_custom_badge_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field( lnbits_theme_options: list[str] = Field(
default=[ default=[
@@ -251,17 +251,15 @@ class ThemesSettings(LNbitsSettings):
"bitcoin", "bitcoin",
] ]
) )
lnbits_custom_logo: Optional[str] = Field(default=None) lnbits_custom_logo: str | None = Field(default=None)
lnbits_custom_image: Optional[str] = Field( lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
default="/static/images/logos/lnbits.svg"
)
lnbits_ad_space_title: str = Field(default="Supported by") lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field( 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" 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 ) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False) lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[]) lnbits_allowed_currencies: list[str] = Field(default=[])
lnbits_default_accounting_currency: Optional[str] = Field(default=None) lnbits_default_accounting_currency: str | None = Field(default=None)
lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png") lnbits_qr_logo: str = Field(default="/static/images/logos/lnbits.png")
lnbits_default_reaction: str = Field(default="confettiBothSides") lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="salvador") lnbits_default_theme: str = Field(default="salvador")
@@ -282,7 +280,7 @@ class FeeSettings(LNbitsSettings):
lnbits_service_fee: float = Field(default=0) lnbits_service_fee: float = Field(default=0)
lnbits_service_fee_ignore_internal: bool = Field(default=True) lnbits_service_fee_ignore_internal: bool = Field(default=True)
lnbits_service_fee_max: int = Field(default=0) 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 # 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 # funding_source.pay_invoice(), it may cause a vulnerability if the values differ
@@ -396,6 +394,12 @@ class NotificationsSettings(LNbitsSettings):
lnbits_telegram_notifications_enabled: bool = Field(default=False) lnbits_telegram_notifications_enabled: bool = Field(default=False)
lnbits_telegram_notifications_access_token: str = Field(default="") lnbits_telegram_notifications_access_token: str = Field(default="")
lnbits_telegram_notifications_chat_id: 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_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_settings_update: bool = Field(default=True)
lnbits_notification_credit_debit: bool = Field(default=True) lnbits_notification_credit_debit: bool = Field(default=True)
@@ -413,121 +417,121 @@ class FakeWalletFundingSource(LNbitsSettings):
class LNbitsFundingSource(LNbitsSettings): class LNbitsFundingSource(LNbitsSettings):
lnbits_endpoint: str = Field(default="https://demo.lnbits.com") lnbits_endpoint: str = Field(default="https://demo.lnbits.com")
lnbits_key: Optional[str] = Field(default=None) lnbits_key: str | None = Field(default=None)
lnbits_admin_key: Optional[str] = Field(default=None) lnbits_admin_key: str | None = Field(default=None)
lnbits_invoice_key: Optional[str] = Field(default=None) lnbits_invoice_key: str | None = Field(default=None)
class ClicheFundingSource(LNbitsSettings): class ClicheFundingSource(LNbitsSettings):
cliche_endpoint: Optional[str] = Field(default=None) cliche_endpoint: str | None = Field(default=None)
class CoreLightningFundingSource(LNbitsSettings): class CoreLightningFundingSource(LNbitsSettings):
corelightning_rpc: Optional[str] = Field(default=None) corelightning_rpc: str | None = Field(default=None)
corelightning_pay_command: str = Field(default="pay") corelightning_pay_command: str = Field(default="pay")
clightning_rpc: Optional[str] = Field(default=None) clightning_rpc: str | None = Field(default=None)
class CoreLightningRestFundingSource(LNbitsSettings): class CoreLightningRestFundingSource(LNbitsSettings):
corelightning_rest_url: Optional[str] = Field(default=None) corelightning_rest_url: str | None = Field(default=None)
corelightning_rest_macaroon: Optional[str] = Field(default=None) corelightning_rest_macaroon: str | None = Field(default=None)
corelightning_rest_cert: Optional[str] = Field(default=None) corelightning_rest_cert: str | None = Field(default=None)
class EclairFundingSource(LNbitsSettings): class EclairFundingSource(LNbitsSettings):
eclair_url: Optional[str] = Field(default=None) eclair_url: str | None = Field(default=None)
eclair_pass: Optional[str] = Field(default=None) eclair_pass: str | None = Field(default=None)
class LndRestFundingSource(LNbitsSettings): class LndRestFundingSource(LNbitsSettings):
lnd_rest_endpoint: Optional[str] = Field(default=None) lnd_rest_endpoint: str | None = Field(default=None)
lnd_rest_cert: Optional[str] = Field(default=None) lnd_rest_cert: str | None = Field(default=None)
lnd_rest_macaroon: Optional[str] = Field(default=None) lnd_rest_macaroon: str | None = Field(default=None)
lnd_rest_macaroon_encrypted: Optional[str] = Field(default=None) lnd_rest_macaroon_encrypted: str | None = Field(default=None)
lnd_rest_route_hints: bool = Field(default=True) lnd_rest_route_hints: bool = Field(default=True)
lnd_rest_allow_self_payment: bool = Field(default=False) lnd_rest_allow_self_payment: bool = Field(default=False)
lnd_cert: Optional[str] = Field(default=None) lnd_cert: str | None = Field(default=None)
lnd_admin_macaroon: Optional[str] = Field(default=None) lnd_admin_macaroon: str | None = Field(default=None)
lnd_invoice_macaroon: Optional[str] = Field(default=None) lnd_invoice_macaroon: str | None = Field(default=None)
lnd_rest_admin_macaroon: Optional[str] = Field(default=None) lnd_rest_admin_macaroon: str | None = Field(default=None)
lnd_rest_invoice_macaroon: Optional[str] = Field(default=None) lnd_rest_invoice_macaroon: str | None = Field(default=None)
class LndGrpcFundingSource(LNbitsSettings): class LndGrpcFundingSource(LNbitsSettings):
lnd_grpc_endpoint: Optional[str] = Field(default=None) lnd_grpc_endpoint: str | None = Field(default=None)
lnd_grpc_cert: Optional[str] = Field(default=None) lnd_grpc_cert: str | None = Field(default=None)
lnd_grpc_port: Optional[int] = Field(default=None) lnd_grpc_port: int | None = Field(default=None)
lnd_grpc_admin_macaroon: Optional[str] = Field(default=None) lnd_grpc_admin_macaroon: str | None = Field(default=None)
lnd_grpc_invoice_macaroon: Optional[str] = Field(default=None) lnd_grpc_invoice_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon: Optional[str] = Field(default=None) lnd_grpc_macaroon: str | None = Field(default=None)
lnd_grpc_macaroon_encrypted: Optional[str] = Field(default=None) lnd_grpc_macaroon_encrypted: str | None = Field(default=None)
class LnPayFundingSource(LNbitsSettings): class LnPayFundingSource(LNbitsSettings):
lnpay_api_endpoint: Optional[str] = Field(default=None) lnpay_api_endpoint: str | None = Field(default=None)
lnpay_api_key: Optional[str] = Field(default=None) lnpay_api_key: str | None = Field(default=None)
lnpay_wallet_key: Optional[str] = Field(default=None) lnpay_wallet_key: str | None = Field(default=None)
lnpay_admin_key: Optional[str] = Field(default=None) lnpay_admin_key: str | None = Field(default=None)
class BlinkFundingSource(LNbitsSettings): class BlinkFundingSource(LNbitsSettings):
blink_api_endpoint: Optional[str] = Field(default="https://api.blink.sv/graphql") blink_api_endpoint: str | None = Field(default="https://api.blink.sv/graphql")
blink_ws_endpoint: Optional[str] = Field(default="wss://ws.blink.sv/graphql") blink_ws_endpoint: str | None = Field(default="wss://ws.blink.sv/graphql")
blink_token: Optional[str] = Field(default=None) blink_token: str | None = Field(default=None)
class ZBDFundingSource(LNbitsSettings): class ZBDFundingSource(LNbitsSettings):
zbd_api_endpoint: Optional[str] = Field(default="https://api.zebedee.io/v0/") zbd_api_endpoint: str | None = Field(default="https://api.zebedee.io/v0/")
zbd_api_key: Optional[str] = Field(default=None) zbd_api_key: str | None = Field(default=None)
class PhoenixdFundingSource(LNbitsSettings): class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: Optional[str] = Field(default="http://localhost:9740/") phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: Optional[str] = Field(default=None) phoenixd_api_password: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings): class AlbyFundingSource(LNbitsSettings):
alby_api_endpoint: Optional[str] = Field(default="https://api.getalby.com/") alby_api_endpoint: str | None = Field(default="https://api.getalby.com/")
alby_access_token: Optional[str] = Field(default=None) alby_access_token: str | None = Field(default=None)
class OpenNodeFundingSource(LNbitsSettings): class OpenNodeFundingSource(LNbitsSettings):
opennode_api_endpoint: Optional[str] = Field(default=None) opennode_api_endpoint: str | None = Field(default=None)
opennode_key: Optional[str] = Field(default=None) opennode_key: str | None = Field(default=None)
opennode_admin_key: Optional[str] = Field(default=None) opennode_admin_key: str | None = Field(default=None)
opennode_invoice_key: Optional[str] = Field(default=None) opennode_invoice_key: str | None = Field(default=None)
class SparkFundingSource(LNbitsSettings): class SparkFundingSource(LNbitsSettings):
spark_url: Optional[str] = Field(default=None) spark_url: str | None = Field(default=None)
spark_token: Optional[str] = Field(default=None) spark_token: str | None = Field(default=None)
class LnTipsFundingSource(LNbitsSettings): class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: Optional[str] = Field(default=None) lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: Optional[str] = Field(default=None) lntips_api_key: str | None = Field(default=None)
lntips_admin_key: Optional[str] = Field(default=None) lntips_admin_key: str | None = Field(default=None)
lntips_invoice_key: Optional[str] = Field(default=None) lntips_invoice_key: str | None = Field(default=None)
class NWCFundingSource(LNbitsSettings): class NWCFundingSource(LNbitsSettings):
nwc_pairing_url: Optional[str] = Field(default=None) nwc_pairing_url: str | None = Field(default=None)
class BreezSdkFundingSource(LNbitsSettings): class BreezSdkFundingSource(LNbitsSettings):
breez_api_key: Optional[str] = Field(default=None) breez_api_key: str | None = Field(default=None)
breez_greenlight_seed: Optional[str] = Field(default=None) breez_greenlight_seed: str | None = Field(default=None)
breez_greenlight_invite_code: Optional[str] = Field(default=None) breez_greenlight_invite_code: str | None = Field(default=None)
breez_greenlight_device_key: Optional[str] = Field(default=None) breez_greenlight_device_key: str | None = Field(default=None)
breez_greenlight_device_cert: Optional[str] = Field(default=None) breez_greenlight_device_cert: str | None = Field(default=None)
breez_use_trampoline: bool = Field(default=True) breez_use_trampoline: bool = Field(default=True)
class BoltzFundingSource(LNbitsSettings): class BoltzFundingSource(LNbitsSettings):
boltz_client_endpoint: Optional[str] = Field(default="127.0.0.1:9002") boltz_client_endpoint: str | None = Field(default="127.0.0.1:9002")
boltz_client_macaroon: Optional[str] = Field(default=None) boltz_client_macaroon: str | None = Field(default=None)
boltz_client_wallet: Optional[str] = Field(default="lnbits") boltz_client_wallet: str | None = Field(default="lnbits")
boltz_client_cert: Optional[str] = Field(default=None) boltz_client_cert: str | None = Field(default=None)
class LightningSettings(LNbitsSettings): class LightningSettings(LNbitsSettings):
@@ -562,8 +566,8 @@ class FundingSourcesSettings(
class WebPushSettings(LNbitsSettings): class WebPushSettings(LNbitsSettings):
lnbits_webpush_pubkey: Optional[str] = Field(default=None) lnbits_webpush_pubkey: str | None = Field(default=None)
lnbits_webpush_privkey: Optional[str] = Field(default=None) lnbits_webpush_privkey: str | None = Field(default=None)
class NodeUISettings(LNbitsSettings): class NodeUISettings(LNbitsSettings):
@@ -669,9 +673,9 @@ class AuditSettings(LNbitsSettings):
def audit_http_request( def audit_http_request(
self, self,
http_method: Optional[str] = None, http_method: str | None = None,
path: Optional[str] = None, path: str | None = None,
http_response_code: Optional[str] = None, http_response_code: str | None = None,
) -> bool: ) -> bool:
if not self.lnbits_audit_enabled: if not self.lnbits_audit_enabled:
return False return False
@@ -689,24 +693,24 @@ class AuditSettings(LNbitsSettings):
return True 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: if len(self.lnbits_audit_exclude_paths) != 0 and path:
for exclude_path in self.lnbits_audit_exclude_paths: for exclude_path in self.lnbits_audit_exclude_paths:
if _re_fullmatch_safe(exclude_path, path): if _re_fullmatch_safe(exclude_path, path):
return False return False
if len(self.lnbits_audit_include_paths) != 0: if len(self.lnbits_audit_include_paths) == 0:
if not path: return True
return False
for include_path in self.lnbits_audit_include_paths: if not path:
if _re_fullmatch_safe(include_path, path): return False
return True for include_path in self.lnbits_audit_include_paths:
if _re_fullmatch_safe(include_path, path):
return True
return False return False
def _is_http_response_code_auditable( def _is_http_response_code_auditable(self, http_response_code: str | None) -> bool:
self, http_response_code: Optional[str]
) -> bool:
if not http_response_code: if not http_response_code:
# No response code means only request filters should apply # No response code means only request filters should apply
return True return True
@@ -803,9 +807,9 @@ class EnvSettings(LNbitsSettings):
class SaaSSettings(LNbitsSettings): class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: Optional[str] = Field(default=None) lnbits_saas_callback: str | None = Field(default=None)
lnbits_saas_secret: Optional[str] = Field(default=None) lnbits_saas_secret: str | None = Field(default=None)
lnbits_saas_instance_id: Optional[str] = Field(default=None) lnbits_saas_instance_id: str | None = Field(default=None)
class PersistenceSettings(LNbitsSettings): class PersistenceSettings(LNbitsSettings):
@@ -903,7 +907,7 @@ class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettin
or user_id == self.super_user 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 return user_id == self.super_user
def is_admin_user(self, user_id: str) -> bool: def is_admin_user(self, user_id: str) -> bool:
@@ -922,12 +926,12 @@ class SuperSettings(EditableSettings):
class AdminSettings(EditableSettings): class AdminSettings(EditableSettings):
is_super_user: bool is_super_user: bool
lnbits_allowed_funding_sources: Optional[list[str]] lnbits_allowed_funding_sources: list[str] | None
class SettingsField(BaseModel): class SettingsField(BaseModel):
id: str id: str
value: Optional[Any] value: Any | None
tag: str = "core" tag: str = "core"
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
+22 -1
View File
@@ -82,6 +82,7 @@ window.localisation.en = {
create_invoice: 'Create Invoice', create_invoice: 'Create Invoice',
camera_tooltip: 'Use camera to scan an invoice/QR', camera_tooltip: 'Use camera to scan an invoice/QR',
export_csv: 'Export to CSV', export_csv: 'Export to CSV',
export_csv_details: 'Export to CSV with details',
chart_tooltip: 'Show chart', chart_tooltip: 'Show chart',
pending: 'Pending', pending: 'Pending',
copy_invoice: 'Copy invoice', copy_invoice: 'Copy invoice',
@@ -195,6 +196,22 @@ window.localisation.en = {
notifications_chat_id: 'Chat ID', notifications_chat_id: 'Chat ID',
notifications_chat_id_desc: 'Chat ID to send the notifications to', 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_email: 'Send email',
notifications_send_email_desc: 'Email you will send from',
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: 'Settings updated',
notification_settings_update_desc: notification_settings_update_desc:
'Notify when server settings have been updated', 'Notify when server settings have been updated',
@@ -482,6 +499,7 @@ window.localisation.en = {
lnbits_wallet: 'LNbits wallet', lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination', denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token', denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code Logo', ui_qr_code_logo: 'QR Code Logo',
ui_qr_code_logo_hint: 'URL to logo image in QR code', ui_qr_code_logo_hint: 'URL to logo image in QR code',
ui_custom_image: 'Custom Image', ui_custom_image: 'Custom Image',
@@ -512,6 +530,7 @@ window.localisation.en = {
allowed_users_label: 'User ID', allowed_users_label: 'User ID',
allow_creation_user: 'Allow creation of new users', allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page', allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
components: 'Components', components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints', long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods', http_request_methods: 'HTTP Request Methods',
@@ -539,5 +558,7 @@ window.localisation.en = {
reset_wallet_keys_desc: reset_wallet_keys_desc:
'Reset the API keys for this wallet. This will invalidate the current keys and generate new ones.', 'Reset the API keys for this wallet. This will invalidate the current keys and generate new ones.',
view_list: 'View wallets as list', 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'
} }
+19
View File
@@ -53,8 +53,10 @@ window.AdminPageLogic = {
chartReady: false, chartReady: false,
formAddAdmin: '', formAddAdmin: '',
formAddUser: '', formAddUser: '',
hideInputToggle: true,
formAddExtensionsManifest: '', formAddExtensionsManifest: '',
nostrNotificationIdentifier: '', nostrNotificationIdentifier: '',
emailNotificationAddress: '',
formAllowedIPs: '', formAllowedIPs: '',
formCallbackUrlRule: '', formCallbackUrlRule: '',
formBlockedIPs: '', formBlockedIPs: '',
@@ -270,6 +272,23 @@ window.AdminPageLogic = {
m => m !== identifer 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() { async toggleServerLog() {
this.serverlogEnabled = !this.serverlogEnabled this.serverlogEnabled = !this.serverlogEnabled
if (this.serverlogEnabled) { if (this.serverlogEnabled) {
+2 -1
View File
@@ -357,9 +357,10 @@ window.LNbits = {
} }
}, },
prepareFilterQuery(tableConfig, props) { prepareFilterQuery(tableConfig, props) {
tableConfig.filter = tableConfig.filter || {}
if (props) { if (props) {
tableConfig.pagination = props.pagination tableConfig.pagination = props.pagination
tableConfig.filter = {...tableConfig.filter, ...props.filter} Object.assign(tableConfig.filter, props.filter)
} }
const pagination = tableConfig.pagination const pagination = tableConfig.pagination
tableConfig.loading = true tableConfig.loading = true
+1 -2
View File
@@ -583,7 +583,7 @@ window.app.component('username-password', {
}, },
reset() { reset() {
this.$emit('update:resetKey', this.reset_key) 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('update:password_2', this.passwordRepeat)
this.$emit('reset') this.$emit('reset')
}, },
@@ -595,7 +595,6 @@ window.app.component('username-password', {
}, },
async signInWithNostr() { async signInWithNostr() {
try { try {
console.log('### signInWithNostr')
const nostrToken = await this.createNostrToken() const nostrToken = await this.createNostrToken()
if (!nostrToken) { if (!nostrToken) {
return return
+38 -10
View File
@@ -6,7 +6,6 @@ window.app.component('payment-list', {
data() { data() {
return { return {
denomination: LNBITS_DENOMINATION, denomination: LNBITS_DENOMINATION,
failedPaymentsToggle: false,
payments: [], payments: [],
paymentsTable: { paymentsTable: {
columns: [ columns: [
@@ -39,6 +38,13 @@ window.app.component('payment-list', {
loading: false loading: false
}, },
searchDate: {from: null, to: null}, searchDate: {from: null, to: null},
searchStatus: {
success: true,
pending: true,
failed: false,
incoming: true,
outgoing: true
},
exportTagName: '', exportTagName: '',
exportPaymentTagList: [], exportPaymentTagList: [],
paymentsCSV: { paymentsCSV: {
@@ -263,18 +269,40 @@ window.app.component('payment-list', {
console.error(e) console.error(e)
return `${amount} ???` 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: { 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': { 'paymentsTable.search': {
handler() { handler() {
const props = {} const props = {}
+7 -3
View File
@@ -9,9 +9,7 @@ window.app = Vue.createApp({
description: '' description: ''
}, },
isUserAuthorized: false, isUserAuthorized: false,
authAction: Quasar.LocalStorage.getItem('lnbits.disclaimerShown') authAction: 'login',
? 'login'
: 'register',
authMethod: 'username-password', authMethod: 'username-password',
usr: '', usr: '',
username: '', username: '',
@@ -104,6 +102,12 @@ window.app = Vue.createApp({
}, },
created() { created() {
this.description = SITE_DESCRIPTION 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') this.isUserAuthorized = !!this.$q.cookies.get('is_lnbits_user_authorized')
const _acccess_cookies_for_safari_refresh_do_not_delete = document.cookie const _acccess_cookies_for_safari_refresh_do_not_delete = document.cookie
if (this.isUserAuthorized) { if (this.isUserAuthorized) {
-3
View File
@@ -352,9 +352,6 @@ window.UsersPageLogic = {
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
exportUsers() {
console.log('export users')
},
async showAccountPage(user_id) { async showAccountPage(user_id) {
this.activeUser.showPassword = false this.activeUser.showPassword = false
this.activeUser.showUserId = false this.activeUser.showUserId = false
+17 -1
View File
@@ -854,7 +854,9 @@ window.WalletPageLogic = {
handleFilterChange(value = {}) { handleFilterChange(value = {}) {
if ( if (
this.paymentsFilter['time[ge]'] !== value['time[ge]'] || 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() this.refreshCharts()
} }
@@ -884,6 +886,19 @@ window.WalletPageLogic = {
filterChartData(data) { filterChartData(data) {
const timeFrom = this.paymentsFilter['time[ge]'] + 'T00:00:00' const timeFrom = this.paymentsFilter['time[ge]'] + 'T00:00:00'
const timeTo = this.paymentsFilter['time[le]'] + 'T23:59:59' 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 => { data = data.filter(p => {
if ( if (
this.paymentsFilter['time[ge]'] && this.paymentsFilter['time[ge]'] &&
@@ -899,6 +914,7 @@ window.WalletPageLogic = {
} }
return true return true
}) })
const labels = data.map(s => const labels = data.map(s =>
new Date(s.date).toLocaleString('default', { new Date(s.date).toLocaleString('default', {
month: 'short', month: 'short',
-30
View File
@@ -1,9 +1,7 @@
import asyncio import asyncio
import json
import time import time
import traceback import traceback
import uuid import uuid
from http import HTTPStatus
from typing import ( from typing import (
Callable, Callable,
Coroutine, Coroutine,
@@ -13,11 +11,8 @@ from typing import (
) )
from loguru import logger from loguru import logger
from py_vapid import Vapid
from pywebpush import WebPushException, webpush
from lnbits.core.crud import ( from lnbits.core.crud import (
delete_webpush_subscriptions,
get_payments, get_payments,
get_standalone_payment, get_standalone_payment,
update_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(): for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`") logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment) await send_chan.put(payment)
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}"
)
+92 -53
View File
@@ -419,7 +419,6 @@
dense dense
use-input use-input
use-chips use-chips
multiple
hide-dropdown-icon hide-dropdown-icon
></q-select> ></q-select>
<div v-else-if="o.type === 'bool'"> <div v-else-if="o.type === 'bool'">
@@ -639,16 +638,100 @@
</q-input> </q-input>
</div> </div>
<div class="gt-sm col-auto"> <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 <q-btn-dropdown
dense
outline outline
persistent persistent
dense icon="archive"
split
class="q-mr-sm" class="q-mr-sm"
color="grey" color="grey"
label="Export"
split
@click="exportCSV(false)" @click="exportCSV(false)"
> >
<q-tooltip>
<span v-text="$t('export_csv')"></span>
</q-tooltip>
<q-list> <q-list>
<q-item> <q-item>
<q-item-section> <q-item-section>
@@ -688,59 +771,12 @@
outline outline
color="grey" color="grey"
@click="exportCSV(true)" @click="exportCSV(true)"
label="Export to CSV with details" :label="$t('export_csv_details')"
></q-btn> ></q-btn>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-btn-dropdown> </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> </div>
<div class="row q-my-md"></div> <div class="row q-my-md"></div>
@@ -1223,7 +1259,7 @@
</q-form> </q-form>
</q-card-section> </q-card-section>
<!-- REGISTER --> <!-- 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-form @submit="register" class="q-gutter-sm">
<q-input <q-input
dense dense
@@ -1266,6 +1302,9 @@
</div> </div>
</q-form> </q-form>
</q-card-section> </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> <slot></slot>
<!-- RESET --> <!-- RESET -->
<q-card-section v-if="authAction === 'reset'"> <q-card-section v-if="authAction === 'reset'">
+3 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from time import time from time import time
from typing import Any, NamedTuple, Optional from typing import Any, NamedTuple
from loguru import logger from loguru import logger
@@ -23,7 +23,7 @@ class Cache:
self.interval = interval self.interval = interval
self._values: dict[Any, Cached] = {} 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) cached = self._values.get(key)
if cached is not None: if cached is not None:
if cached.expiry > time(): if cached.expiry > time():
@@ -35,7 +35,7 @@ class Cache:
def set(self, key: str, value: Any, expiry: float = 10): def set(self, key: str, value: Any, expiry: float = 10):
self._values[key] = Cached(value, time() + expiry) 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) cached = self._values.pop(key, None)
if cached and cached.expiry > time(): if cached and cached.expiry > time():
return cached.value return cached.value
+11 -5
View File
@@ -63,10 +63,15 @@ class AESCipher:
final_key += key final_key += key
return final_key[:output] 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.""" """Decrypts a string using AES-256-CBC."""
passphrase = self.passphrase 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__" assert encrypted_bytes[0:8] == b"Salted__"
salt = encrypted_bytes[8:16] salt = encrypted_bytes[8:16]
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
@@ -78,13 +83,14 @@ class AESCipher:
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc:
raise ValueError("Wrong passphrase") from 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 passphrase = self.passphrase
salt = Random.new().read(8) salt = Random.new().read(8)
key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16) key_iv = self.bytes_to_key(passphrase.encode(), salt, 32 + 16)
key = key_iv[:32] key = key_iv[:32]
iv = key_iv[32:] iv = key_iv[32:]
aes = AES.new(key, AES.MODE_CBC, iv) aes = AES.new(key, AES.MODE_CBC, iv)
return base64.b64encode( encoded = b"Salted__" + salt + aes.encrypt(self.pad(message))
b"Salted__" + salt + aes.encrypt(self.pad(message)) return (
base64.urlsafe_b64encode(encoded) if urlsafe else base64.b64encode(encoded)
).decode() ).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: if len(settings.lnbits_allowed_currencies) > 0:
return [ return [
item item
@@ -187,6 +187,9 @@ def allowed_currencies():
async def btc_rates(currency: str) -> list[tuple[str, float]]: 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): def replacements(ticker: str):
return { return {
"FROM": "BTC", "FROM": "BTC",
+1 -2
View File
@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import importlib import importlib
from typing import Optional
from lnbits.nodes import set_node_class from lnbits.nodes import set_node_class
from lnbits.settings import settings from lnbits.settings import settings
@@ -33,7 +32,7 @@ from .void import VoidWallet
from .zbd import ZBDWallet 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 backend_wallet_class = class_name or settings.lnbits_backend_wallet_class
funding_source_constructor = getattr(wallets_module, backend_wallet_class) funding_source_constructor = getattr(wallets_module, backend_wallet_class)
global funding_source global funding_source
+17 -17
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
from abc import ABC, abstractmethod 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 from loguru import logger
@@ -11,15 +11,15 @@ if TYPE_CHECKING:
class StatusResponse(NamedTuple): class StatusResponse(NamedTuple):
error_message: Optional[str] error_message: str | None
balance_msat: int balance_msat: int
class InvoiceResponse(NamedTuple): class InvoiceResponse(NamedTuple):
ok: bool ok: bool
checking_id: Optional[str] = None # payment_hash, rpc_id checking_id: str | None = None # payment_hash, rpc_id
payment_request: Optional[str] = None payment_request: str | None = None
error_message: Optional[str] = None error_message: str | None = None
@property @property
def success(self) -> bool: def success(self) -> bool:
@@ -36,11 +36,11 @@ class InvoiceResponse(NamedTuple):
class PaymentResponse(NamedTuple): class PaymentResponse(NamedTuple):
# when ok is None it means we don't know if this succeeded # when ok is None it means we don't know if this succeeded
ok: Optional[bool] = None ok: bool | None = None
checking_id: Optional[str] = None # payment_hash, rcp_id checking_id: str | None = None # payment_hash, rcp_id
fee_msat: Optional[int] = None fee_msat: int | None = None
preimage: Optional[str] = None preimage: str | None = None
error_message: Optional[str] = None error_message: str | None = None
@property @property
def success(self) -> bool: def success(self) -> bool:
@@ -56,9 +56,9 @@ class PaymentResponse(NamedTuple):
class PaymentStatus(NamedTuple): class PaymentStatus(NamedTuple):
paid: Optional[bool] = None paid: bool | None = None
fee_msat: Optional[int] = None fee_msat: int | None = None
preimage: Optional[str] = None preimage: str | None = None
@property @property
def success(self) -> bool: def success(self) -> bool:
@@ -94,7 +94,7 @@ class PaymentPendingStatus(PaymentStatus):
class Wallet(ABC): class Wallet(ABC):
__node_cls__: Optional[type[Node]] = None __node_cls__: type[Node] | None = None
def __init__(self) -> None: def __init__(self) -> None:
self.pending_invoices: list[str] = [] self.pending_invoices: list[str] = []
@@ -111,9 +111,9 @@ class Wallet(ABC):
def create_invoice( def create_invoice(
self, self,
amount: int, amount: int,
memo: Optional[str] = None, memo: str | None = None,
description_hash: Optional[bytes] = None, description_hash: bytes | None = None,
unhashed_description: Optional[bytes] = None, unhashed_description: bytes | None = None,
**kwargs, **kwargs,
) -> Coroutine[None, None, InvoiceResponse]: ) -> Coroutine[None, None, InvoiceResponse]:
pass pass
Generated
+2 -21
View File
@@ -1395,9 +1395,6 @@ files = [
{file = "importlib_resources-6.1.1.tar.gz", hash = "sha256:3893a00122eafde6894c59914446a512f728a0c1a45f9bb9b63721b6bacf0b4a"}, {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] [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"] 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)"] 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] [package.dependencies]
anyio = ">=3.4.0,<5" anyio = ">=3.4.0,<5"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras] [package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] 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"}, {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] [extras]
breez = ["breez-sdk"] breez = ["breez-sdk"]
liquid = ["wallycore"] liquid = ["wallycore"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.13 | ^3.12 | ^3.11 | ^3.10 | ^3.9" python-versions = "~3.12 | ~3.11 | ~3.10"
content-hash = "e263865649975ea7e977b3cbf6cb453c3653de115523d026e3863605ab48a463" content-hash = "96dd180aaa4fbfeb34fa6f9647c8684fce183a72b1b41d22101a9dd4b962fa2e"
+2 -2
View File
@@ -12,7 +12,7 @@ packages = [
] ]
[tool.poetry.dependencies] [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" bech32 = "1.2.0"
click = "8.1.7" click = "8.1.7"
ecdsa = "0.19.0" ecdsa = "0.19.0"
@@ -192,7 +192,7 @@ extend-exclude = [
select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"] select = ["F", "E", "W", "I", "A", "C", "N", "UP", "RUF", "B"]
# UP007: pyupgrade: use X | Y instead of Optional. (python3.10) # UP007: pyupgrade: use X | Y instead of Optional. (python3.10)
# RUF012: mutable-class-default # RUF012: mutable-class-default
ignore = ["UP007", "RUF012"] ignore = ["RUF012"]
# Allow autofix for all enabled rules (when `--fix`) is provided. # Allow autofix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"] fixable = ["ALL"]