Compare commits

..
Author SHA1 Message Date
Vlad Stan e0cf512d3e fix: remove nostr message 2026-02-03 15:17:49 +02:00
Vlad Stan b86720f59e feat: better wording 2026-02-03 14:40:46 +02:00
Vlad Stan 13c370fb08 feat: check invitation code 2026-02-03 11:21:47 +02:00
Vlad Stan 3b4e8ace6a feat: update settings 2026-02-03 10:29:53 +02:00
Vlad Stan 390c616998 feat: send invitation code to backend 2026-02-03 10:24:00 +02:00
Vlad Stan e5e6ee3628 feat: add confirmation options UI 2026-02-02 18:36:49 +02:00
Vlad Stan f962502610 feat: configure ui 2026-02-02 16:35:57 +02:00
Vlad Stan f40cb536ba feat: configure activation codes 2026-02-02 16:29:52 +02:00
Vlad Stan db9a9eb8c2 feat: add some info 2026-02-02 16:29:52 +02:00
Vlad Stan 8ba662ff99 refactor: reorder fields 2026-02-02 16:29:52 +02:00
Vlad Stan e5acd6188e feat: add ui config 2026-02-02 16:29:52 +02:00
Vlad Stan 46ebb6ce27 refactor: code cleanup 2026-02-02 16:29:35 +02:00
Vlad Stan c7398f6314 chore: clean-up 2026-02-02 12:37:48 +02:00
Vlad Stan efa6398c51 chore: bundle 2026-02-02 12:35:34 +02:00
Vlad Stan 7179dbfeef refactor: simplify queries 2026-02-02 12:34:57 +02:00
Vlad Stan 4021648082 refactor: use normal update 2026-02-02 12:34:57 +02:00
Vlad Stan d5236aa88f test: add more check 2026-02-02 12:34:55 +02:00
Vlad Stan ff4132ef95 test: login after user disabled does not work 2026-02-02 12:34:55 +02:00
Vlad Stan 7b2805ef46 fix: column selection 2026-02-02 12:34:47 +02:00
Vlad Stan 3bbfc2a58c fix: better message 2026-02-02 12:34:47 +02:00
Vlad Stan 00795a186d feat: add back toggle admin 2026-02-02 12:34:47 +02:00
Vlad Stan fa0c57ce3d fix: only fetch keys for activated users 2026-02-02 12:34:47 +02:00
Vlad Stan ff6959095f fix: clear cache 2026-02-02 12:34:47 +02:00
Vlad Stan e951d02c3d feat: clear cache on user deactivation 2026-02-02 12:34:47 +02:00
Vlad Stan fadad38c99 feat: basic account activate/deactivate 2026-02-02 12:34:47 +02:00
25 changed files with 100 additions and 742 deletions
+1 -3
View File
@@ -59,7 +59,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx # LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# which fundingsources are allowed in the admin ui # which fundingsources are allowed in the admin ui
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, LightsparkSparkWallet" # LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
# uvicorn variable, allow https behind a proxy # uvicorn variable, allow https behind a proxy
# IMPORTANT: this also needs the webserver to be configured to forward the headers # IMPORTANT: this also needs the webserver to be configured to forward the headers
@@ -191,8 +191,6 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# TODO: add Spark
# StrikeWallet # StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1 STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY STRIKE_API_KEY=YOUR_STRIKE_API_KEY
+1 -1
View File
@@ -57,7 +57,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. | | **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). | | **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
## TODO: add Spark ---
### Notes for readers ### Notes for readers
+1 -3
View File
@@ -52,7 +52,7 @@ A backend wallet is selected and configured entirely through LNbits environment
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune)) ### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest) [Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs) Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
**Environment variables** **Environment variables**
@@ -338,8 +338,6 @@ Configure in the admin UI or via env vars:
<a id="strike"></a> <a id="strike"></a>
## TODO: add Spark
## Strike (alpha) ## Strike (alpha)
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs. Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
+26 -31
View File
@@ -98,17 +98,19 @@ async def get_accounts(
async def get_account( async def get_account(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(user_id) == 0: if len(user_id) == 0:
return None return None
activate_clause = "" if activated is None else "AND activated = :activated"
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" f"""
SELECT * FROM accounts SELECT * FROM accounts
WHERE id = :id AND (activated = true OR activated = :activated) WHERE id = :id {activate_clause}
""", """, # noqa: S608
{"id": user_id, "activated": active_only}, {"id": user_id, "activated": activated},
Account, Account,
) )
@@ -134,7 +136,7 @@ async def delete_accounts_no_wallets(
async def get_account_by_username( async def get_account_by_username(
username: str, active_only: bool = True, conn: Connection | None = None username: str, activated: bool = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(username) == 0: if len(username) == 0:
return None return None
@@ -142,32 +144,28 @@ async def get_account_by_username(
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(username) = :username AND activated = :activated
LOWER(username) = :username
AND (activated = true OR activated = :activated)
""", """,
{"username": username.lower(), "activated": active_only}, {"username": username.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_pubkey( async def get_account_by_pubkey(
pubkey: str, active_only: bool = True, conn: Connection | None = None pubkey: str, activated: bool | None = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(pubkey) = :pubkey AND activated = :activated
LOWER(pubkey) = :pubkey
AND (activated = true OR activated = :activated)
""", """,
{"pubkey": pubkey.lower(), "activated": active_only}, {"pubkey": pubkey.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_email( async def get_account_by_email(
email: str, active_only: bool = True, conn: Connection | None = None email: str, activated: bool = True, conn: Connection | None = None
) -> Account | None: ) -> Account | None:
if len(email) == 0: if len(email) == 0:
return None return None
@@ -175,38 +173,35 @@ async def get_account_by_email(
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE LOWER(email) = :email AND activated = :activated
LOWER(email) = :email
AND (activated = true OR activated = :activated)
""", """,
{"email": email.lower(), "activated": active_only}, {"email": email.lower(), "activated": activated},
Account, Account,
) )
async def get_account_by_username_or_email( async def get_account_by_username_or_email(
username_or_email: str, username_or_email: str,
active_only: bool = True, activated: bool = True,
conn: Connection | None = None, conn: Connection | None = None,
) -> Account | None: ) -> Account | None:
return await (conn or db).fetchone( return await (conn or db).fetchone(
""" """
SELECT * FROM accounts SELECT * FROM accounts
WHERE WHERE (LOWER(email) = :value or LOWER(username) = :value)
(LOWER(email) = :value or LOWER(username) = :value) AND activated = :activated
AND (activated = true OR activated = :activated)
""", """,
{"value": username_or_email.lower(), "activated": active_only}, {"value": username_or_email.lower(), "activated": activated},
Account, Account,
) )
async def get_user( async def get_user(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool | None = True, conn: Connection | None = None
) -> User | None: ) -> User | None:
async with db.reuse_conn(conn) if conn else db.connect() as conn: async with db.reuse_conn(conn) if conn else db.connect() as conn:
account = await get_account(user_id, active_only, conn=conn) account = await get_account(user_id, activated=activated, conn=conn)
if not account: if not account:
return None return None
return await get_user_from_account(account, conn=conn) return await get_user_from_account(account, conn=conn)
@@ -251,14 +246,14 @@ async def update_user_access_control_list(
async def get_user_access_control_lists( async def get_user_access_control_lists(
user_id: str, active_only: bool = True, conn: Connection | None = None user_id: str, activated: bool = True, conn: Connection | None = None
) -> UserAcls: ) -> UserAcls:
user_acls = await (conn or db).fetchone( user_acls = await (conn or db).fetchone(
""" """
SELECT id, access_control_list FROM accounts SELECT id, access_control_list FROM accounts
WHERE id = :user_id AND (activated = true OR activated = :activated) WHERE id = :user_id AND activated = :activated
""", """,
{"user_id": user_id, "activated": active_only}, {"user_id": user_id, "activated": activated},
UserAcls, UserAcls,
) )
@@ -266,7 +261,7 @@ async def get_user_access_control_lists(
async def clear_user_id_cache(user_id: str): async def clear_user_id_cache(user_id: str):
user = await get_user(user_id, active_only=True) user = await get_user(user_id, activated=None)
if user: if user:
clear_user_cache(user) clear_user_cache(user)
+1 -1
View File
@@ -318,7 +318,7 @@ class RegisterUser(BaseModel):
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)
invitation_code: str | None = Query(default=None, max_length=256) invitation_code: str | None = Query(default=None, min_length=1, max_length=256)
class CreateUser(BaseModel): class CreateUser(BaseModel):
+4 -4
View File
@@ -118,8 +118,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
""" """
webhook_id = settings.paypal_webhook_id webhook_id = settings.paypal_webhook_id
if not webhook_id: if not webhook_id:
logger.warning("PayPal webhook ID not set.") logger.warning("PayPal webhook ID not set; skipping verification.")
raise ValueError("PayPal webhook cannot be verified. Missing webhook ID.") return
required_headers = { required_headers = {
"PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"), "PAYPAL-TRANSMISSION-ID": headers.get("PAYPAL-TRANSMISSION-ID"),
@@ -129,8 +129,8 @@ async def verify_paypal_webhook(headers, payload: bytes):
"PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"), "PAYPAL-AUTH-ALGO": headers.get("PAYPAL-AUTH-ALGO"),
} }
if not all(required_headers.values()): if not all(required_headers.values()):
logger.warning("Missing PayPal webhook headers.") logger.warning("Missing PayPal webhook headers; skipping verification.")
raise ValueError("PayPal webhook cannot be verified. Missing headers.") return
try: try:
async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client: async with httpx.AsyncClient(base_url=settings.paypal_api_endpoint) as client:
+2 -13
View File
@@ -776,7 +776,7 @@ async def _pay_internal_invoice(
await update_payment(internal_payment, conn=conn) await update_payment(internal_payment, conn=conn)
logger.success(f"internal payment successful {internal_payment.checking_id}") logger.success(f"internal payment successful {internal_payment.checking_id}")
await _send_payment_notification_in_background(wallet.id, payment, conn=conn) send_payment_notification_in_background(wallet, payment)
# notify receiver asynchronously # notify receiver asynchronously
from lnbits.tasks import internal_invoice_queue from lnbits.tasks import internal_invoice_queue
@@ -849,8 +849,7 @@ async def _pay_external_invoice(
payment = await update_payment_success_status( payment = await update_payment_success_status(
payment, payment_response, conn=conn payment, payment_response, conn=conn
) )
send_payment_notification_in_background(wallet, payment)
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
logger.success(f"payment successful {payment_response.checking_id}") logger.success(f"payment successful {payment_response.checking_id}")
payment.checking_id = payment_response.checking_id payment.checking_id = payment_response.checking_id
@@ -1058,13 +1057,3 @@ async def cancel_hold_invoice(payment: Payment) -> InvoiceResponse:
await update_payment(payment) await update_payment(payment)
return response return response
async def _send_payment_notification_in_background(
wallet_id: str, payment: Payment, conn: Connection | None = None
):
# fetch balance again
wallet = await get_wallet(wallet_id, conn=conn)
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
+2 -5
View File
@@ -199,11 +199,8 @@ async def init_admin_settings(super_user: str | None = None) -> SuperSettings:
async def check_register_activation_settings(data: RegisterUser): async def check_register_activation_settings(data: RegisterUser):
if not settings.lnbits_require_user_activation: if not settings.lnbits_require_user_activation:
return None return None
if settings.lnbits_user_activation_by_invitation_code: if settings.lnbits_user_activation_by_invitation_code and data.invitation_code:
code = data.invitation_code.strip() if data.invitation_code else "" code = data.invitation_code.strip()
if len(code) == 0:
raise ValueError("Invitation code cannot be empty.")
if code == settings.lnbits_register_reusable_activation_code: if code == settings.lnbits_register_reusable_activation_code:
return None return None
if code in settings.lnbits_register_one_time_activation_codes: if code in settings.lnbits_register_one_time_activation_codes:
+3 -11
View File
@@ -98,7 +98,7 @@ async def nostr_login(request: Request) -> JSONResponse:
if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98): if not settings.is_auth_method_allowed(AuthMethods.nostr_auth_nip98):
raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.") raise HTTPException(HTTPStatus.FORBIDDEN, "Login with Nostr Auth not allowed.")
event = _nostr_nip98_event(request) event = _nostr_nip98_event(request)
account = await get_account_by_pubkey(event["pubkey"], active_only=False) account = await get_account_by_pubkey(event["pubkey"])
if not account: if not account:
account = Account( account = Account(
id=uuid4().hex, id=uuid4().hex,
@@ -106,8 +106,6 @@ async def nostr_login(request: Request) -> JSONResponse:
extra=UserExtra(provider="nostr"), extra=UserExtra(provider="nostr"),
) )
await create_user_account(account) await create_user_account(account)
if not account.activated:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User is not activated.")
return _auth_success_response(account.username or "", account.id, account.email) return _auth_success_response(account.username or "", account.id, account.email)
@@ -363,7 +361,7 @@ async def register(data: RegisterUser) -> JSONResponse:
if not is_valid_username(data.username): if not is_valid_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid username.")
if await get_account_by_username(data.username, active_only=False): if await get_account_by_username(data.username):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
if data.email and not is_valid_email_address(data.email): if data.email and not is_valid_email_address(data.email):
@@ -511,21 +509,15 @@ async def update_ui_customization(
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse: async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install: if not settings.first_install:
raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install") raise HTTPException(HTTPStatus.FORBIDDEN, "This is not your first install")
if settings.first_install_token: if settings.first_install_token:
if not data.first_install_token: if not data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Missing first_install_token.")
if settings.first_install_token != data.first_install_token: if settings.first_install_token != data.first_install_token:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.") raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid first_install_token.")
account = await get_account_by_username(data.username, False)
if account:
raise HTTPException(HTTPStatus.BAD_REQUEST, "Username already exists.")
account = await get_account(settings.super_user) account = await get_account(settings.super_user)
if not account: if not account:
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.") raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Superuser not found.")
account.username = data.username account.username = data.username
account.extra = account.extra or UserExtra() account.extra = account.extra or UserExtra()
account.extra.provider = "lnbits" account.extra.provider = "lnbits"
@@ -541,7 +533,7 @@ async def _handle_sso_login(userinfo: OpenID, verified_user_id: str | None = Non
raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.") raise HTTPException(HTTPStatus.BAD_REQUEST, "Invalid email.")
redirect_path = "/wallet" redirect_path = "/wallet"
account = await get_account_by_email(email, active_only=False) account = await get_account_by_email(email)
if verified_user_id: if verified_user_id:
if account: if account:
+2 -2
View File
@@ -74,7 +74,7 @@ async def api_get_users(
summary="Get user by Id", summary="Get user by Id",
) )
async def api_get_user(user_id: str) -> User: async def api_get_user(user_id: str) -> User:
user = await get_user(user_id, active_only=False) user = await get_user(user_id, activated=None)
if not user: if not user:
raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.") raise HTTPException(HTTPStatus.NOT_FOUND, "User not found.")
return user return user
@@ -242,7 +242,7 @@ async def api_users_toggle_activated(
if settings.is_admin_user(user_id): if settings.is_admin_user(user_id):
settings.lnbits_admin_users.remove(user_id) settings.lnbits_admin_users.remove(user_id)
user_account = await get_account(user_id, active_only=False) user_account = await get_account(user_id, activated=None)
if not user_account: if not user_account:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, status_code=HTTPStatus.NOT_FOUND,
+42 -9
View File
@@ -12,6 +12,7 @@ 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
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
from sqlalchemy.sql import text from sqlalchemy.sql import text
@@ -55,6 +56,14 @@ def compat_timestamp_placeholder(key: str):
return f":{key}" return f":{key}"
def get_placeholder(model: Any, field: str) -> str:
type_ = model.__fields__[field].type_
if type_ == datetime:
return compat_timestamp_placeholder(field)
else:
return f":{field}"
class Compat: class Compat:
type: str | None = "<inherited>" type: str | None = "<inherited>"
schema: str | None = "<inherited>" schema: str | None = "<inherited>"
@@ -317,7 +326,31 @@ class Database(Compat):
self.engine: AsyncEngine = create_async_engine( self.engine: AsyncEngine = create_async_engine(
database_uri, echo=settings.debug_database database_uri, echo=settings.debug_database
) )
if self.type in {POSTGRES, COCKROACH}:
@event.listens_for(self.engine.sync_engine, "connect")
def register_custom_types(dbapi_connection, *_):
def _parse_date(value) -> datetime:
if value is None:
value = "1970-01-01 00:00:00"
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
# Parse and add UTC timezone info
return datetime.strptime(value, f).replace(tzinfo=timezone.utc)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
"TIMESTAMP",
encoder=datetime,
decoder=_parse_date,
schema="pg_catalog",
)
)
self.lock = asyncio.Lock() self.lock = asyncio.Lock()
logger.trace(f"database {self.type} added for {self.name}") logger.trace(f"database {self.type} added for {self.name}")
@asynccontextmanager @asynccontextmanager
@@ -630,7 +663,7 @@ def insert_query(table_name: str, model: BaseModel) -> str:
placeholders = [] placeholders = []
keys = model_to_dict(model).keys() keys = model_to_dict(model).keys()
for field in keys: for field in keys:
placeholders.append(f":{field}") placeholders.append(get_placeholder(model, field))
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields = ", ".join([f'"{key}"' for key in keys]) fields = ", ".join([f'"{key}"' for key in keys])
values = ", ".join(placeholders) values = ", ".join(placeholders)
@@ -648,8 +681,9 @@ def update_query(
""" """
fields = [] fields = []
for field in model_to_dict(model).keys(): for field in model_to_dict(model).keys():
placeholder = get_placeholder(model, field)
# add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword) # add quotes to keys to avoid SQL conflicts (e.g. `user` is a reserved keyword)
fields.append(f'"{field}" = :{field}') fields.append(f'"{field}" = {placeholder}')
query = ", ".join(fields) query = ", ".join(fields)
return f"UPDATE {table_name} SET {query} {where}" # noqa: S608 return f"UPDATE {table_name} SET {query} {where}" # noqa: S608
@@ -667,12 +701,7 @@ def model_to_dict(model: BaseModel) -> dict:
if model.__fields__[key].field_info.extra.get("no_database", False): if model.__fields__[key].field_info.extra.get("no_database", False):
continue continue
if isinstance(value, datetime): if isinstance(value, datetime):
if DB_TYPE == SQLITE: _dict[key] = value.timestamp()
_dict[key] = value.timestamp()
else:
# remove tz. postgres and cockroach TIMESTAMP is not tz aware
# so it will throw if we dont remove the UTC.
_dict[key] = value.replace(tzinfo=None)
continue continue
if ( if (
type(type_) is type(BaseModel) type(type_) is type(BaseModel)
@@ -728,7 +757,11 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE: if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc) _dict[key] = datetime.fromtimestamp(value, timezone.utc)
else: else:
_dict[key] = value.replace(tzinfo=timezone.utc) # Ensure PostgreSQL datetime values have timezone info
if isinstance(value, datetime) and value.tzinfo is None:
_dict[key] = value.replace(tzinfo=timezone.utc)
else:
_dict[key] = value
continue continue
if issubclass(type_, BaseModel): if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value) _dict[key] = dict_to_submodel(type_, value)
-13
View File
@@ -602,17 +602,6 @@ class SparkFundingSource(LNbitsSettings):
spark_token: str | None = Field(default=None) spark_token: str | None = Field(default=None)
class SparkL2FundingSource(LNbitsSettings):
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_network: str = Field(default="MAINNET")
spark_l2_internal_sidecar_version: str | None = Field(default="0.1.1")
spark_l2_external_endpoint: str | None = Field(default=None)
spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
class LnTipsFundingSource(LNbitsSettings): class LnTipsFundingSource(LNbitsSettings):
lntips_api_endpoint: str | None = Field(default=None) lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None) lntips_api_key: str | None = Field(default=None)
@@ -718,7 +707,6 @@ class FundingSourcesSettings(
PhoenixdFundingSource, PhoenixdFundingSource,
OpenNodeFundingSource, OpenNodeFundingSource,
SparkFundingSource, SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource, LnTipsFundingSource,
NWCFundingSource, NWCFundingSource,
BreezSdkFundingSource, BreezSdkFundingSource,
@@ -1047,7 +1035,6 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet", "FakeWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet", "LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet", "LnTipsWallet",
"LndRestWallet", "LndRestWallet",
"LndWallet", "LndWallet",
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
+6
View File
@@ -566,6 +566,12 @@ window.app.component('username-password', {
this.confirmationMethod !== 'code' || this.confirmationMethod !== 'code' ||
this.confirmationCode.length > 0 this.confirmationCode.length > 0
console.log('### disableRegister', {
usernameOK,
passwordOK,
passwordsMatch,
codeOk
})
return !usernameOK || !passwordOK || !passwordsMatch || !codeOk return !usernameOK || !passwordOK || !passwordsMatch || !codeOk
}, },
confirmationMethodsCount() { confirmationMethodsCount() {
@@ -228,51 +228,6 @@ window.app.component('lnbits-admin-funding-sources', {
spark_token: 'Token' spark_token: 'Token'
} }
], ],
[
'LightsparkSparkWallet',
'Spark (L2)',
{
spark_l2_internal_sidecar_version: {
label: 'Internal Sidecar Version (eg: 0.1.1).',
hint: 'If specified then the sidecar will be downloaded. Alternatively you can specify an External Sidecar endpoint in the Advanced section.',
value: ''
},
spark_l2_mnemonic: {
label: 'Mnemonic',
hint: 'Only required if Interna Sidecar version is specified.'
},
spark_l2_network: {
label: 'Network',
value: 'MAINNET'
},
spark_l2_external_endpoint: {
label: 'External Sidecar Endpoint. ',
hint: 'If specified then this endpoint will be used instead of the internal sidecar. Make sure to also specify the API key if your sidecar requires authentication.',
value: '',
advanced: true
},
spark_l2_external_api_key: {
label: 'External Sidecar API Key. ',
hint: 'API Key for authenticating with the external sidecar if it requires authentication.',
value: '',
advanced: true
},
spark_l2_pay_wait_ms: {
label: 'Pay Wait Time (ms)',
advanced: true
},
spark_l2_pay_poll_ms: {
label: 'Pay Poll Time (ms)',
advanced: true
},
spark_l2_stream_keepalive_ms: {
label: 'Stream Keepalive Time (ms)',
advanced: true
}
}
],
[ [
'NWCWallet', 'NWCWallet',
'Nostr Wallet Connect', 'Nostr Wallet Connect',
@@ -71,10 +71,6 @@ window.app.component('lnbits-wallet-extra', {
'lnbits.exchangeRate.' + this.g.wallet.currency, 'lnbits.exchangeRate.' + this.g.wallet.currency,
this.g.exchangeRate this.g.exchangeRate
) )
if (this.g.exchangeRate <= 0) {
this.g.fiatTracking = false
this.g.isFiatPriority = false
}
}) })
.catch(e => console.error(e)) .catch(e => console.error(e))
} }
+1 -5
View File
@@ -144,7 +144,7 @@ window._lnbitsUtils = {
return null return null
} }
}, },
async notifyApiError(error) { notifyApiError(error) {
if (!error.response) { if (!error.response) {
return console.error(error) return console.error(error)
} }
@@ -154,10 +154,6 @@ window._lnbitsUtils = {
500: 'negative' 500: 'negative'
} }
let messages = error.response.data.detail let messages = error.response.data.detail
if (!messages) {
const text = await error.response.data?.text()
messages = this.parseJSONSafe(text)?.detail
}
if (messages) { if (messages) {
messages = Array.isArray(messages) messages = Array.isArray(messages)
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`) ? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
@@ -310,13 +310,7 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-select <q-select
filled filled
:options="[ :options="[$t('second'), $t('minute'), $t('hour')]"
{label: $t('second'), value: 'second'},
{label: $t('minute'), value: 'minute'},
{label: $t('hour'), value: 'hour'}
]"
emit-value
map-options
v-model="formData.lnbits_rate_limit_unit" v-model="formData.lnbits_rate_limit_unit"
:label="$t('time_unit')" :label="$t('time_unit')"
></q-select> ></q-select>
+1 -1
View File
@@ -178,7 +178,7 @@
" "
:clickable="!!reviewsUrl" :clickable="!!reviewsUrl"
@click="openReviews(extension)" @click="openReviews(extension)"
></lnbits-extension-rating> />
<q-btn-group size="xs" style="margin: 5px 0"> <q-btn-group size="xs" style="margin: 5px 0">
<q-btn <q-btn
v-if="extension.hasFreeRelease" v-if="extension.hasFreeRelease"
+4 -10
View File
@@ -292,8 +292,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
async def btc_price(currency: str) -> float: async def btc_price(currency: str) -> float:
rates = await btc_rates(currency) rates = await btc_rates(currency)
if not rates: if not rates:
logger.warning("Could not fetch any Bitcoin price.") raise ValueError("Could not fetch any Bitcoin price.")
return 0.0
elif len(rates) == 1: elif len(rates) == 1:
logger.warning("Could only fetch one Bitcoin price.") logger.warning("Could only fetch one Bitcoin price.")
@@ -307,8 +306,7 @@ async def get_fiat_rate_and_price_satoshis(currency: str) -> tuple[float, float]
f"btc-price-{currency}", f"btc-price-{currency}",
settings.lnbits_exchange_rate_cache_seconds, settings.lnbits_exchange_rate_cache_seconds,
) )
rate = float(100_000_000 / price) if price > 0 else 0.0 return float(100_000_000 / price), price
return rate, price
async def get_fiat_rate_satoshis(currency: str) -> float: async def get_fiat_rate_satoshis(currency: str) -> float:
@@ -318,13 +316,9 @@ async def get_fiat_rate_satoshis(currency: str) -> float:
async def fiat_amount_as_satoshis(amount: float, currency: str) -> int: async def fiat_amount_as_satoshis(amount: float, currency: str) -> int:
rate = await get_fiat_rate_satoshis(currency) rate = await get_fiat_rate_satoshis(currency)
if rate > 0: return int(amount * (rate))
return int(amount * rate)
raise ValueError(f"Could not get exchange rate for {currency}.")
async def satoshis_amount_as_fiat(amount: float, currency: str) -> float: async def satoshis_amount_as_fiat(amount: float, currency: str) -> float:
rate = await get_fiat_rate_satoshis(currency) rate = await get_fiat_rate_satoshis(currency)
if rate > 0: return float(amount / rate)
return float(amount / rate)
raise ValueError(f"Could not get exchange rate for {currency}.")
-2
View File
@@ -20,7 +20,6 @@ from .corelightning import CoreLightningWallet as CLightningWallet
from .corelightningrest import CoreLightningRestWallet from .corelightningrest import CoreLightningRestWallet
from .eclair import EclairWallet from .eclair import EclairWallet
from .fake import FakeWallet from .fake import FakeWallet
from .lightspark import LightsparkSparkWallet
from .lnbits import LNbitsWallet from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet from .lndgrpc import LndWallet
from .lndrest import LndRestWallet from .lndrest import LndRestWallet
@@ -70,7 +69,6 @@ __all__ = [
"FakeWallet", "FakeWallet",
"LNPayWallet", "LNPayWallet",
"LNbitsWallet", "LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet", "LnTipsWallet",
"LndRestWallet", "LndRestWallet",
"LndWallet", "LndWallet",
-424
View File
@@ -1,424 +0,0 @@
import asyncio
import hashlib
import json
import os
import shutil
import subprocess
import uuid
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, cast
import httpx
from bolt11 import decode as bolt11_decode
from loguru import logger
from lnbits.helpers import download_url, normalize_endpoint
from lnbits.settings import settings
from .base import (
InvoiceResponse,
PaymentFailedStatus,
PaymentPendingStatus,
PaymentResponse,
PaymentStatus,
PaymentSuccessStatus,
StatusResponse,
Wallet,
)
class SparkSidecarError(Exception):
pass
class LightsparkSparkWallet(Wallet):
"""
Spark L2 funding source via a local sidecar service.
Required settings/env:
- SPARK_L2_ENDPOINT (default http://127.0.0.1:8765)
Optional:
- SPARK_L2_API_KEY
"""
def __init__(self):
self._status = "Initializing"
self._sidecar_path = Path(settings.lnbits_data_folder, "light_spark")
self.pending_invoices: list[str] = []
self.endpoint = "http://127.0.0.1:8765"
self._api_key = uuid.uuid4().hex
if settings.spark_l2_internal_sidecar_version:
self._sidecar_version = settings.spark_l2_internal_sidecar_version
self.sidecar_task = asyncio.create_task(self._start_sidecar())
logger.info(f"Internal Spark sidecar ({self._sidecar_version}).")
elif settings.spark_l2_external_endpoint:
self.endpoint = normalize_endpoint(
cast(str, settings.spark_l2_external_endpoint)
)
self._api_key = settings.spark_l2_external_api_key
logger.info(f"Using external Spark sidecar endpoint: {self.endpoint}")
else:
logger.error(
"No Spark sidecar configuration found. Please set either "
"spark_l2_internal_sidecar_version or spark_l2_external_endpoint."
)
headers = {"User-Agent": settings.user_agent, "X-Api-Key": self._api_key}
self.client = httpx.AsyncClient(
base_url=self.endpoint,
headers=headers,
timeout=60,
)
async def cleanup(self):
try:
await self.client.aclose()
self.sidecar_task.cancel()
except RuntimeError as e:
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
try:
res = await self._request("POST", "/v1/balance")
balance_msat = res.get("balance_msat")
if balance_msat is not None:
return StatusResponse(None, int(balance_msat))
balance_sats = res.get("balance_sats")
if balance_sats is None:
return StatusResponse("Spark sidecar: missing balance.", 0)
return StatusResponse(None, int(balance_sats) * 1000)
except Exception as e:
logger.warning(e)
return StatusResponse(f"Spark sidecar status error: {e}", 0)
async def create_invoice(
self,
amount: int,
memo: str | None = None,
description_hash: bytes | None = None,
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
expiry = kwargs.get("expiry")
expiry_secs = int(expiry) if expiry else None
description_hash_hex = None
if description_hash:
description_hash_hex = description_hash.hex()
elif unhashed_description:
description_hash_hex = hashlib.sha256(unhashed_description).hexdigest()
try:
payload = {
"amount_sats": int(amount),
"memo": (memo or "") if not description_hash_hex else None,
"description_hash": description_hash_hex,
"expiry_seconds": expiry_secs,
}
res = await self._request("POST", "/v1/invoices", payload)
bolt11 = res.get("payment_request")
checking_id = res.get("checking_id")
if not bolt11 or not checking_id:
return InvoiceResponse(
ok=False,
error_message="Spark sidecar invoice response missing fields.",
)
self.pending_invoices.append(checking_id)
return InvoiceResponse(
ok=True,
payment_request=bolt11,
checking_id=checking_id,
preimage=res.get("preimage", None),
)
except Exception as e:
return InvoiceResponse(ok=False, error_message=str(e))
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
max_fee_sats = (int(fee_limit_msat) + 999) // 1000
payment_hash = None
try:
payment_hash = bolt11_decode(bolt11).payment_hash
except Exception as exc:
logger.warning(exc)
payment_hash = None
payload = {
"bolt11": bolt11,
"max_fee_sats": max_fee_sats,
"payment_hash": payment_hash,
}
res = await self._request("POST", "/v1/payments", payload)
checking_id = payment_hash or res.get("checking_id")
if not checking_id:
return PaymentResponse(
ok=False,
error_message="Spark sidecar payment response missing checking_id.",
)
status = res.get("status")
fee_msat = res.get("fee_msat")
ok = None
if status:
ok = self._map_payment_ok(status)
return PaymentResponse(
ok=ok,
checking_id=checking_id,
fee_msat=int(fee_msat) if fee_msat is not None else None,
preimage=res.get("preimage"),
)
except Exception as e:
return PaymentResponse(ok=False, error_message=str(e))
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
res = await self._request("GET", f"/v1/invoices/{checking_id}")
status = res.get("status")
if not status:
return PaymentPendingStatus()
return self._map_invoice_status(status)
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
try:
res = await self._request("GET", f"/v1/payments/{checking_id}")
status = res.get("status")
fee_msat = res.get("fee_msat")
preimage = res.get("preimage")
if not status:
return PaymentPendingStatus()
mapped = self._map_payment_status(status)
if mapped.success:
return PaymentSuccessStatus(
fee_msat=int(fee_msat) if fee_msat is not None else None,
preimage=preimage,
)
if mapped.failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
except Exception as exc:
logger.warning(exc)
return PaymentPendingStatus()
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
stream_path = "/v1/invoices/stream"
while settings.lnbits_running:
try:
async with self.client.stream("GET", stream_path, timeout=None) as r:
if r.status_code in {404, 405}:
logger.warning(
"Spark sidecar invoice stream not available, "
"falling back to polling."
)
async for checking_id in self._poll_pending_invoices():
yield checking_id
return
r.raise_for_status()
logger.info("connected to Spark sidecar invoice stream.")
async for line in r.aiter_lines():
if not line or not line.startswith("data:"):
continue
data = json.loads(line[5:].strip())
checking_id = data.get("checking_id")
if not checking_id:
continue
yield checking_id
except Exception as exc:
logger.error(
"lost connection to Spark sidecar invoice stream: "
f"'{exc}' retrying in 5 seconds"
)
await asyncio.sleep(5)
async def _request(
self, method: str, path: str, json_data: dict[str, Any] | None = None
) -> dict[str, Any]:
error_message = None
try:
r = await self.client.request(method, path, json=json_data)
r.raise_for_status()
j = r.json()
except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError) as exc:
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
try:
error_json = exc.response.json()
if "error" in error_json:
error_message = error_json["error"]
except Exception as json_exc:
logger.error(
f"Failed to parse Spark error response as JSON: {json_exc}"
)
raise SparkSidecarError(
error_message or f"Spark sidecar request error: '{exc}'"
) from exc
if error_message or j.get("error"):
raise SparkSidecarError(
error_message or f"Spark sidecar error: {j['error']}"
)
return j
async def _poll_pending_invoices(self) -> AsyncGenerator[str, None]:
while settings.lnbits_running:
for invoice in list(self.pending_invoices):
try:
status = await self.get_invoice_status(invoice)
if status.paid:
yield invoice
self.pending_invoices.remove(invoice)
elif status.failed:
self.pending_invoices.remove(invoice)
except Exception as exc:
logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
await asyncio.sleep(5)
async def _start_sidecar(self):
logger.info("Starting Spark sidecar")
node_path = shutil.which("node")
if not node_path:
logger.error("Node.js not found in PATH, cannot start Spark sidecar")
return
logger.info(f"Node.js found: {node_path}")
repo, version = "spark_sidecar", self._sidecar_version
node_modules_path = Path(self._sidecar_path, f"{repo}-{version}")
await self._prepare_sidecar(repo, version, node_modules_path)
await self._start_sidecar_process(node_path, node_modules_path)
async def _prepare_sidecar(self, repo: str, version: str, node_modules_path: Path):
if not Path(node_modules_path, "package.json").is_file():
await self._download_sidecar(repo, version)
else:
logger.info("Spark sidecar already downloaded.")
if not Path(node_modules_path, "node_modules").is_dir():
self._install_sidecar_packages(node_modules_path)
else:
logger.info("Spark sidecar npm dependencies already installed.")
def _install_sidecar_packages(self, node_modules_path: Path):
logger.info(f"Installing Spark sidecar npm dependencies {node_modules_path}")
npm_path = shutil.which("npm")
if not npm_path:
logger.error("npm not found in PATH, cannot start Spark sidecar")
return
logger.info(f"npm found: {npm_path}")
result = subprocess.run( # noqa: S603
[npm_path, "install"],
cwd=str(node_modules_path),
capture_output=True,
text=True,
shell=False,
check=True, # raises an exception if npm fails
)
logger.info("Spark sidecar npm dependencies installed.")
logger.info("npm install output:")
logger.info(result.stdout)
logger.error(result.stderr)
async def _start_sidecar_process(self, node_path: str, node_modules_path: Path):
logger.info("Starting Spark sidecar node process.")
env = {
"SPARK_NETWORK": settings.spark_l2_network,
"SPARK_SIDECAR_API_KEY": self._api_key or "",
"SPARK_PAY_WAIT_MS": str(settings.spark_l2_pay_wait_ms),
"SPARK_MNEMONIC": str(settings.spark_l2_mnemonic),
}
process = subprocess.Popen( # noqa: S603
[node_path, "server.mjs"],
env=env,
cwd=str(node_modules_path),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
shell=False,
text=True,
)
logger.info("Started Spark sidecar node process.")
await asyncio.to_thread(self._log_process_output, process)
async def _download_sidecar(self, repo: str, version: str):
zip_path = Path(self._sidecar_path, f"{repo}.zip")
logger.info(f"⏳ Downloading Spark sidecar to {zip_path}")
Path(zip_path).parent.mkdir(parents=True, exist_ok=True)
if zip_path.is_file():
os.remove(zip_path)
await asyncio.to_thread(
download_url,
f"https://github.com/lnbits/{repo}/archive/refs/tags/v{version}.zip",
zip_path,
)
logger.info("✅ Downloaded Spark sidecar.")
logger.info("⏳ Extracting Spark sidecar.")
shutil.unpack_archive(
zip_path,
self._sidecar_path,
)
logger.info("✅ Extracted Spark sidecar.")
def _log_process_output(self, process: subprocess.Popen):
if process.stdout:
for line in process.stdout:
logger.warning(f"[Lightspark]: {line}", end="")
else:
logger.error(" No output captured for Spark sidecar.")
def _map_invoice_status(self, status: str) -> PaymentStatus:
success = {
"LIGHTNING_PAYMENT_RECEIVED",
"TRANSFER_COMPLETED",
"PAYMENT_PREIMAGE_RECOVERED",
}
failed = {
"TRANSFER_FAILED",
"PAYMENT_PREIMAGE_RECOVERING_FAILED",
"REFUND_SIGNING_FAILED",
"REFUND_SIGNING_COMMITMENTS_QUERYING_FAILED",
"TRANSFER_CREATION_FAILED",
}
if status in success:
return PaymentSuccessStatus()
if status in failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
def _map_payment_status(self, status: str) -> PaymentStatus:
success = {
"LIGHTNING_PAYMENT_SUCCEEDED",
"TRANSFER_COMPLETED",
"PREIMAGE_PROVIDED",
}
failed = {
"LIGHTNING_PAYMENT_FAILED",
"TRANSFER_FAILED",
"PREIMAGE_PROVIDING_FAILED",
"USER_TRANSFER_VALIDATION_FAILED",
"USER_SWAP_RETURN_FAILED",
}
if status in success:
return PaymentSuccessStatus()
if status in failed:
return PaymentFailedStatus()
return PaymentPendingStatus()
def _map_payment_ok(self, status: str) -> bool | None:
mapped = self._map_payment_status(status)
if mapped.success:
return True
if mapped.failed:
return False
return None
-142
View File
@@ -297,148 +297,6 @@ async def test_register_ok(http_client: AsyncClient):
), f"Expected 1 default wallet, not {len(user.wallets)}." ), f"Expected 1 default wallet, not {len(user.wallets)}."
@pytest.mark.anyio
async def test_register_no_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 400
assert response.json().get("detail") == "No activation method provided."
settings.lnbits_user_activation_by_invitation_code = True
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
},
)
assert response.status_code == 400, "User creation blocked without activation code."
assert response.json().get("detail") == "Invitation code cannot be empty."
@pytest.mark.anyio
async def test_register_invalid_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "bar",
},
)
assert response.status_code == 400
assert response.json().get("detail") == "Invalid invitation code."
@pytest.mark.anyio
async def test_register_reusable_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "foo",
},
)
assert response.status_code == 200, "User created with reusable code."
assert response.json().get("access_token") is not None
# Register again with the same code
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "foo",
},
)
assert response.status_code == 200, "User created with reusable code."
assert response.json().get("access_token") is not None
@pytest.mark.anyio
async def test_register_one_time_activation_code(
http_client: AsyncClient, settings: Settings
):
settings.lnbits_require_user_activation = True
settings.lnbits_user_activation_by_invitation_code = True
settings.lnbits_register_reusable_activation_code = "foo"
settings.lnbits_register_one_time_activation_codes = ["baz", "qux"]
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "baz",
},
)
assert response.status_code == 200, "User created with one-time code."
assert response.json().get("access_token") is not None
# Register again with the same code
tiny_id = shortuuid.uuid()[:8]
response = await http_client.post(
"/api/v1/auth/register",
json={
"username": f"u21.{tiny_id}",
"password": "secret1234",
"password_repeat": "secret1234",
"email": f"u21.{tiny_id}@lnbits.com",
"invitation_code": "baz",
},
)
assert response.status_code == 400, "Invalid invitation code."
assert response.json().get("detail") == "Invalid invitation code."
@pytest.mark.anyio @pytest.mark.anyio
async def test_register_email_twice(http_client: AsyncClient): async def test_register_email_twice(http_client: AsyncClient):
tiny_id = shortuuid.uuid()[:8] tiny_id = shortuuid.uuid()[:8]
-4
View File
@@ -341,7 +341,3 @@ def _settings_cleanup(settings: Settings):
settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100 settings.lnbits_max_outgoing_payment_amount_sats = 10_000_000_100
settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200 settings.lnbits_max_incoming_payment_amount_sats = 10_000_000_200
settings.stripe_limits = FiatProviderLimits() settings.stripe_limits = FiatProviderLimits()
settings.lnbits_require_user_activation = False
settings.lnbits_user_activation_by_invitation_code = False
settings.lnbits_register_reusable_activation_code = ""
settings.lnbits_register_one_time_activation_codes = []