Compare commits

..
Author SHA1 Message Date
Vlad Stan 613f88faf2 test: codex testing 2026-03-30 09:56:42 +03:00
Vlad Stan 6a2ade1695 fix: ui 2026-03-27 16:57:33 +02:00
Vlad Stan 8211f95bf1 feat: ui 2026-03-27 16:54:52 +02:00
Vlad Stan 3857402471 feat: ui 2026-03-27 16:35:57 +02:00
Vlad Stan 0f0e756c37 feat: ui 2026-03-27 16:32:44 +02:00
Vlad Stan 2e82b255e7 feat: ui 2026-03-27 15:22:57 +02:00
Vlad Stan dda3685a81 feat: ui 2026-03-27 15:17:25 +02:00
Vlad Stan 301c588112 feat: better fields 2026-03-27 14:14:26 +02:00
Vlad Stan 3b47db1b35 feat: UI 2026-03-27 14:07:08 +02:00
Vlad Stan a2e5d1507c feat: better ui 2026-03-27 13:54:44 +02:00
Vlad Stan 9f9057cca6 feat: ui 2026-03-27 12:52:49 +02:00
Vlad Stan 3d919aa1a8 feat: UI changs 2026-03-27 12:33:19 +02:00
Vlad Stan 1bedc428a5 feat: ui 2026-03-27 12:17:19 +02:00
Vlad Stan 43abd4ed82 feat: ui 2026-03-27 12:14:02 +02:00
Vlad Stan 742efd7dd9 fat: ui 2026-03-27 12:10:51 +02:00
Vlad Stan 7446dfc29c feat: ui settings 2026-03-27 11:57:40 +02:00
Vlad Stan 396fe72a3f feat: ui 2026-03-27 11:48:15 +02:00
Vlad Stan 77f112cd5e feat: ui 2026-03-27 11:43:31 +02:00
Vlad Stan 0003574d21 feat: nicer mocks 2026-03-26 19:43:51 +02:00
Vlad Stan 22e3ec3672 feat: some changes 2026-03-26 19:30:04 +02:00
Vlad Stan 2e77dcf393 feat: ui 2026-03-26 19:22:15 +02:00
Vlad Stan 0b8b530ca6 feat: nicer UI 2026-03-26 19:13:06 +02:00
Vlad Stan 193217d157 feat: ok 2026-03-26 15:29:03 +02:00
Vlad Stan 33fef98d9e feat: ok 2026-03-26 15:09:40 +02:00
Vlad Stan 8649129156 feat: something 2026-03-26 14:51:35 +02:00
Vlad Stan 427894d106 feat: nicer dropdown 2026-03-26 14:39:00 +02:00
Vlad Stan d5cde46ed6 feat: blaaa 2026-03-26 14:31:30 +02:00
Vlad Stan cc0ea50be0 feat: better 2026-03-26 14:10:10 +02:00
Vlad Stan 633a0d2d2d feat: better UI 2026-03-26 13:29:15 +02:00
Vlad Stan 21c007d2b3 feat: initial commit 2026-03-26 12:48:28 +02:00
131 changed files with 28127 additions and 19350 deletions
+1 -13
View File
@@ -7,20 +7,14 @@
# They are NOT managed by the Admin UI and are not stored in the database.
# === First Install Token ===
# If set the user is required to enter this token on the /first_install page
# if set the user is required to enter this token on the /first_install page
# FIRST_INSTALL_TOKEN="myaccesstoken"
# === Security ===
# When enabled (recommended), auth cookies require HTTPS and SSO will reject insecure HTTP.
AUTH_HTTPS_ONLY=true
# === Logging and Development ===
DEBUG=False
DEBUG_DATABASE=False
BUNDLE_ASSETS=True
# add `?profiler=true` to the url to enable the profiler for that request
PROFILER=False
# logging into LNBITS_DATA_FOLDER/logs/
ENABLE_LOG_TO_FILE=true
@@ -30,10 +24,6 @@ LOG_ROTATION="100 MB"
LOG_RETENTION="3 months"
# for database cleanup commands
# CLEANUP_WALLETS_DAYS=90
# Hard limit for total created users. Set to 0 to disable the limit.
# LNBITS_MAX_USERS=0
# Hard limit for total installed extensions. Set to 0 to disable the limit.
# LNBITS_MAX_EXTENSIONS=0
# === Admin Settings ===
@@ -119,8 +109,6 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
# Toggle the background styling on burger menus / drawers
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
######################################
-3
View File
@@ -69,10 +69,7 @@ jobs:
--onefile \
--name lnbits \
--hidden-import=embit \
--hidden-import=bitstring.bitstore_bitarray \
--collect-all embit \
--collect-all bitstring \
--collect-all bitarray \
--collect-all lnbits \
--collect-all sqlalchemy \
--collect-all breez_sdk \
-1
View File
@@ -6,7 +6,6 @@ __pycache__
*$py.class
.mypy_cache
.vscode
.codex
*-lock.json
.python-version
-1
View File
@@ -1 +0,0 @@
min-release-age=7
-60
View File
@@ -1,60 +0,0 @@
# AGENTS.md - AI Coding Agent Guide for LNbits
This file guides AI coding agents working on LNbits. Keep changes small, verified, and aligned with existing project patterns.
## Core Behavior
- Think before coding. State material assumptions. Ask when ambiguity affects correctness, security, payments, wallets, or data migrations.
- Prefer the simplest implementation that solves the request.
- Make surgical changes. Every changed line should trace back to the task.
- Do not refactor, reformat, rename, or clean adjacent code unless required.
- Remove only dead code or imports created by your own changes.
- Define success criteria for non-trivial work and verify them before reporting done.
## LNbits Architecture
- Keep core lean. Prefer/assess extensions for non-core features.
- Preserve compatibility with existing extensions and wallet backends.
- Follow existing patterns in `lnbits/core`, `lnbits/wallets`, `lnbits/extensions`, and frontend code.
- Use existing CRUD, services, settings, and migration patterns.
- Do not edit generated files, bundled vendor files, or unrelated extension code.
## Security-Sensitive Areas
Be extra cautious with payments, wallet balances, admin routes, keys, LNURL, Bolt11, funding sources, migrations, and authentication.
Do not expose raw stack traces or sensitive values. Do not add synchronous blocking work in hot async paths without justification.
## Commands and Verification
Read `Makefile` before running project commands.
Use Makefile targets instead of hand-written commands when available:
- `make check` for full checks.
- `make test-unit` for unit tests.
- `make test-api` for API tests.
- `make test-wallets` for wallet tests.
- `make checkbundle` when bundled frontend assets may be affected.
- `make format` only when formatting is intended.
Do not run `make test` by default. Use the targeted tests available in the Makefile that are related to the work done, unless the user explicitly asks for broader test coverage.
## Dependencies
Do not add dependencies without approval. If approved, update the correct project files and explain why the dependency is necessary.
## Maintenance
LNbits maintainers own this file. They should update it when the development workflow, architecture, or verification commands materially change.
Do not edit, commit, push, or include changes to this file in a PR as part of normal feature work unless the user explicitly asks for `AGENTS.md` changes.
## Reporting
When finished, report:
- Summary of what changed.
- Files touched.
- Makefile targets or checks run.
- Anything not verified and why.
-1
View File
@@ -66,7 +66,6 @@ test-regtest:
LNBITS_DATA_FOLDER="./tests/data" \
PYTHONUNBUFFERED=1 \
DEBUG=true \
rm -rf ./tests/data \
uv run pytest tests/regtest
test-migration:
+11 -11
View File
@@ -1,12 +1,12 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/logos/lnbits-full-inverse.svg">
<img src="docs/logos/lnbits-full.svg" alt="LNbits" style="width:300px">
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:300px">
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img width="2000" height="203" alt="lnbits_head" src="https://github.com/user-attachments/assets/77669718-ac10-43c7-ae95-6ce236c77401" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
# LNbits — The most powerful Bitcoin & Lightning toolkit
@@ -46,7 +46,7 @@ Get yourself familiar and test on our demo server [demo.lnbits.com](https://demo
LNbits is packaged with tools to help manage funds, such as a table of transactions, line chart of spending, export to csv. Each wallet also comes with its own API keys, to help partition the exposure of your funding source.
<img alt="lnbits_wallet" src="docs/assets/wallet.jpg" />
<img src="https://i.imgur.com/w8jdGpF.png" style="width:800px">
## LNbits extension universe
@@ -54,25 +54,25 @@ Extend YOUR LNbits to meet YOUR needs.
All non-core features are installed as extensions, reducing your code base and making your LNbits unique to you. Extend your LNbits install in any direction, and even create and share your own extensions.
<img alt="lnbits_extensions" src="docs/assets/extensions.jpg" />
<img src="https://i.imgur.com/aEBpwJF.png" style="width:800px">
## LNbits API
LNbits has a powerful API, many projects use LNbits to do the heavy lifting for their bitcoin/lightning services.
<img alt="lnbits_api" src="docs/assets/api.jpg" />
<img src="https://i.imgur.com/V742sb9.png" style="width:800px">
## LNbits node manager
LNbits comes packaged with a light node management UI, to make running your node that much easier.
<img alt="lnbits_api" src="docs/assets/lightning_node.jpg" />
<img src="https://i.imgur.com/TYqIK60.png" style="width:800px">
## LNbits merchant tools
## LNbits across all your devices
The LNbits stack can process both bitcoin and fiat payments, making it a turnkey, all-in-one solution for merchants. With orders and inventory shared across extensions, and built-in notifications for Nostr, Telegram, and email, LNbits keeps everything in sync, freeing merchants to focus on their business.
As well as working great in a browser, LNbits has native IoS and Android apps as well as a chrome extension. So you can enjoy the same UI across ALL your devices.
<img alt="lnbits_merchants" src="docs/assets/merchants_small.webp" />
<img src="https://i.imgur.com/J96EbRf.png" style="width:800px">
## Powered by LNbits
Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
+1
View File
@@ -14,6 +14,7 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets
-4
View File
@@ -65,7 +65,6 @@ from .middleware import (
InstalledExtensionMiddleware,
add_first_install_middleware,
add_ip_block_middleware,
add_profiler_middleware,
add_ratelimit_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -197,9 +196,6 @@ def create_app() -> FastAPI:
register_exception_handlers(app)
if settings.profiler:
add_profiler_middleware(app)
return app
-4
View File
@@ -12,7 +12,6 @@ from .extensions import (
drop_extension_db,
get_installed_extension,
get_installed_extensions,
get_installed_extensions_count,
get_user_active_extensions_ids,
get_user_extension,
get_user_extensions,
@@ -59,7 +58,6 @@ from .users import (
get_account_by_username,
get_account_by_username_or_email,
get_accounts,
get_accounts_count,
get_user,
get_user_access_control_lists,
get_user_from_account,
@@ -119,13 +117,11 @@ __all__ = [
"get_account_by_username",
"get_account_by_username_or_email",
"get_accounts",
"get_accounts_count",
"get_admin_settings",
"get_db_version",
"get_db_versions",
"get_installed_extension",
"get_installed_extensions",
"get_installed_extensions_count",
"get_latest_payments_by_extension",
"get_payment",
"get_payments",
-7
View File
@@ -90,13 +90,6 @@ async def get_installed_extensions(
return all_extensions
async def get_installed_extensions_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM installed_extensions"
)
return int(row["count"]) if row else 0
async def get_user_extension(
user_id: str, extension: str, conn: Connection | None = None
) -> UserExtension | None:
-1
View File
@@ -292,7 +292,6 @@ async def create_payment(
tag=extra.get("tag", None),
extra=extra,
labels=data.labels or [],
external_id=data.external_id,
)
await (conn or db).insert("apipayments", payment)
-7
View File
@@ -37,13 +37,6 @@ async def create_account(
return account
async def get_accounts_count(conn: Connection | None = None) -> int:
row: dict | None = await (conn or db).fetchone(
"SELECT COUNT(*) as count FROM accounts"
)
return int(row["count"]) if row else 0
async def update_account(account: Account, conn: Connection | None = None) -> Account:
account.updated_at = datetime.now(timezone.utc)
await (conn or db).update("accounts", account)
-13
View File
@@ -802,16 +802,3 @@ async def m044_add_activated_to_accounts(db: Connection):
Used for account activation status.
"""
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
async def m045_add_external_id_to_payments(db: Connection):
"""
Adds external_id column to apipayments.
Used for external payment references.
"""
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
logger.debug("Creating index idx_payments_external_id...")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_payments_external_id
ON apipayments (external_id);
""")
-28
View File
@@ -13,7 +13,6 @@ from lnbits.db import FilterModel
from lnbits.fiat.base import (
FiatPaymentStatus,
)
from lnbits.helpers import is_valid_external_id
from lnbits.utils.exchange_rates import allowed_currencies
from lnbits.wallets.base import (
PaymentStatus,
@@ -54,11 +53,6 @@ class CreatePayment(BaseModel):
webhook: str | None = None
fee: int = 0
labels: list[str] | None = None
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class Payment(BaseModel):
@@ -83,11 +77,6 @@ class Payment(BaseModel):
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
labels: list[str] = []
extra: dict = {}
external_id: str | None = None
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
def __init__(self, **data):
super().__init__(**data)
@@ -162,7 +151,6 @@ class PaymentFilters(FilterModel):
"status",
"time",
"labels",
"external_id",
]
__sort_fields__ = [
@@ -173,13 +161,11 @@ class PaymentFilters(FilterModel):
"memo",
"time",
"tag",
"external_id",
]
status: str | None
tag: str | None
checking_id: str | None
external_id: str | None
amount: int
fee: int
memo: str | None
@@ -263,7 +249,6 @@ class CreateInvoice(BaseModel):
lnurl_withdraw: LnurlWithdrawResponse | None = None
fiat_provider: str | None = None
labels: list[str] = []
external_id: str | None = Query(default=None, max_length=256)
@validator("payment_hash")
def check_hex(cls, v):
@@ -278,10 +263,6 @@ class CreateInvoice(BaseModel):
raise ValueError("The provided unit is not supported")
return v
@validator("external_id")
def validate_external_id(cls, external_id):
return _validate_external_id(external_id)
class PaymentsStatusCount(BaseModel):
incoming: int = 0
@@ -320,12 +301,3 @@ class CancelInvoice(BaseModel):
class UpdatePaymentLabels(BaseModel):
labels: list[str] = []
def _validate_external_id(external_id: str | None) -> str | None:
if external_id and not is_valid_external_id(external_id):
raise ValueError(
"Invalid external id. Max length is 256 characters. "
"Space and newlines are not allowed."
)
return external_id
+2 -4
View File
@@ -1,6 +1,4 @@
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from pydantic import BaseModel
class TinyURL(BaseModel):
@@ -8,4 +6,4 @@ class TinyURL(BaseModel):
url: str
endless: bool
wallet: str
time: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
time: float
+3 -127
View File
@@ -1,9 +1,7 @@
import base64
import io
from urllib.parse import quote
from uuid import uuid4
import filetype
from fastapi import UploadFile
from loguru import logger
from PIL import Image
@@ -12,48 +10,11 @@ from lnbits.core.crud.assets import create_asset, get_user_assets_count
from lnbits.core.models.assets import Asset
from lnbits.settings import settings
IMAGE_MIME_TYPE_ALIASES = {
"heic": "image/heic",
"heics": "image/heics",
"heif": "image/heif",
"image/jpg": "image/jpeg",
"jpeg": "image/jpeg",
"jpg": "image/jpeg",
"png": "image/png",
}
PIL_IMAGE_FORMAT_MIME_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
}
INLINE_ASSET_MIME_TYPES = {
"image/heic",
"image/heics",
"image/heif",
"image/jpeg",
"image/png",
}
ASSET_SECURITY_HEADERS = {
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": (
"sandbox; default-src 'none'; script-src 'none'; "
"object-src 'none'; base-uri 'none'"
),
}
THUMBNAIL_FORMAT_MIME_TYPES = {
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
}
async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) -> Asset:
if not file.content_type:
raise ValueError("File must have a content type.")
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
@@ -69,26 +30,14 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
stored_mime_type = detect_image_mime_type(contents)
if stored_mime_type != content_type:
logger.warning(
"Image MIME type mismatch: declared={}, detected={}",
content_type,
stored_mime_type,
)
raise ValueError(
"Image file content does not match declared file type. "
f"Declared: '{content_type}', detected: '{stored_mime_type}'."
)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
user_id=user_id,
mime_type=stored_mime_type,
mime_type=file.content_type,
is_public=is_public,
name=filename,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -102,79 +51,6 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
return asset
def normalize_asset_mime_type(content_type: str) -> str:
content_type = content_type.split(";", 1)[0].strip().lower()
return IMAGE_MIME_TYPE_ALIASES.get(content_type, content_type)
def normalize_media_type(media_type: str) -> str:
return media_type.split(";", 1)[0].strip().lower() or "application/octet-stream"
def thumbnail_media_type() -> str:
thumbnail_format = (settings.lnbits_asset_thumbnail_format or "png").strip().lower()
return THUMBNAIL_FORMAT_MIME_TYPES.get(thumbnail_format, "application/octet-stream")
def content_disposition(disposition: str, filename: str) -> str:
safe_filename = filename or "unnamed"
quoted_filename = quote(safe_filename, safe="")
if quoted_filename == safe_filename:
return f'{disposition}; filename="{safe_filename}"'
return f"{disposition}; filename*=utf-8''{quoted_filename}"
def allowed_asset_mime_types() -> set[str]:
return {
mime_type
for mime_type in (
normalize_asset_mime_type(mime_type)
for mime_type in settings.lnbits_assets_allowed_mime_types
)
if mime_type.startswith("image/")
}
def detect_image_mime_type(contents: bytes) -> str:
kind = filetype.guess(contents)
mime_type = normalize_asset_mime_type(kind.mime) if kind else None
if mime_type and mime_type in PIL_IMAGE_FORMAT_MIME_TYPES.values():
verify_pil_image(contents, mime_type)
return mime_type
if mime_type and mime_type.startswith("image/"):
return mime_type
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if not mime_type:
raise ValueError("Image file content does not match declared file type.")
return mime_type
def verify_pil_image(contents: bytes, mime_type: str) -> None:
try:
with Image.open(io.BytesIO(contents)) as image:
image.verify()
detected_mime_type = PIL_IMAGE_FORMAT_MIME_TYPES.get(image.format or "")
except Exception as exc:
raise ValueError(
"Image file content does not match declared file type."
) from exc
if detected_mime_type != mime_type:
raise ValueError("Image file content does not match declared file type.")
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
image = Image.open(io.BytesIO(contents))
-12
View File
@@ -9,7 +9,6 @@ from lnbits.core.crud import (
delete_installed_extension,
get_db_version,
get_installed_extension,
get_installed_extensions_count,
update_installed_extension_state,
)
from lnbits.core.crud.extensions import (
@@ -39,8 +38,6 @@ async def install_extension(
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
await check_extensions_limit(installed_ext)
if not skip_download:
await ext_info.download_archive()
@@ -66,15 +63,6 @@ async def install_extension(
return extension
async def check_extensions_limit(installed_ext: InstallableExtension | None = None):
if settings.lnbits_max_extensions == 0 or installed_ext:
return
extensions_count = await get_installed_extensions_count()
if extensions_count >= settings.lnbits_max_extensions:
raise ValueError("Max amount of extensions have been installed")
async def uninstall_extension(ext_id: str):
await stop_extension_background_work(ext_id)
-77
View File
@@ -2,7 +2,6 @@ import hashlib
import hmac
import json
import time
from base64 import b64encode
import httpx
from loguru import logger
@@ -170,82 +169,6 @@ async def verify_paypal_webhook(headers, payload: bytes):
raise ValueError("PayPal webhook cannot be verified.") from exc
def check_square_signature(
payload: bytes,
sig_header: str | None,
secret: str | None,
notification_url: str | None,
):
if not sig_header:
logger.warning("Square signature header is missing.")
raise ValueError("Square signature header is missing.")
if not secret:
logger.warning("Square webhook signature key is not set.")
raise ValueError("Square webhook cannot be verified.")
if not notification_url:
logger.warning("Square webhook notification URL is not set.")
raise ValueError("Square webhook cannot be verified.")
signed_payload = notification_url.encode() + payload
computed_signature = b64encode(
hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).digest()
).decode()
if hmac.compare_digest(computed_signature, sig_header) is not True:
logger.warning("Square signature verification failed.")
raise ValueError("Square signature verification failed.")
def check_revolut_signature(
payload: bytes,
sig_header: str | None,
timestamp_header: str | None,
secret: str | None,
tolerance_seconds=300,
):
if not sig_header:
logger.warning("Revolut signature header is missing.")
raise ValueError("Revolut signature header is missing.")
if not timestamp_header:
logger.warning("Revolut timestamp header is missing.")
raise ValueError("Revolut timestamp header is missing.")
if not secret:
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("Invalid Revolut timestamp.") from exc
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
logger.warning("Timestamp outside tolerance.")
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
digest = hmac.new(
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"v1={digest}"
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
if not any(
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
raise ValueError("Revolut signature verification failed.")
async def test_connection(provider: str) -> SimpleStatus:
"""
Test the connection to Stripe by checking if the API key is valid.
+1 -37
View File
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
from lnbits.core.db import db
from lnbits.core.models import PaymentDailyStats, PaymentFilters
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.db import Connection, Filters
from lnbits.decorators import check_user_extension_access
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
from lnbits.fiat import get_fiat_provider
from lnbits.helpers import check_callback_url
from lnbits.settings import settings
from lnbits.tasks import create_task, internal_invoice_queue_put
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
from lnbits.wallets import fake_wallet, get_funding_source
@@ -64,7 +64,6 @@ async def pay_invoice(
description: str = "",
tag: str = "",
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if settings.lnbits_only_allow_incoming_payments:
@@ -98,7 +97,6 @@ async def pay_invoice(
memo=description or invoice.description or "",
extra=extra,
labels=labels,
external_id=external_id,
)
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
@@ -219,7 +217,6 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
internal=data.internal,
payment_hash=data.payment_hash,
labels=data.labels,
external_id=data.external_id,
conn=conn,
)
@@ -261,7 +258,6 @@ async def create_invoice(
internal: bool | None = False,
payment_hash: str | None = None,
labels: list[str] | None = None,
external_id: str | None = None,
conn: Connection | None = None,
) -> Payment:
if not amount > 0:
@@ -346,7 +342,6 @@ async def create_invoice(
webhook=webhook,
fee=invoice_response.fee_msat or 0,
labels=labels,
external_id=external_id,
)
payment = await create_payment(
@@ -514,8 +509,6 @@ async def update_wallet_balance(
)
payment.status = PaymentState.SUCCESS
await update_payment(payment, conn=conn)
from lnbits.tasks import internal_invoice_queue_put
await internal_invoice_queue_put(payment.checking_id)
@@ -826,8 +819,6 @@ async def _pay_external_invoice(
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
from lnbits.tasks import create_task
task = create_task(
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
)
@@ -1077,30 +1068,3 @@ async def _send_payment_notification_in_background(
if not wallet:
raise PaymentError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
send_payment_notification_in_background(wallet, payment)
async def update_invoice_callback(checking_id: str) -> Payment | None:
"""
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
or internal_invoice_queue. Checks its status, updates and returns it.
returns None if no payment was found or it not and incoming payment.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return None
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return None
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
-12
View File
@@ -23,7 +23,6 @@ from ..crud import (
get_account_by_email,
get_account_by_pubkey,
get_account_by_username,
get_accounts_count,
get_super_settings,
get_user_extensions,
get_user_from_account,
@@ -56,8 +55,6 @@ async def create_user_account_no_ckeck(
conn: Connection | None = None,
) -> User:
async with db.reuse_conn(conn) if conn else db.connect() as conn:
await check_users_limit(conn)
if account:
account.validate_fields()
if account.username and await get_account_by_username(
@@ -98,15 +95,6 @@ async def create_user_account_no_ckeck(
return user
async def check_users_limit(conn: Connection | None = None):
if settings.lnbits_max_users == 0:
return
users_count = await get_accounts_count(conn=conn)
if users_count >= settings.lnbits_max_users:
raise ValueError("Max amount of users have been created")
async def update_user_account(account: Account) -> Account:
account.validate_fields()
+9 -25
View File
@@ -16,14 +16,7 @@ from lnbits.core.crud.assets import (
from lnbits.core.models.assets import AssetFilters, AssetInfo, AssetUpdate
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.users import AccountId
from lnbits.core.services.assets import (
ASSET_SECURITY_HEADERS,
INLINE_ASSET_MIME_TYPES,
content_disposition,
create_user_asset,
normalize_media_type,
thumbnail_media_type,
)
from lnbits.core.services.assets import create_user_asset
from lnbits.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
@@ -82,7 +75,11 @@ async def api_get_asset_data(
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(asset.data, asset.mime_type, asset.name)
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
@asset_router.get(
@@ -104,14 +101,14 @@ async def api_get_asset_thumbnail(
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return asset_response(
return Response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=thumbnail_media_type(),
filename=asset_info.name,
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
)
@@ -175,16 +172,3 @@ async def api_delete_asset(
await delete_user_asset(account_id.id, asset_id)
return SimpleStatus(success=True, message="Asset deleted successfully.")
def asset_response(content: bytes, media_type: str, filename: str) -> Response:
media_type = normalize_media_type(media_type)
disposition = "inline" if media_type in INLINE_ASSET_MIME_TYPES else "attachment"
return Response(
content=content,
media_type=media_type,
headers={
**ASSET_SECURITY_HEADERS,
"Content-Disposition": content_disposition(disposition, filename),
},
)
+1 -5
View File
@@ -348,11 +348,7 @@ async def handle_oauth_token(request: Request, provider: str) -> RedirectRespons
userinfo = await provider_sso.verify_and_process(request)
if not userinfo:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "Invalid user info.")
if provider_sso.state is None or provider_sso.state == "null":
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
+1 -432
View File
@@ -4,30 +4,17 @@ from fastapi import APIRouter, Request
from loguru import logger
from lnbits.core.crud.payments import (
get_payments,
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentFilters
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.payments import CreateInvoice
from lnbits.core.services.fiat_providers import (
check_fiat_status,
check_revolut_signature,
check_square_signature,
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import (
create_fiat_invoice,
create_wallet_invoice,
service_fee_fiat,
)
from lnbits.db import Filter, Filters
from lnbits.fiat import get_fiat_provider
from lnbits.core.services.payments import create_fiat_invoice
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import settings
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
@@ -63,41 +50,6 @@ async def api_generic_webhook_handler(
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "square":
payload = await request.body()
sig_header = request.headers.get("x-square-hmacsha256-signature")
check_square_signature(
payload,
sig_header,
settings.square_webhook_signature_key,
settings.square_payment_webhook_url,
)
event = await request.json()
await handle_square_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
if provider_name.lower() == "revolut":
payload = await request.body()
sig_header = request.headers.get("Revolut-Signature")
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
check_revolut_signature(
payload,
sig_header,
timestamp_header,
settings.revolut_webhook_signing_secret,
)
event = await request.json()
await handle_revolut_event(event)
return SimpleStatus(
success=True,
message=f"Callback received successfully from '{provider_name}'.",
)
return SimpleStatus(
success=False,
message=f"Unknown fiat provider '{provider_name}'.",
@@ -328,386 +280,3 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
except (json.JSONDecodeError, IndexError) as e:
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
return FiatSubscriptionPaymentOptions()
async def handle_square_event(event: dict):
event_id = event.get("event_id") or event.get("id", "")
event_type = event.get("type", "")
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
if event_type == "payment.updated":
await _handle_square_payment_event(event)
return
if event_type == "invoice.payment_made":
await _handle_square_invoice_payment_made(event)
return
logger.warning(f"Unhandled Square event type: '{event_type}'.")
async def handle_revolut_event(event: dict):
event_type = event.get("event", "")
order_id = event.get("order_id")
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
if not order_id:
logger.warning("Revolut event missing order_id.")
return
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if not payment:
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
await _handle_revolut_subscription_order_paid(order_id)
return
await check_fiat_status(payment)
return
if event_type == "SUBSCRIPTION_INITIATED":
await _handle_revolut_subscription_initiated(event)
return
if event_type in [
"SUBSCRIPTION_CANCELLED",
"SUBSCRIPTION_FINISHED",
"SUBSCRIPTION_OVERDUE",
]:
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
return
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
async def _handle_revolut_subscription_initiated(event: dict):
subscription_id = event.get("subscription_id")
if not subscription_id:
subscription_id = event.get("id")
if not subscription_id:
logger.warning("Revolut subscription event missing subscription_id.")
return
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
subscription = await fiat_provider.get_subscription(subscription_id)
await _handle_revolut_subscription(subscription, fiat_provider)
async def _get_revolut_provider() -> RevolutWallet | None:
fiat_provider = await get_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
return None
return fiat_provider
async def _handle_revolut_subscription(
subscription: dict, fiat_provider: RevolutWallet
):
subscription_id = subscription.get("id")
if not subscription_id:
logger.warning("Revolut subscription missing id.")
return
reference = fiat_provider.deserialize_subscription_reference(
subscription.get("external_reference")
)
if not reference:
logger.warning("Revolut subscription event missing LNbits metadata.")
return
cycle_id = subscription.get("current_cycle_id")
if not cycle_id:
logger.warning("Revolut subscription missing current_cycle_id.")
return
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
order_id = cycle.get("order_id")
if not order_id:
logger.warning("Revolut subscription cycle missing order_id.")
return
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
if existing_payment:
if existing_payment.external_id != subscription_id:
existing_payment.external_id = subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
order = await fiat_provider.get_order(order_id)
amount_minor = order.get("amount")
currency = (order.get("currency") or "").upper()
if amount_minor is None or not currency:
raise ValueError("Revolut subscription order missing amount or currency.")
extra = {
**(reference.extra or {}),
"subscription_request_id": reference.subscription_request_id,
"fiat_method": "subscription",
"tag": reference.tag,
"subscription": {
"checking_id": f"order_{order_id}",
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
amount_minor=amount_minor,
currency=currency,
memo=reference.memo or "",
extra=extra,
order_id=order_id,
payment_request=order.get("checkout_url") or "",
subscription_id=subscription_id,
)
await check_fiat_status(lnbits_payment)
async def _handle_revolut_subscription_order_paid(order_id: str):
fiat_provider = await _get_revolut_provider()
if not fiat_provider:
return
order = await fiat_provider.get_order(order_id)
if order.get("type") != "payment" or order.get("state") != "completed":
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
return
channel_data = order.get("channel_data") or {}
subscription_id = channel_data.get("subscription_id")
if not subscription_id:
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
return
subscription = await fiat_provider.get_subscription(subscription_id)
if subscription.get("state") != "active":
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
return
await _handle_revolut_subscription_initiated(subscription)
async def _create_revolut_subscription_payment(
wallet_id: str,
amount_minor: int,
currency: str,
memo: str,
extra: dict,
order_id: str,
payment_request: str,
subscription_id: str,
) -> Payment:
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
payment = await create_wallet_invoice(
wallet_id,
CreateInvoice(
unit=currency,
amount=amount,
memo=memo,
extra=extra,
internal=True,
external_id=subscription_id,
),
)
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
payment.fiat_provider = "revolut"
payment.extra["fiat_checking_id"] = f"order_{order_id}"
payment.extra["fiat_payment_request"] = payment_request
checking_id = f"fiat_revolut_order_{order_id}"
await update_payment(payment, checking_id)
payment.checking_id = checking_id
return payment
async def _handle_square_payment_event(event: dict):
payment = _square_extract_payment(event)
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if payment_options.wallet_id:
if not _square_payment_is_completed(payment):
logger.debug("Square subscription payment is not completed yet.")
return
await _handle_square_subscription_payment(payment, payment_options)
return
order_id = payment.get("order_id")
if not order_id:
logger.warning("Square payment event missing order_id.")
return
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
if not lnbits_payment:
logger.warning(f"No payment found for Square order: '{order_id}'.")
return
await check_fiat_status(lnbits_payment)
async def _handle_square_invoice_payment_made(event: dict):
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
order_id = invoice.get("order_id")
if not order_id:
logger.warning("Square invoice.payment_made event missing order_id.")
return
subscription_id = invoice.get("subscription_id")
fiat_provider = await get_fiat_provider("square")
if not isinstance(fiat_provider, SquareWallet):
logger.warning("Square fiat provider is not configured.")
return
payment = await fiat_provider.get_payment_for_order(order_id)
if not payment:
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
return
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
if not payment_options.wallet_id:
payment_id = payment.get("id")
stored_payment = (
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
if payment_id
else None
)
if not stored_payment and subscription_id:
stored_payments = await get_payments(
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
)
)
stored_payment = stored_payments[0] if stored_payments else None
if stored_payment:
payment_options = _square_payment_options_from_payment(stored_payment)
else:
logger.warning("Square subscription payment missing LNbits metadata.")
return
await _handle_square_subscription_payment(
payment,
payment_options,
invoice.get("public_url") or "",
square_subscription_id=subscription_id,
)
async def _handle_square_subscription_payment(
payment: dict,
payment_options: FiatSubscriptionPaymentOptions,
payment_request: str = "",
square_subscription_id: str | None = None,
):
amount_money = payment.get("amount_money") or {}
amount = amount_money.get("amount")
currency = (amount_money.get("currency") or "").upper()
payment_id = payment.get("id")
if amount is None or not currency or not payment_id:
raise ValueError("Square subscription payment event missing payment amount.")
wallet_id = payment_options.wallet_id
if not wallet_id:
raise ValueError("Square subscription payment event missing wallet_id.")
checking_id = f"payment_{payment_id}"
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
if existing_payment:
if (
square_subscription_id
and existing_payment.external_id != square_subscription_id
):
existing_payment.external_id = square_subscription_id
await update_payment(existing_payment)
await check_fiat_status(existing_payment)
return
square_subscription_id = square_subscription_id or (
payment_options.extra or {}
).get("square_subscription_id")
extra = {
**(payment_options.extra or {}),
"subscription_request_id": payment_options.subscription_request_id,
"fiat_method": "subscription",
"tag": payment_options.tag,
"subscription": {
"checking_id": checking_id,
"payment_request": payment_request,
},
}
lnbits_payment = await create_fiat_invoice(
wallet_id=wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount / 100,
memo=payment_options.memo or "",
extra=extra,
fiat_provider="square",
external_id=square_subscription_id,
),
)
await check_fiat_status(lnbits_payment)
def _square_payment_options_from_payment(
payment: Payment,
) -> FiatSubscriptionPaymentOptions:
extra = payment.extra or {}
return FiatSubscriptionPaymentOptions(
wallet_id=payment.wallet_id,
tag=extra.get("tag") or payment.tag,
subscription_request_id=extra.get("subscription_request_id"),
extra=extra,
memo=payment.memo,
)
def _square_extract_payment(event: dict) -> dict:
event_object = event.get("data", {}).get("object", {})
return event_object.get("payment") or event_object
def _square_payment_is_completed(payment: dict) -> bool:
return (payment.get("status") or "").upper() == "COMPLETED"
def _square_payment_note(payment: dict) -> str:
return payment.get("note") or payment.get("payment_note") or ""
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
try:
meta = json.loads(custom_id)
if not isinstance(meta, list):
return FiatSubscriptionPaymentOptions()
wallet_id = meta[0] if len(meta) > 0 else None
tag = meta[1] if len(meta) > 1 else None
subscription_request_id = meta[2] if len(meta) > 2 else None
extra_link = meta[3] if len(meta) > 3 else None
memo = meta[4] if len(meta) > 4 else None
extra = {
"link": extra_link,
"subscription_request_id": subscription_request_id,
}
return FiatSubscriptionPaymentOptions(
wallet_id=wallet_id,
tag=tag,
subscription_request_id=subscription_request_id,
extra=extra,
memo=memo,
)
except (json.JSONDecodeError, IndexError, TypeError):
return FiatSubscriptionPaymentOptions()
+2 -6
View File
@@ -98,16 +98,12 @@ async def api_install_extension(data: CreateExtension):
ext_info.clean_extension_files()
detail = (
str(exc)
if isinstance(exc, (AssertionError, ValueError))
if isinstance(exc, AssertionError)
else f"Failed to install extension '{ext_info.id}'."
f"({ext_info.installed_version})."
)
raise HTTPException(
status_code=(
HTTPStatus.BAD_REQUEST
if isinstance(exc, (AssertionError, ValueError))
else HTTPStatus.INTERNAL_SERVER_ERROR
),
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=detail,
) from exc
+1 -67
View File
@@ -2,35 +2,17 @@ from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from pydantic import BaseModel
from lnbits.core.crud.settings import set_settings_field
from lnbits.core.models.misc import SimpleStatus
from lnbits.core.models.wallets import WalletTypeInfo
from lnbits.core.services import update_cached_settings
from lnbits.core.services.fiat_providers import test_connection
from lnbits.decorators import check_admin, require_admin_key
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
from lnbits.fiat import StripeWallet, get_fiat_provider
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
class RevolutCreateWebhook(BaseModel):
url: str
endpoint: str | None = None
api_secret_key: str | None = None
api_version: str | None = None
class RevolutCreateWebhookResponse(BaseModel):
id: str | None = None
url: str
events: list[str] = []
signing_secret: str
already_exists: bool = False
@fiat_router.put(
"/check/{provider}",
status_code=HTTPStatus.OK,
@@ -40,54 +22,6 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
return await test_connection(provider)
@fiat_router.post(
"/revolut/webhook",
status_code=HTTPStatus.OK,
dependencies=[Depends(check_admin)],
)
async def api_create_revolut_webhook(
data: RevolutCreateWebhook,
) -> RevolutCreateWebhookResponse:
try:
webhook = await RevolutWallet.create_webhook(
url=data.url,
endpoint=data.endpoint,
api_secret_key=data.api_secret_key,
api_version=data.api_version,
)
except ValueError as exc:
logger.warning(exc)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
logger.warning(exc)
raise HTTPException(
status_code=500, detail="Failed to create Revolut webhook."
) from exc
signing_secret = webhook.get("signing_secret")
webhook_url = webhook.get("url") or data.url
if not signing_secret:
raise HTTPException(
status_code=502, detail="Revolut returned no webhook signing secret."
)
updated_settings = {
"revolut_payment_webhook_url": webhook_url,
"revolut_webhook_signing_secret": signing_secret,
}
for key, value in updated_settings.items():
await set_settings_field(key, value)
update_cached_settings(updated_settings)
return RevolutCreateWebhookResponse(
id=webhook.get("id"),
url=webhook_url,
events=webhook.get("events") or [],
signing_secret=signing_secret,
already_exists=webhook.get("already_exists", False),
)
@fiat_router.post(
"/{provider}/subscription",
status_code=HTTPStatus.OK,
+3 -7
View File
@@ -20,7 +20,7 @@ from lnbits.decorators import (
check_first_install,
check_user_exists,
)
from lnbits.helpers import check_callback_url, extension_id_from_path, template_renderer
from lnbits.helpers import check_callback_url, template_renderer
from lnbits.settings import settings
from ..crud import get_user
@@ -198,9 +198,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(
return template_renderer().TemplateResponse(
request,
"base.html",
{
@@ -213,9 +211,7 @@ async def index(
@generic_router.get("/node/public")
@generic_router.get("/first_install", dependencies=[Depends(check_first_install)])
async def index_public(request: Request) -> HTMLResponse:
return template_renderer(
extension_id=extension_id_from_path(request.url.path)
).TemplateResponse(request, "base.html", {"public": True})
return template_renderer().TemplateResponse(request, "base.html", {"public": True})
@generic_router.get("/uuidv4/{hex_value}")
-1
View File
@@ -263,7 +263,6 @@ async def api_payments_create(
payment_request=invoice_data.bolt11,
extra=invoice_data.extra,
labels=invoice_data.labels,
external_id=invoice_data.external_id,
)
return payment
+2 -4
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' format for the database URL."
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
-6
View File
@@ -9,8 +9,6 @@ from lnbits.fiat.base import FiatProvider
from lnbits.settings import settings
from .paypal import PayPalWallet
from .revolut import RevolutWallet
from .square import SquareWallet
from .stripe import StripeWallet
fiat_module = importlib.import_module("lnbits.fiat")
@@ -19,8 +17,6 @@ fiat_module = importlib.import_module("lnbits.fiat")
class FiatProviderType(Enum):
stripe = "StripeWallet"
paypal = "PayPalWallet"
square = "SquareWallet"
revolut = "RevolutWallet"
async def get_fiat_provider(name: str) -> FiatProvider | None:
@@ -56,7 +52,5 @@ fiat_providers: dict[str, FiatProvider] = {}
__all__ = [
"PayPalWallet",
"RevolutWallet",
"SquareWallet",
"StripeWallet",
]
-4
View File
@@ -95,10 +95,6 @@ class FiatSubscriptionPaymentOptions(BaseModel):
description="Unique ID that can be used to identify the subscription request."
"If not provided, one will be generated.",
)
customer_email: str | None = Field(
default=None,
description="The customer email to use for the subscription.",
)
tag: str | None = Field(
default=None,
description="Payments created by the recurring subscription"
-637
View File
@@ -1,637 +0,0 @@
import asyncio
import ipaddress
import json
from collections.abc import AsyncGenerator
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
class RevolutCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class RevolutCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
checkout: RevolutCheckoutOptions | None = None
class RevolutSubscriptionReference(BaseModel):
wallet_id: str
tag: str | None = None
subscription_request_id: str | None = None
extra: dict[str, Any] | None = None
memo: str | None = None
REVOLUT_WEBHOOK_EVENTS = [
"ORDER_AUTHORISED",
"ORDER_COMPLETED",
"SUBSCRIPTION_INITIATED",
]
ZERO_DECIMAL_CURRENCIES = {
"BIF",
"CLP",
"DJF",
"GNF",
"ISK",
"JPY",
"KMF",
"KRW",
"PYG",
"RWF",
"UGX",
"VND",
"VUV",
"XAF",
"XOF",
"XPF",
}
THREE_DECIMAL_CURRENCIES = {
"BHD",
"IQD",
"JOD",
"KWD",
"LYD",
"OMR",
"TND",
}
REVOLUT_CUSTOMER_LIST_LIMIT = 500
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
REVOLUT_REQUEST_TIMEOUT = 30
class RevolutWallet(FiatProvider):
"""https://developer.revolut.com/docs/merchant"""
def __init__(self):
logger.debug("Initializing RevolutWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.revolut_api_endpoint:
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
if not settings.revolut_api_secret_key:
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
self.headers = {
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
"Revolut-Api-Version": settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("RevolutWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Revolut wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if opts is None:
return FiatInvoiceResponse(
ok=False, error_message="Invalid Revolut options"
)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
)
payload = {
"amount": amount_minor,
"currency": currency.upper(),
"description": checkout.description or memo or "LNbits Invoice",
"redirect_url": success_url,
"metadata": {
**checkout.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
},
}
try:
r = await self.client.post(
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
data = r.json()
order_id = data.get("id")
checkout_url = data.get("checkout_url")
if not order_id or not checkout_url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=checkout_url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if quantity != 1:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions do not support quantity.",
)
wallet_id = payment_options.wallet_id
if not wallet_id:
return FiatSubscriptionResponse(
ok=False, error_message="Wallet ID is required."
)
extra = payment_options.extra or {}
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
reference = RevolutSubscriptionReference(
wallet_id=wallet_id,
tag=payment_options.tag,
subscription_request_id=payment_options.subscription_request_id,
extra=extra,
memo=payment_options.memo,
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
or settings.revolut_payment_success_url
or "https://lnbits.com"
),
}
if extra.get("trial_duration"):
payload["trial_duration"] = extra["trial_duration"]
headers = {
**self.headers,
"Idempotency-Key": payment_options.subscription_request_id,
}
try:
customer_id, customer_error = await self._get_subscription_customer_id(
payment_options
)
if not customer_id:
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
payload["customer_id"] = customer_id
r = await self.client.post(
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
revolut_subscription_id = data.get("id")
setup_order_id = data.get("setup_order_id")
if not revolut_subscription_id or not setup_order_id:
return FiatSubscriptionResponse(
ok=False,
error_message=(
"Server error: missing subscription id or setup order id"
),
)
setup_order = await self.get_order(setup_order_id)
checkout_url = setup_order.get("checkout_url")
if not checkout_url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing setup checkout url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
r = await self.client.post(
f"/api/subscriptions/{subscription_id}/cancel",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Revolut does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
order_id = self._normalize_revolut_id(checking_id)
return self._status_from_order(await self.get_order(order_id))
except Exception as exc:
logger.debug(f"Error getting Revolut invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Revolut does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Revolut does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
def _normalize_revolut_id(self, checking_id: str) -> str:
value = (
checking_id.replace("fiat_revolut_", "", 1)
if checking_id.startswith("fiat_revolut_")
else checking_id
)
return value.replace("order_", "", 1) if value.startswith("order_") else value
async def get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
async def get_subscription_cycle(
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
async def _get_subscription_customer_id(
self, payment_options: FiatSubscriptionPaymentOptions
) -> tuple[str | None, str | None]:
if not payment_options.customer_email:
return (
None,
"Revolut subscriptions require customer_email.",
)
customer = await self._get_customer_by_email(payment_options.customer_email)
customer_id = customer.get("id") if customer else None
if customer_id:
return customer_id, None
customer = await self._create_customer(payment_options.customer_email)
customer_id = customer.get("id")
if not customer_id:
return None, "Server error: missing customer id"
return customer_id, None
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
page_token = None
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
customer_page = await self._list_customers(page_token=page_token)
customer = _find_customer_by_email(customer_page["customers"], email)
if customer:
return customer
page_token = customer_page.get("next_page_token")
if not page_token:
return None
return None
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
if page_token:
params["page_token"] = page_token
r = await self.client.get(
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return _extract_customer_page(r.json())
async def _create_customer(self, email: str) -> dict[str, Any]:
r = await self.client.post(
"/api/customers",
json={"email": email},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
return r.json()
@classmethod
async def create_webhook(
cls,
url: str,
endpoint: str | None = None,
api_secret_key: str | None = None,
api_version: str | None = None,
) -> dict[str, Any]:
if not url:
raise ValueError("Missing Revolut webhook URL.")
cls._validate_webhook_url(url)
if not endpoint and not settings.revolut_api_endpoint:
raise ValueError("Missing Revolut API endpoint.")
if not api_secret_key and not settings.revolut_api_secret_key:
raise ValueError("Missing Revolut API secret key.")
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
secret_key = api_secret_key or settings.revolut_api_secret_key
headers = {
"Authorization": f"Bearer {secret_key}",
"Revolut-Api-Version": api_version or settings.revolut_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
webhooks = await cls._list_webhooks(client)
existing = await cls._get_existing_webhook(client, webhooks, url)
if existing:
existing["already_exists"] = True
return existing
response = await client.post(
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
return response.json()
@classmethod
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
if isinstance(data, dict):
for field in ["webhooks", "data", "items"]:
if isinstance(data.get(field), list):
return data[field]
return []
@classmethod
async def _get_existing_webhook(
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
) -> dict[str, Any] | None:
for webhook in webhooks:
if cls._normalize_webhook_url(webhook.get("url")) != (
cls._normalize_webhook_url(url)
):
continue
webhook_id = webhook.get("id")
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
webhook = response.json()
events = set(webhook.get("events") or [])
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
if missing_events:
raise ValueError(
"A Revolut webhook already exists for this URL, but it is "
f"missing required events: {', '.join(sorted(missing_events))}."
)
if not webhook.get("signing_secret"):
raise ValueError(
"A Revolut webhook already exists for this URL, but Revolut "
"did not return a signing secret."
)
return webhook
return None
@classmethod
def _normalize_webhook_url(cls, url: str | None) -> str:
return (url or "").strip().rstrip("/")
@classmethod
def _validate_webhook_url(cls, url: str) -> None:
parsed = urlparse(url)
hostname = parsed.hostname
if parsed.scheme not in ["http", "https"] or not hostname:
raise ValueError("Revolut webhook URL must be a clearnet URL.")
host = hostname.lower()
if host == "localhost" or host.endswith(".localhost"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
if host.endswith(".local") or host.endswith(".onion"):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
try:
ip = ipaddress.ip_address(host)
except ValueError:
return
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
raise ValueError("Revolut webhook URL must be a clearnet URL.")
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
status = (order.get("state") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELLED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
@classmethod
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
scale = Decimal(10) ** cls.currency_exponent(currency)
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
@classmethod
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
scale = Decimal(10) ** cls.currency_exponent(currency)
return float(Decimal(amount) / scale)
@classmethod
def currency_exponent(cls, currency: str) -> int:
normalized = currency.upper()
if normalized in ZERO_DECIMAL_CURRENCIES:
return 0
if normalized in THREE_DECIMAL_CURRENCIES:
return 3
return 2
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> RevolutCreateInvoiceOptions | None:
try:
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Revolut options: {e}")
return None
def _serialize_subscription_reference(
self, reference: RevolutSubscriptionReference
) -> str:
payload = reference.dict(exclude_none=True)
serialized = json.dumps(payload, separators=(",", ":"))
if len(serialized) > 1024:
raise ValueError("Revolut subscription external_reference is too long.")
return serialized
def deserialize_subscription_reference(
self, external_reference: str | None
) -> RevolutSubscriptionReference | None:
if not external_reference:
return None
try:
return RevolutSubscriptionReference.parse_obj(
json.loads(external_reference)
)
except (json.JSONDecodeError, ValidationError) as exc:
logger.warning(exc)
return None
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.revolut_api_endpoint),
str(settings.revolut_api_secret_key),
str(settings.revolut_api_version),
str(settings.revolut_webhook_signing_secret),
]
)
def _extract_customer_page(data: Any) -> dict[str, Any]:
if isinstance(data, list):
return {"customers": _filter_customer_list(data)}
if isinstance(data, dict):
for field in ["customers", "data", "items"]:
customers = data.get(field)
if isinstance(customers, list):
return {
"customers": _filter_customer_list(customers),
"next_page_token": data.get("next_page_token"),
}
return {"customers": []}
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
return [customer for customer in customers if isinstance(customer, dict)]
def _find_customer_by_email(
customers: list[dict[str, Any]], email: str
) -> dict[str, Any] | None:
normalized_email = email.casefold()
for customer in customers:
if str(customer.get("email") or "").casefold() == normalized_email:
return customer
return None
-620
View File
@@ -1,620 +0,0 @@
import asyncio
import json
from collections.abc import AsyncGenerator
from typing import Any, Literal
import httpx
from loguru import logger
from pydantic import BaseModel, Field, ValidationError
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
from lnbits.settings import settings
from .base import (
FiatInvoiceResponse,
FiatPaymentFailedStatus,
FiatPaymentPendingStatus,
FiatPaymentResponse,
FiatPaymentStatus,
FiatPaymentSuccessStatus,
FiatProvider,
FiatStatusResponse,
FiatSubscriptionPaymentOptions,
FiatSubscriptionResponse,
)
FiatMethod = Literal["checkout", "subscription"]
class SquareCheckoutOptions(BaseModel):
class Config:
extra = "ignore"
success_url: str | None = None
metadata: dict[str, str] = Field(default_factory=dict)
line_item_name: str | None = None
class SquareSubscriptionOptions(BaseModel):
class Config:
extra = "ignore"
checking_id: str | None = None
payment_request: str | None = None
class SquareCreateInvoiceOptions(BaseModel):
class Config:
extra = "ignore"
fiat_method: FiatMethod = "checkout"
checkout: SquareCheckoutOptions | None = None
subscription: SquareSubscriptionOptions | None = None
class SquareSubscriptionCheckoutInfo(BaseModel):
plan_variation_id: str
price_money: dict[str, Any]
class SquareWallet(FiatProvider):
"""https://developer.squareup.com/reference/square"""
def __init__(self):
logger.debug("Initializing SquareWallet")
self._settings_fields = self._settings_connection_fields()
if not settings.square_api_endpoint:
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
if not settings.square_access_token:
raise ValueError("Cannot initialize SquareWallet: missing access token.")
if not settings.square_location_id:
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
self.location_id = settings.square_location_id
self.headers = {
"Authorization": f"Bearer {settings.square_access_token}",
"Square-Version": settings.square_api_version,
"Content-Type": "application/json",
"User-Agent": settings.user_agent,
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
logger.info("SquareWallet initialized.")
async def cleanup(self):
try:
await self.client.aclose()
except RuntimeError as e:
logger.warning(f"Error closing Square wallet connection: {e}")
async def status(
self, only_check_settings: bool | None = False
) -> FiatStatusResponse:
if only_check_settings:
if self._settings_fields != self._settings_connection_fields():
return FiatStatusResponse("Connection settings have changed.", 0)
return FiatStatusResponse(balance=0)
try:
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
except json.JSONDecodeError:
return FiatStatusResponse("Server error: 'invalid json response'", 0)
except Exception as exc:
logger.warning(exc)
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
async def create_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
memo: str | None = None,
extra: dict[str, Any] | None = None,
**kwargs,
) -> FiatInvoiceResponse:
opts = self._parse_create_opts(extra or {})
if not opts:
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
if opts.fiat_method == "subscription":
return self._create_subscription_invoice(opts.subscription)
return await self._create_checkout_invoice(
amount=amount,
payment_hash=payment_hash,
currency=currency,
opts=opts,
memo=memo,
)
async def create_subscription(
self,
subscription_id: str,
quantity: int,
payment_options: FiatSubscriptionPaymentOptions,
**kwargs,
) -> FiatSubscriptionResponse:
if settings.lnbits_running:
return FiatSubscriptionResponse(
ok=False, error_message="Subscription not supported for Square."
)
success_url = (
payment_options.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
payment_options.extra = payment_options.extra or {}
payment_options.extra["subscription_request_id"] = (
payment_options.subscription_request_id
)
try:
checkout_info = await self._get_subscription_checkout_info(subscription_id)
metadata = self._serialize_metadata(payment_options)
payload = {
"idempotency_key": payment_options.subscription_request_id,
"description": metadata,
"quick_pay": {
"name": (payment_options.memo or "LNbits Subscription")[:255],
"price_money": checkout_info.price_money,
"location_id": self.location_id,
},
"checkout_options": {
"redirect_url": success_url,
"subscription_plan_id": checkout_info.plan_variation_id,
},
"payment_note": metadata,
}
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
url = payment_link.get("url")
if not url:
return FiatSubscriptionResponse(
ok=False, error_message="Server error: missing url"
)
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=url,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
async def cancel_subscription(
self,
subscription_id: str,
correlation_id: str,
**kwargs,
) -> FiatSubscriptionResponse:
try:
square_subscription_id = await self._get_square_subscription_id(
subscription_id, correlation_id
)
r = await self.client.post(
f"/v2/subscriptions/{square_subscription_id}/cancel"
)
r.raise_for_status()
return FiatSubscriptionResponse(ok=True)
except Exception as exc:
logger.warning(exc)
return FiatSubscriptionResponse(
ok=False, error_message="Unable to cancel subscription."
)
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
raise NotImplementedError("Square does not support paying invoices directly.")
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
try:
square_id = self._normalize_square_id(checking_id)
if square_id.startswith("payment_"):
payment_id = square_id.replace("payment_", "", 1)
return await self._get_payment_status(payment_id)
order_id = (
square_id.replace("order_", "", 1)
if square_id.startswith("order_")
else square_id
)
return await self._get_order_status(order_id)
except Exception as exc:
logger.debug(f"Error getting Square invoice status: {exc}")
return FiatPaymentPendingStatus()
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
raise NotImplementedError("Square does not support outgoing payments.")
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
logger.warning(
"Square does not support paid invoices stream. Use webhooks instead."
)
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
while settings.lnbits_running:
value = await mock_queue.get()
yield value
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if payment_id:
return await self._get_payment_status(payment_id)
if (order.get("state") or "").upper() == "CANCELED":
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _get_order(self, order_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/orders/{order_id}")
r.raise_for_status()
return r.json().get("order") or {}
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
order = await self._get_order(order_id)
payment_id = self._payment_id_from_order(order)
if not payment_id:
return None
return await self._get_payment(payment_id)
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
tenders = order.get("tenders") or []
for tender in tenders:
payment_id = tender.get("payment_id")
if payment_id:
return payment_id
return None
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
return self._status_from_payment(await self._get_payment(payment_id))
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/payments/{payment_id}")
r.raise_for_status()
return r.json().get("payment") or {}
async def _get_subscription_checkout_info(
self, subscription_plan_id: str
) -> SquareSubscriptionCheckoutInfo:
catalog_object = await self._get_catalog_object(subscription_plan_id)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
return await self._get_plan_checkout_info(catalog_object)
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
price_money = await self._get_subscription_price_money(
catalog_object,
)
plan_variation_id = catalog_object.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
raise ValueError(
"Square subscription ID must be a plan ID or plan variation ID."
)
async def _get_plan_checkout_info(
self, catalog_object: dict[str, Any]
) -> SquareSubscriptionCheckoutInfo:
plan_data = catalog_object.get("subscription_plan_data") or {}
plan_variations = plan_data.get("subscription_plan_variations") or []
eligible_item_ids = plan_data.get("eligible_item_ids") or []
plan_variation = next(
(
variation
for variation in plan_variations
if not variation.get("is_deleted")
),
None,
)
if not plan_variation:
raise ValueError("Square subscription plan is missing a variation.")
price_money = await self._get_subscription_price_money(
plan_variation,
eligible_item_ids=eligible_item_ids,
)
plan_variation_id = plan_variation.get("id")
if not plan_variation_id:
raise ValueError("Square subscription plan variation is missing an ID.")
return SquareSubscriptionCheckoutInfo(
plan_variation_id=plan_variation_id,
price_money=price_money,
)
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
r = await self.client.get(f"/v2/catalog/object/{object_id}")
r.raise_for_status()
return r.json().get("object") or {}
async def _get_subscription_price_money(
self,
plan_variation: dict[str, Any],
eligible_item_ids: list[str] | None = None,
) -> dict[str, Any]:
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
phases = variation_data.get("phases") or []
for phase in phases:
pricing = phase.get("pricing") or {}
price_money = pricing.get("price_money") or phase.get(
"recurring_price_money"
)
parsed_price_money = self._parse_price_money(price_money)
if parsed_price_money:
return parsed_price_money
if pricing.get("type") == "RELATIVE":
return await self._get_relative_subscription_price_money(
eligible_item_ids or []
)
raise ValueError("Square subscription plan variation is missing price_money.")
async def _get_relative_subscription_price_money(
self, eligible_item_ids: list[str]
) -> dict[str, Any]:
if len(eligible_item_ids) != 1:
raise ValueError(
"Square relative subscription plan must have exactly one item."
)
item = await self._get_catalog_object(eligible_item_ids[0])
item_variations: list[dict[str, Any]] = []
if item.get("type") == "ITEM":
item_variations = (item.get("item_data") or {}).get("variations") or []
elif item.get("type") == "ITEM_VARIATION":
item_variations = [item]
item_variation = next(
(
variation
for variation in item_variations
if not variation.get("is_deleted")
),
None,
)
if not item_variation:
raise ValueError("Square subscription item is missing a variation.")
price_money = self._parse_price_money(
(item_variation.get("item_variation_data") or {}).get("price_money")
)
if price_money:
return price_money
raise ValueError("Square subscription item variation is missing price_money.")
def _parse_price_money(
self, price_money: dict[str, Any] | None
) -> dict[str, Any] | None:
if (
price_money
and price_money.get("amount") is not None
and price_money.get("currency")
):
return {
"amount": int(price_money["amount"]),
"currency": price_money["currency"].upper(),
}
return None
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
status = (payment.get("status") or "").upper()
if status == "COMPLETED":
return FiatPaymentSuccessStatus()
if status in ["CANCELED", "FAILED"]:
return FiatPaymentFailedStatus()
return FiatPaymentPendingStatus()
async def _create_checkout_invoice(
self,
amount: float,
payment_hash: str,
currency: str,
opts: SquareCreateInvoiceOptions,
memo: str | None = None,
) -> FiatInvoiceResponse:
amount_cents = int(amount * 100)
co = opts.checkout or SquareCheckoutOptions()
success_url = (
co.success_url
or settings.square_payment_success_url
or "https://lnbits.com"
)
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
metadata = {
**co.metadata,
"payment_hash": payment_hash,
"alan_action": "invoice",
}
payload = {
"idempotency_key": payment_hash,
"order": {
"location_id": self.location_id,
"metadata": metadata,
"line_items": [
{
"name": line_item_name,
"quantity": "1",
"base_price_money": {
"amount": amount_cents,
"currency": currency.upper(),
},
}
],
},
"checkout_options": {"redirect_url": success_url},
}
if memo:
payload["payment_note"] = memo[:500]
try:
r = await self.client.post(
"/v2/online-checkout/payment-links", json=payload
)
r.raise_for_status()
data = r.json()
payment_link = data.get("payment_link") or {}
order_id = payment_link.get("order_id")
url = payment_link.get("url")
if not order_id or not url:
return FiatInvoiceResponse(
ok=False, error_message="Server error: missing order id or url"
)
return FiatInvoiceResponse(
ok=True,
checking_id=f"order_{order_id}",
payment_request=url,
)
except json.JSONDecodeError:
return FiatInvoiceResponse(
ok=False, error_message="Server error: invalid json response"
)
except Exception as exc:
logger.warning(exc)
return FiatInvoiceResponse(
ok=False, error_message=f"Unable to connect to {self.endpoint}."
)
def _create_subscription_invoice(
self, opts: SquareSubscriptionOptions | None
) -> FiatInvoiceResponse:
term = opts or SquareSubscriptionOptions()
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
return FiatInvoiceResponse(
ok=True,
checking_id=checking_id,
payment_request=term.payment_request or "",
)
def _normalize_square_id(self, checking_id: str) -> str:
return (
checking_id.replace("fiat_square_", "", 1)
if checking_id.startswith("fiat_square_")
else checking_id
)
def _parse_create_opts(
self, raw_opts: dict[str, Any]
) -> SquareCreateInvoiceOptions | None:
try:
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
except ValidationError as e:
logger.warning(f"Invalid Square options: {e}")
return None
def _serialize_metadata(
self, payment_options: FiatSubscriptionPaymentOptions
) -> str:
extra_link = None
if payment_options.extra:
raw_link = payment_options.extra.get("link")
extra_link = str(raw_link)[:200] if raw_link else None
meta = [
payment_options.wallet_id,
payment_options.tag,
payment_options.subscription_request_id,
extra_link,
]
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
if memo_limit > 0 and payment_options.memo:
meta.append(payment_options.memo[:memo_limit])
else:
meta.append(None)
metadata = json.dumps(meta, separators=(",", ":"))
if len(metadata) > 500:
raise ValueError("Square subscription metadata is too long.")
return metadata
async def _get_square_subscription_id(
self, subscription_id: str, wallet_id: str
) -> str:
try:
from lnbits.core.crud.payments import get_payments
from lnbits.core.models import PaymentFilters
from lnbits.db import Filter, Filters
payments = await get_payments(
wallet_id=wallet_id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", [subscription_id], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
limit=1,
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id and payment.fiat_provider == "square"
),
None,
)
if payment and payment.external_id:
return payment.external_id
payments = await get_payments(
wallet_id=wallet_id,
incoming=True,
filters=Filters(
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
payment = next(
(
payment
for payment in payments
if payment.external_id
and payment.fiat_provider == "square"
and (payment.extra or {}).get("subscription_request_id")
== subscription_id
),
None,
)
if payment and payment.external_id:
return payment.external_id
except Exception as exc:
logger.warning(exc)
return subscription_id
def _settings_connection_fields(self) -> str:
return "-".join(
[
str(settings.square_api_endpoint),
str(settings.square_access_token),
str(settings.square_location_id),
str(settings.square_api_version),
]
)
+1 -47
View File
@@ -52,45 +52,7 @@ def static_url_for(static: str, path: str) -> str:
return f"/{static}/{path}?v={settings.server_startup_time}"
def extension_id_from_path(path: str) -> str | None:
parts = [part for part in path.split("/") if part]
if not parts:
return None
if len(parts) >= 3 and parts[0] == "upgrades":
return parts[2]
ext_id = parts[0]
ext_i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "static", "i18n"
)
if ext_i18n_dir.is_dir():
return ext_id
return None
def extension_i18n_urls(extension_id: str | None) -> list[str]:
if not extension_id:
return []
i18n_dir = Path(
settings.lnbits_extensions_path, "extensions", extension_id, "static", "i18n"
)
if not i18n_dir.is_dir():
return []
files = [file.name for file in i18n_dir.glob("*.js") if file.is_file()]
return [
static_url_for(f"{extension_id}/static", f"i18n/{filename}")
for filename in sorted(files, key=lambda name: (name != "en.js", name))
]
def template_renderer(
additional_folders: list | None = None,
extension_id: str | None = None,
) -> Jinja2Templates:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = [
"lnbits/templates",
settings.extension_builder_working_dir_path.as_posix(),
@@ -124,14 +86,6 @@ def template_renderer(
t.env.globals["INCLUDED_CSS"] = vendor_files["css"]
t.env.globals["INCLUDED_COMPONENTS"] = vendor_files["components"]
if not extension_id and additional_folders:
for folder in additional_folders:
parts = Path(folder).parts
if parts and parts[-1] == "templates" and len(parts) >= 2:
extension_id = parts[-2]
break
t.env.globals["INCLUDED_EXTENSION_I18N"] = extension_i18n_urls(extension_id)
# backwards compatibility for extensions (tpos)
t.env.globals["LNBITS_DENOMINATION"] = settings.lnbits_denomination
-14
View File
@@ -7,7 +7,6 @@ from typing import Any
from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from loguru import logger
from pyinstrument import Profiler
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
@@ -247,16 +246,3 @@ def add_first_install_middleware(app: FastAPI):
):
return RedirectResponse("/first_install")
return await call_next(request)
def add_profiler_middleware(app: FastAPI):
@app.middleware("http")
async def profile_middleware(request: Request, call_next):
profiling = request.query_params.get("profiler", False)
if profiling:
profiler = Profiler(async_mode="enabled")
profiler.start()
_ = await call_next(request)
profiler.stop()
return HTMLResponse(profiler.output_html())
return await call_next(request)
+7 -60
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.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://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
@@ -300,7 +300,6 @@ class ThemesSettings(LNbitsSettings):
lnbits_default_card_rounded: bool = Field(default=True)
lnbits_default_card_gradient: bool = Field(default=True)
lnbits_default_card_shadow: bool = Field(default=False)
lnbits_default_burger_menu_background: bool = Field(default=True)
class OpsSettings(LNbitsSettings):
@@ -324,6 +323,10 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -585,8 +588,6 @@ class ZBDFundingSource(LNbitsSettings):
class PhoenixdFundingSource(LNbitsSettings):
phoenixd_api_endpoint: str | None = Field(default="http://localhost:9740/")
phoenixd_api_password: str | None = Field(default=None)
phoenixd_data_dir: str | None = Field(default=None)
phoenixd_mnemonic: str | None = Field(default=None)
class AlbyFundingSource(LNbitsSettings):
@@ -699,35 +700,6 @@ class PayPalFiatProvider(LNbitsSettings):
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class SquareFiatProvider(LNbitsSettings):
square_enabled: bool = Field(default=False)
square_api_endpoint: str = Field(default="https://connect.squareup.com")
square_access_token: str | None = Field(default=None)
square_location_id: str | None = Field(default=None)
square_api_version: str = Field(default="2026-01-22")
square_payment_success_url: str = Field(default="https://lnbits.com")
square_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
)
square_webhook_signature_key: str | None = Field(default=None)
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class RevolutFiatProvider(LNbitsSettings):
revolut_enabled: bool = Field(default=False)
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
revolut_api_secret_key: str | None = Field(default=None)
revolut_api_version: str = Field(default="2026-04-20")
revolut_payment_success_url: str = Field(default="https://lnbits.com")
revolut_payment_webhook_url: str = Field(
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
)
revolut_webhook_signing_secret: str | None = Field(default=None)
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
class LightningSettings(LNbitsSettings):
lightning_invoice_expiry: int = Field(default=3600, gt=0)
@@ -765,12 +737,7 @@ class FundingSourcesSettings(
funding_source_max_retries: int = Field(default=4, ge=0)
class FiatProvidersSettings(
StripeFiatProvider,
PayPalFiatProvider,
SquareFiatProvider,
RevolutFiatProvider,
):
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
"""
Checks if a specific fiat provider is enabled.
@@ -781,10 +748,6 @@ class FiatProvidersSettings(
return self.stripe_enabled
if provider == "paypal":
return self.paypal_enabled
if provider == "square":
return self.square_enabled
if provider == "revolut":
return self.revolut_enabled
return False
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
@@ -804,18 +767,6 @@ class FiatProvidersSettings(
):
allowed_providers.append("paypal")
if self.square_enabled and (
not self.square_limits.allowed_users
or user_id in self.square_limits.allowed_users
):
allowed_providers.append("square")
if self.revolut_enabled and (
not self.revolut_limits.allowed_users
or user_id in self.revolut_limits.allowed_users
):
allowed_providers.append("revolut")
return allowed_providers
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
@@ -1055,8 +1006,8 @@ class EnvSettings(LNbitsSettings):
debug: bool = Field(default=False)
debug_database: bool = Field(default=False)
bundle_assets: bool = Field(default=True)
profiler: bool = Field(default=False)
# When enabled, auth cookies require HTTPS and SSO will reject insecure HTTP.
# Set to false for local/dev environments that run without TLS.
auth_https_only: bool = Field(default=True)
host: str = Field(default="127.0.0.1")
port: int = Field(default=5000, gt=0)
@@ -1075,8 +1026,6 @@ class EnvSettings(LNbitsSettings):
cleanup_wallets_days: int = Field(default=90, ge=0)
funding_source_max_retries: int = Field(default=4, ge=0)
lnbits_max_users: int = Field(default=0, ge=0)
lnbits_max_extensions: int = Field(default=0, ge=0)
@property
def has_default_extension_path(self) -> bool:
@@ -1282,7 +1231,6 @@ class PublicSettings(BaseModel):
default_card_rounded: bool = Field(alias="defaultCardRounded")
default_card_gradient: bool = Field(alias="defaultCardGradient")
default_card_shadow: bool = Field(alias="defaultCardShadow")
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
denomination: str | None = Field()
extensions: list[str] = Field()
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
@@ -1346,7 +1294,6 @@ class PublicSettings(BaseModel):
defaultCardRounded=settings.lnbits_default_card_rounded,
defaultCardGradient=settings.lnbits_default_card_gradient,
defaultCardShadow=settings.lnbits_default_card_shadow,
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
denomination=settings.lnbits_denomination,
extensions=list(settings.lnbits_installed_extensions_ids),
allowedCurrencies=settings.lnbits_allowed_currencies,
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
+18 -18
View File
File diff suppressed because one or more lines are too long
-7
View File
@@ -395,13 +395,6 @@ body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card,
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
body.no-burger-background .q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
:root {
--size: 100px;
--gap: 25px;
-15
View File
@@ -267,15 +267,6 @@ window.localisation.en = {
webhook_events_list: 'The following events must be supported by the webhook:',
webhook_stripe_description:
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
webhook_square_description:
'On the Square side configure a webhook pointing to this exact LNbits URL.',
square_webhook_url_hint:
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
access_token: 'Access Token',
location_id: 'Location ID',
square_location_id_hint:
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
api_version: 'API Version',
payment_proof: 'Payment Proof',
update: 'Update',
update_available: 'Update {version} available!',
@@ -284,8 +275,6 @@ window.localisation.en = {
requires_server_restart:
'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet',
phoenixd_warning:
"Phoenixd mnemonic is only available if phoenixd data-dir is specified and is readable by LNbits. It's not indicative of phoenixd not running. It just means LNbits cannot access the mnemonic to display it here.",
latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications',
notifications_configure: 'Configure Notifications',
@@ -490,8 +479,6 @@ window.localisation.en = {
toggle_card_gradient: 'Toggle gradient on cards',
card_shadow: 'Card Shadow',
toggle_card_shadow: 'Toggle shadow on cards',
burger_menu_background: 'Burger Menu Background',
toggle_burger_menu_background: 'Toggle burger menu background',
language: 'Language',
assets: 'Assets',
max_asset_size_mb: 'Max Asset Size (MB)',
@@ -816,8 +803,6 @@ window.localisation.en = {
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
webhook_paypal_description:
'On the PayPal side configure a webhook pointing to your LNbits server.',
square_webhook_signature_key_hint:
'Square webhook signature key used to verify incoming events.',
callback_success_url: 'Callback Success URL',
callback_success_url_hint:
'The user will be redirected to this URL after the payment is successful',
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+1 -3
View File
@@ -452,9 +452,7 @@ window.app.component('username-password', {
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false,
showPwd: false,
showPwdRepeat: false
showConfirmationCode: false
}
},
methods: {
@@ -5,9 +5,6 @@ window.app.component('lnbits-admin-fiat-providers', {
return {
formAddStripeUser: '',
formAddPaypalUser: '',
formAddSquareUser: '',
formAddRevolutUser: '',
creatingRevolutWebhook: false,
hideInputToggle: true
}
},
@@ -23,12 +20,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData?.paypal_payment_webhook_url ||
this.calculateWebhookUrl('paypal')
)
},
revolutWebhookUrl() {
return (
this.formData?.revolut_payment_webhook_url ||
this.calculateWebhookUrl('revolut')
)
}
},
watch: {
@@ -67,8 +58,6 @@ window.app.component('lnbits-admin-fiat-providers', {
syncWebhookUrls() {
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
},
maybeSetWebhookUrl(fieldName, provider) {
if (!this.formData) {
@@ -88,47 +77,6 @@ window.app.component('lnbits-admin-fiat-providers', {
}
this.copyText(url)
},
isClearnetWebhookUrl(url) {
let parsedUrl
try {
parsedUrl = new URL(url)
} catch (e) {
return false
}
const host = parsedUrl.hostname.toLowerCase()
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false
}
if (
host === 'localhost' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
host.endsWith('.onion')
) {
return false
}
if (
/^127\./.test(host) ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^169\.254\./.test(host) ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
host === '0.0.0.0' ||
host === '::1'
) {
return false
}
return true
},
notifyRevolutWebhookWarning(message) {
Quasar.Notify.create({
type: 'warning',
message,
icon: null,
closeBtn: true
})
},
addStripeAllowedUser() {
const addUser = this.formAddStripeUser || ''
if (
@@ -163,40 +111,6 @@ window.app.component('lnbits-admin-fiat-providers', {
this.formData.paypal_limits.allowed_users =
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
},
addSquareAllowedUser() {
const addUser = this.formAddSquareUser || ''
if (
addUser.length &&
!this.formData.square_limits.allowed_users.includes(addUser)
) {
this.formData.square_limits.allowed_users = [
...this.formData.square_limits.allowed_users,
addUser
]
this.formAddSquareUser = ''
}
},
removeSquareAllowedUser(user) {
this.formData.square_limits.allowed_users =
this.formData.square_limits.allowed_users.filter(u => u !== user)
},
addRevolutAllowedUser() {
const addUser = this.formAddRevolutUser || ''
if (
addUser.length &&
!this.formData.revolut_limits.allowed_users.includes(addUser)
) {
this.formData.revolut_limits.allowed_users = [
...this.formData.revolut_limits.allowed_users,
addUser
]
this.formAddRevolutUser = ''
}
},
removeRevolutAllowedUser(user) {
this.formData.revolut_limits.allowed_users =
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
},
checkFiatProvider(providerName) {
LNbits.api
.request('PUT', `/api/v1/fiat/check/${providerName}`)
@@ -210,48 +124,6 @@ window.app.component('lnbits-admin-fiat-providers', {
})
})
.catch(LNbits.utils.notifyApiError)
},
createRevolutWebhook() {
const webhookUrl = this.calculateWebhookUrl('revolut')
this.formData.revolut_payment_webhook_url = webhookUrl
if (!this.formData.revolut_api_secret_key) {
this.notifyRevolutWebhookWarning(
'Add your Revolut API secret key before creating a webhook.'
)
return
}
if (!this.isClearnetWebhookUrl(webhookUrl)) {
this.notifyRevolutWebhookWarning(
'Revolut webhook URL must be a clearnet URL.'
)
return
}
this.creatingRevolutWebhook = true
LNbits.api
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
url: webhookUrl,
endpoint: this.formData.revolut_api_endpoint,
api_secret_key: this.formData.revolut_api_secret_key,
api_version: this.formData.revolut_api_version
})
.then(response => {
const data = response.data
this.formData.revolut_payment_webhook_url = data.url
this.formData.revolut_webhook_signing_secret = data.signing_secret
Quasar.Notify.create({
type: 'positive',
message: `Revolut webhook ${
data.already_exists ? 'already exists' : 'created'
}${data.id ? `: ${data.id}` : ''}.`,
icon: null
})
})
.catch(LNbits.utils.notifyApiError)
.finally(() => {
this.creatingRevolutWebhook = false
})
}
}
})
@@ -202,18 +202,7 @@ window.app.component('lnbits-admin-funding-sources', {
'Phoenixd',
{
phoenixd_api_endpoint: 'Endpoint',
phoenixd_api_password: 'Key',
phoenixd_data_dir: {
label: 'Data Directory',
hint: 'Directory where phoenixd stores its data, including the seed phrase.'
},
phoenixd_mnemonic: {
label: 'Phoenixd Seed Phrase',
hint: 'Only available if phoenixd data-dir is specified',
readonly: true,
copy: true,
qrcode: true
}
phoenixd_api_password: 'Key'
}
],
[
@@ -41,37 +41,5 @@ window.app.component('lnbits-admin-site-customisation', {
]
}
},
methods: {
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadBackgroundImage(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/assets?public_asset=true',
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
const assetUrl = `${window.location.origin}/api/v1/assets/${data.id}/thumbnail`
this.formData.lnbits_default_bgimage = assetUrl
Quasar.Notify.create({
type: 'positive',
message: 'Background image uploaded.',
icon: null
})
} catch (e) {
LNbits.utils.notifyApiError(e)
}
}
}
methods: {}
})
@@ -69,14 +69,6 @@ window.app.component('lnbits-theme', {
document.body.classList.remove('card-shadow')
}
},
'g.burgerMenuChoice'(val) {
this.$q.localStorage.set('lnbits.burgerMenu', val)
if (val === true) {
document.body.classList.remove('no-burger-background')
} else {
document.body.classList.add('no-burger-background')
}
},
'g.mobileSimple'(val) {
this.$q.localStorage.set('lnbits.mobileSimple', val)
if (val === true) {
@@ -158,9 +150,6 @@ window.app.component('lnbits-theme', {
if (this.g.cardShadowChoice === true) {
document.body.classList.add('card-shadow')
}
if (this.g.burgerMenuChoice !== true) {
document.body.classList.add('no-burger-background')
}
if (this.g.bgimageChoice !== '') {
document.body.classList.add('bg-image')
document.body.style.setProperty(
+5 -6
View File
@@ -1,17 +1,16 @@
function eventReaction(amount) {
localUrl = ''
const reaction =
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction
if (!reaction || reaction.toLowerCase() === 'none') {
reaction = localStorage.getItem('lnbits.reactions')
if (!reaction || reaction === 'None') {
return
}
try {
if (amount < 0) {
return
}
if (typeof window[reaction] === 'function') {
window[reaction]()
reaction = localStorage.getItem('lnbits.reactions')
if (reaction) {
window[reaction.split('|')[1]]()
}
} catch (e) {
console.log(e)
-4
View File
@@ -29,10 +29,6 @@ window.g = Vue.reactive({
SETTINGS.defaultCardGradient
),
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
burgerMenuChoice: localStore(
'lnbits.burgerMenu',
SETTINGS.defaultBurgerMenuBackground
),
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
bgimageChoice: localStore(
'lnbits.backgroundImage',
+9 -35
View File
@@ -545,56 +545,31 @@ window.PageAccount = {
if (file) {
this.uploadAsset(file)
}
e.target.value = null
},
onBackgroundImageInput(e) {
const file = e.target.files[0]
if (file) {
this.uploadBackgroundImage(file)
}
e.target.value = null
},
async uploadAsset(
file,
{isPublic = this.assetsUploadToPublic, notifySuccess = true} = {}
) {
async uploadAsset(file) {
const formData = new FormData()
formData.append('file', file)
try {
const {data} = await LNbits.api.request(
await LNbits.api.request(
'POST',
`/api/v1/assets?public_asset=${isPublic}`,
`/api/v1/assets?public_asset=${this.assetsUploadToPublic}`,
null,
formData,
{
headers: {'Content-Type': 'multipart/form-data'}
}
)
if (notifySuccess) {
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
}
this.$q.notify({
type: 'positive',
message: 'Upload successful!',
icon: null
})
await this.getUserAssets()
return data
} catch (e) {
console.warn(e)
LNbits.utils.notifyApiError(e)
}
},
async uploadBackgroundImage(file) {
const asset = await this.uploadAsset(file, {
isPublic: false,
notifySuccess: false
})
if (!asset) {
return
}
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/thumbnail`
await this.siteCustomisationChanged({bgimageChoice: assetUrl})
},
async deleteAsset(asset) {
LNbits.utils
.confirmDialog('Are you sure you want to delete this asset?')
@@ -732,8 +707,7 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow,
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
cardShadowChoice: this.g.settings.defaultCardShadow
}
this.siteCustomisationChanged(defaults)
}
+1 -1
View File
@@ -21,7 +21,7 @@ window.PageHome = {
return (
this.lnurl !== '' &&
this.g.settings.allowRegister &&
this.g.settings.authMethods.includes('user-id-only')
'user-id-only' in this.g.settings.authMethods
)
},
formatDescription() {
-9
View File
@@ -70,12 +70,3 @@ body.card-shadow.body--dark {
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
}
}
body.no-burger-background {
.q-drawer {
background-color: transparent !important;
background-image: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
}
+20776
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
/*
* DOM element rendering detection
* https://davidwalsh.name/detect-node-insertion
*/
@keyframes chartjs-render-animation {
from { opacity: 0.99; }
to { opacity: 1; }
}
.chartjs-render-monitor {
animation: chartjs-render-animation 0.001s;
}
/*
* DOM element resizing detection
* https://github.com/marcj/css-element-queries
*/
.chartjs-size-monitor,
.chartjs-size-monitor-expand,
.chartjs-size-monitor-shrink {
position: absolute;
direction: ltr;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
visibility: hidden;
z-index: -1;
}
.chartjs-size-monitor-expand > div {
position: absolute;
width: 1000000px;
height: 1000000px;
left: 0;
top: 0;
}
.chartjs-size-monitor-shrink > div {
position: absolute;
width: 200%;
height: 200%;
left: 0;
top: 0;
}
+952 -1463
View File
File diff suppressed because it is too large Load Diff
+3116 -2768
View File
File diff suppressed because it is too large Load Diff
+131 -221
View File
@@ -1,5 +1,5 @@
/*!
* qrcode.vue v3.9.0
* qrcode.vue v3.6.0
* A Vue.js component to generate QRCode. Both support Vue 2 and Vue 3
* © 2017-PRESENT @scopewu(https://github.com/scopewu)
* MIT License.
@@ -909,18 +909,7 @@ var qrcodegen;
})(qrcodegen || (qrcodegen = {}));
var QR = qrcodegen;
var _uid = 0;
function getUid() {
if (typeof vue.useId === 'function') {
return "".concat(vue.useId(), "-").concat(_uid++);
}
return "vue-".concat(Math.random().toString(36).slice(2), "-").concat(_uid++);
}
var defaultErrorCorrectLevel = 'L';
var DEFAULT_QR_SIZE = 100;
var DEFAULT_MARGIN = 0;
var DEFAULT_IMAGE_SIZE_RATIO = 0.1;
var IMAGE_EXCAVATE_THICKNESS = 2;
var ErrorCorrectLevelMap = {
L: QR.QrCode.Ecc.LOW,
M: QR.QrCode.Ecc.MEDIUM,
@@ -940,139 +929,74 @@ var SUPPORTS_PATH2D = (function () {
function validErrorCorrectLevel(level) {
return level in ErrorCorrectLevelMap;
}
function getNeighborFlags(modules, row, col) {
var north = row > 0 ? modules[row - 1][col] : false;
var south = row < modules.length - 1 ? modules[row + 1][col] : false;
var west = col > 0 ? modules[row][col - 1] : false;
var east = col < modules[row].length - 1 ? modules[row][col + 1] : false;
return {
nw: !north && !west,
ne: !north && !east,
se: !south && !east,
sw: !south && !west,
};
}
function generateRoundedPath(modules, margin, radius) {
if (margin === void 0) { margin = 0; }
if (radius === void 0) { radius = 0; }
var pathSegments = [];
var r = Math.min(radius, 0.5);
for (var row = 0; row < modules.length; row++) {
for (var col = 0; col < modules[row].length; col++) {
if (!modules[row][col])
continue;
var _a = getNeighborFlags(modules, row, col), nw = _a.nw, ne = _a.ne, se = _a.se, sw = _a.sw;
var x = col + margin;
var y = row + margin;
pathSegments.push("M".concat(x + (nw ? r : 0), " ").concat(y), "L".concat(x + 1 - (ne ? r : 0), " ").concat(y));
if (ne) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1, " ").concat(y + r));
}
pathSegments.push("L".concat(x + 1, " ").concat(y + 1 - (se ? r : 0)));
if (se) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + 1 - r, " ").concat(y + 1));
}
pathSegments.push("L".concat(x + (sw ? r : 0), " ").concat(y + 1));
if (sw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x, " ").concat(y + 1 - r));
}
pathSegments.push("L".concat(x, " ").concat(y + (nw ? r : 0)));
if (nw) {
pathSegments.push("A".concat(r, " ").concat(r, " 0 0 1 ").concat(x + r, " ").concat(y));
}
pathSegments.push('z');
}
}
return pathSegments.join('');
}
function generatePath(modules, margin) {
if (margin === void 0) { margin = 0; }
var pathSegments = [];
for (var y = 0; y < modules.length; y++) {
var row = modules[y];
var ops = [];
modules.forEach(function (row, y) {
var start = null;
for (var x = 0; x < row.length; x++) {
var cell = row[x];
row.forEach(function (cell, x) {
if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma,
pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
// saving a char per operation
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
start = null;
continue;
return;
}
// end of row, clean up or skip
if (x === row.length - 1) {
if (!cell) {
// We would have closed the op above already so this can only mean
// 2+ light modules in a row.
continue;
return;
}
if (start === null) {
// Just a single dark module.
pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
}
else {
// Otherwise finish the current line.
pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
}
continue;
return;
}
if (cell && start === null) {
start = x;
}
}
}
return pathSegments.join('');
});
});
return ops.join('');
}
function getImageSettings(cells, size, margin, imageSettings) {
var width = imageSettings.width, height = imageSettings.height, imageX = imageSettings.x, imageY = imageSettings.y;
var numCells = cells.length + margin * 2;
var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO);
var defaultSize = Math.floor(size * 0.1);
var scale = numCells / size;
var w = (width || defaultSize) * scale;
var h = (height || defaultSize) * scale;
var x = imageX == null ? cells.length / 2 - w / 2 : imageX * scale;
var y = imageY == null ? cells.length / 2 - h / 2 : imageY * scale;
var borderRadius = (imageSettings.borderRadius || 0) * scale;
return { x: x, y: y, h: h, w: w, borderRadius: borderRadius };
var excavation = null;
if (imageSettings.excavate) {
var floorX = Math.floor(x);
var floorY = Math.floor(y);
var ceilW = Math.ceil(w + x - floorX);
var ceilH = Math.ceil(h + y - floorY);
excavation = { x: floorX, y: floorY, w: ceilW, h: ceilH };
}
return { x: x, y: y, h: h, w: w, excavation: excavation };
}
function useQRCode(props) {
var margin = vue.computed(function () { var _a; return ((_a = props.margin) !== null && _a !== void 0 ? _a : DEFAULT_MARGIN) >>> 0; });
var cells = vue.computed(function () {
var level = validErrorCorrectLevel(props.level) ? props.level : defaultErrorCorrectLevel;
return QR.QrCode.encodeText(props.value, ErrorCorrectLevelMap[level]).getModules();
});
var numCells = vue.computed(function () { return cells.value.length + margin.value * 2; });
var fgPath = vue.computed(function () {
if (props.radius > 0) {
return generateRoundedPath(cells.value, margin.value, props.radius);
function excavateModules(modules, excavation) {
return modules.slice().map(function (row, y) {
if (y < excavation.y || y >= excavation.y + excavation.h) {
return row;
}
return generatePath(cells.value, margin.value);
return row.map(function (cell, x) {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
});
var imageProps = vue.computed(function () {
if (!props.imageSettings.src)
return null;
var settings = getImageSettings(cells.value, props.size, margin.value, props.imageSettings);
return {
x: settings.x + margin.value,
y: settings.y + margin.value,
width: settings.w,
height: settings.h,
borderRadius: settings.borderRadius,
};
});
var imageBorderProps = vue.computed(function () {
if (!props.imageSettings.excavate || !imageProps.value)
return null;
var borderThickness = IMAGE_EXCAVATE_THICKNESS / (props.size / numCells.value);
return {
x: imageProps.value.x - borderThickness,
y: imageProps.value.y - borderThickness,
width: imageProps.value.width + borderThickness * 2,
height: imageProps.value.height + borderThickness * 2,
borderRadius: imageProps.value.borderRadius,
};
});
return { margin: margin, numCells: numCells, cells: cells, fgPath: fgPath, imageProps: imageProps, imageBorderProps: imageBorderProps };
}
var QRCodeProps = {
value: {
@@ -1082,7 +1006,7 @@ var QRCodeProps = {
},
size: {
type: Number,
default: DEFAULT_QR_SIZE,
default: 100,
},
level: {
type: String,
@@ -1100,7 +1024,7 @@ var QRCodeProps = {
margin: {
type: Number,
required: false,
default: DEFAULT_MARGIN,
default: 0,
},
imageSettings: {
type: Object,
@@ -1128,12 +1052,6 @@ var QRCodeProps = {
required: false,
default: '#fff',
},
radius: {
type: Number,
required: false,
default: 0,
validator: function (r) { return !isNaN(r) && r >= 0 && r <= 0.5; },
},
};
var QRCodeVueProps = __assign(__assign({}, QRCodeProps), { renderAs: {
type: String,
@@ -1145,11 +1063,36 @@ var QrcodeSvg = vue.defineComponent({
name: 'QRCodeSvg',
props: QRCodeProps,
setup: function (props) {
var _a = useQRCode(props), numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var uid = getUid();
var qrGradientId = "qrcode.vue-gradient-".concat(uid);
var qrLogoClipPathId = "qrcode.vue-logo-clip-path-".concat(uid);
var gradientVNode = vue.computed(function () {
var numCells = vue.ref(0);
var fgPath = vue.ref('');
var imageProps;
var generate = function () {
var value = props.value, _level = props.level, _margin = props.margin;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
numCells.value = cells.length + margin * 2;
if (props.imageSettings.src) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
// Drawing strategy: instead of a rect per module, we're going to create a
// single path for the dark modules and layer that on top of a light rect,
// for a total of 2 DOM nodes. We pay a bit more in string concat but that's
// way faster than DOM ops.
// For level 1, 441 nodes -> 2
// For level 40, 31329 -> 2
fgPath.value = generatePath(cells, margin);
};
var renderGradient = function () {
if (!props.gradient)
return null;
var gradientProps = props.gradientType === 'linear'
@@ -1166,7 +1109,7 @@ var QrcodeSvg = vue.defineComponent({
fx: '50%',
fy: '50%',
};
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [
vue.h('stop', {
offset: '0%',
style: { stopColor: props.gradientStartColor },
@@ -1176,52 +1119,27 @@ var QrcodeSvg = vue.defineComponent({
style: { stopColor: props.gradientEndColor },
}),
]);
});
var clipPathVNode = vue.computed(function () {
if (!imageProps.value)
return null;
var borderRadius = imageProps.value.borderRadius;
if (borderRadius <= 0)
return null;
return vue.h('clipPath', { id: qrLogoClipPathId }, [
vue.h('rect', {
x: imageProps.value.x,
y: imageProps.value.y,
width: imageProps.value.width,
height: imageProps.value.height,
rx: borderRadius,
ry: borderRadius,
}),
]);
});
};
generate();
vue.onUpdated(generate);
return function () { return vue.h('svg', {
width: props.size,
height: props.size,
'shape-rendering': "crispEdges",
xmlns: 'http://www.w3.org/2000/svg',
viewBox: "0 0 ".concat(numCells.value, " ").concat(numCells.value),
role: 'img',
'aria-label': props.value,
}, [
vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]),
vue.h('defs', {}, [renderGradient()]),
vue.h('rect', {
width: '100%',
height: '100%',
fill: props.background,
}),
vue.h('path', {
fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground,
fill: props.gradient ? 'url(#qr-gradient)' : props.foreground,
d: fgPath.value,
}),
imageBorderProps.value && vue.h('rect', {
x: imageBorderProps.value.x,
y: imageBorderProps.value.y,
width: imageBorderProps.value.width,
height: imageBorderProps.value.height,
fill: props.background,
rx: imageBorderProps.value.borderRadius,
ry: imageBorderProps.value.borderRadius,
}),
props.imageSettings.src && imageProps.value && vue.h('image', __assign(__assign({ href: props.imageSettings.src }, imageProps.value), (imageProps.value.borderRadius > 0 ? { 'clip-path': "url(#".concat(qrLogoClipPathId, ")") } : {}))),
props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)),
]); };
},
});
@@ -1229,89 +1147,81 @@ var QrcodeCanvas = vue.defineComponent({
name: 'QRCodeCanvas',
props: QRCodeProps,
setup: function (props, ctx) {
var _a = useQRCode(props), margin = _a.margin, cells = _a.cells, numCells = _a.numCells, fgPath = _a.fgPath, imageProps = _a.imageProps, imageBorderProps = _a.imageBorderProps;
var canvasEl = vue.ref(null);
var imageEl = vue.ref(null);
var drawRoundedRect = function (ctx, x, y, width, height, radius) {
ctx.beginPath();
if (ctx.roundRect) {
ctx.roundRect(x, y, width, height, radius);
}
else {
ctx.rect(x, y, width, height);
}
};
var imageRef = vue.ref(null);
var generate = function () {
var size = props.size, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var value = props.value, _level = props.level, size = props.size, _margin = props.margin, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var margin = _margin >>> 0;
var level = validErrorCorrectLevel(_level) ? _level : defaultErrorCorrectLevel;
var canvas = canvasEl.value;
if (!canvas) {
return;
}
var canvasCtx = canvas.getContext('2d');
if (!canvasCtx) {
var ctx = canvas.getContext('2d');
if (!ctx) {
return;
}
var image = imageEl.value;
var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
var scale = (size / numCells.value) * devicePixelRatio;
var cells = QR.QrCode.encodeText(value, ErrorCorrectLevelMap[level]).getModules();
var numCells = cells.length + margin * 2;
var image = imageRef.value;
var imageProps = { x: 0, y: 0, width: 0, height: 0 };
var showImage = props.imageSettings.src && image != null && image.naturalWidth !== 0 && image.naturalHeight !== 0;
if (showImage) {
var imageSettings = getImageSettings(cells, props.size, margin, props.imageSettings);
imageProps = {
x: imageSettings.x + margin,
y: imageSettings.y + margin,
width: imageSettings.w,
height: imageSettings.h,
};
if (imageSettings.excavation) {
cells = excavateModules(cells, imageSettings.excavation);
}
}
var devicePixelRatio = window.devicePixelRatio || 1;
var scale = (size / numCells) * devicePixelRatio;
canvas.height = canvas.width = size * devicePixelRatio;
canvasCtx.setTransform(scale, 0, 0, scale, 0, 0);
canvasCtx.fillStyle = background;
canvasCtx.fillRect(0, 0, numCells.value, numCells.value);
ctx.scale(scale, scale);
ctx.fillStyle = background;
ctx.fillRect(0, 0, numCells, numCells);
if (gradient) {
var grad = void 0;
if (gradientType === 'linear') {
grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value);
grad = ctx.createLinearGradient(0, 0, numCells, numCells);
}
else {
grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2);
grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2);
}
grad.addColorStop(0, gradientStartColor);
grad.addColorStop(1, gradientEndColor);
canvasCtx.fillStyle = grad;
ctx.fillStyle = grad;
}
else {
canvasCtx.fillStyle = foreground;
ctx.fillStyle = foreground;
}
if (SUPPORTS_PATH2D) {
canvasCtx.fill(new Path2D(fgPath.value));
ctx.fill(new Path2D(generatePath(cells, margin)));
}
else {
cells.value.forEach(function (row, rdx) {
cells.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) {
if (cell) {
canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1);
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
}
});
});
}
var showImage = props.imageSettings.src && image && image.naturalWidth !== 0 && image.naturalHeight !== 0;
if (showImage && imageProps.value) {
if (imageBorderProps.value) {
var imageBorder = imageBorderProps.value;
canvasCtx.fillStyle = props.background;
drawRoundedRect(canvasCtx, imageBorder.x, imageBorder.y, imageBorder.width, imageBorder.height, imageBorder.borderRadius);
canvasCtx.fill();
}
var borderRadius = imageProps.value.borderRadius;
if (borderRadius > 0) {
canvasCtx.save();
drawRoundedRect(canvasCtx, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height, borderRadius);
canvasCtx.clip();
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
canvasCtx.restore();
}
else {
canvasCtx.drawImage(image, imageProps.value.x, imageProps.value.y, imageProps.value.width, imageProps.value.height);
}
if (showImage) {
ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height);
}
};
vue.onMounted(generate);
vue.watchEffect(generate);
vue.onUpdated(generate);
var style = ctx.attrs.style;
return function () { return vue.h(vue.Fragment, [
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, role: 'img', 'aria-label': props.value, style: __assign(__assign({}, ctx.attrs.style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
props.imageSettings.src && vue.h('img', {
ref: imageEl,
ref: imageRef,
src: props.imageSettings.src,
style: { display: 'none' },
onLoad: generate,
@@ -1321,23 +1231,23 @@ var QrcodeCanvas = vue.defineComponent({
});
var QrcodeVue = vue.defineComponent({
name: 'Qrcode',
props: QRCodeVueProps,
setup: function (props) {
return function () { return vue.h(props.renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
value: props.value,
size: props.size,
margin: props.margin,
level: props.level,
background: props.background,
foreground: props.foreground,
imageSettings: props.imageSettings,
gradient: props.gradient,
gradientType: props.gradientType,
gradientStartColor: props.gradientStartColor,
gradientEndColor: props.gradientEndColor,
radius: props.radius,
}); };
render: function () {
var _a = this.$props, renderAs = _a.renderAs, value = _a.value, size = _a.size, margin = _a.margin, level = _a.level, background = _a.background, foreground = _a.foreground, imageSettings = _a.imageSettings, gradient = _a.gradient, gradientType = _a.gradientType, gradientStartColor = _a.gradientStartColor, gradientEndColor = _a.gradientEndColor;
return vue.h(renderAs === 'svg' ? QrcodeSvg : QrcodeCanvas, {
value: value,
size: size,
margin: margin,
level: level,
background: background,
foreground: foreground,
imageSettings: imageSettings,
gradient: gradient,
gradientType: gradientType,
gradientStartColor: gradientStartColor,
gradientEndColor: gradientEndColor,
});
},
props: QRCodeVueProps,
});
exports.QrcodeCanvas = QrcodeCanvas;
+28 -15
View File
@@ -43,7 +43,8 @@ summary {
abbr[title] {
border-bottom: none;
text-decoration: underline;
text-decoration: underline dotted;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/**
@@ -199,7 +200,8 @@ input[type=search]::-webkit-search-decoration {
.material-symbols-outlined,
.material-symbols-rounded,
.material-symbols-sharp {
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: inherit;
font-size: inherit;
display: inline-flex;
@@ -996,7 +998,8 @@ input[type=search]::-webkit-search-decoration {
height: 1px;
}
.q-checkbox__bg, .q-checkbox__icon-container {
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-checkbox__bg {
top: 25%;
@@ -2209,7 +2212,8 @@ body.q-ios-padding .q-dialog__inner > div {
width: 100%;
min-width: 0;
outline: 0 !important;
user-select: auto;
-webkit-user-select: auto;
user-select: auto;
}
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
-webkit-animation-name: q-autofill;
@@ -3035,7 +3039,8 @@ body.body--dark .q-knob--editable:focus:before {
z-index: 2001;
height: 100%;
width: 15px;
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-layout, .q-header, .q-footer, .q-page {
@@ -3292,7 +3297,8 @@ body.platform-ios .q-layout--containerized {
height: 1px;
}
.q-radio__bg, .q-radio__icon-container {
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-radio__bg {
top: 25%;
@@ -3776,7 +3782,8 @@ body.platform-ios:not(.native-mobile) .q-dialog__inner--top .q-select__dialog--f
.q-slide-item__content {
background: inherit;
transition: transform 0.2s ease-in;
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
}
@@ -4149,7 +4156,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
}
.q-splitter__separator {
background-color: rgba(0, 0, 0, 0.12);
user-select: none;
-webkit-user-select: none;
user-select: none;
position: relative;
z-index: 1;
}
@@ -4238,7 +4246,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
color: #000;
}
.q-stepper__tab--navigation {
user-select: none;
-webkit-user-select: none;
user-select: none;
cursor: pointer;
}
.q-stepper__tab--active, .q-stepper__tab--done {
@@ -4470,7 +4479,8 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
.q-table th {
font-weight: 500;
font-size: 12px;
user-select: none;
-webkit-user-select: none;
user-select: none;
}
.q-table th.sortable {
cursor: pointer;
@@ -5474,7 +5484,8 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no
width: 0.5em;
height: 0.5em;
transition: left 0.22s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
-webkit-user-select: none;
user-select: none;
z-index: 0;
}
.q-toggle__thumb:after {
@@ -10326,7 +10337,6 @@ body.body--dark .inset-shadow-down {
.glossy {
background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 50%, rgba(0, 0, 0, 0.12) 51%, rgba(0, 0, 0, 0.04)) !important;
}
.q-placeholder::placeholder {
color: inherit;
opacity: 0.7;
@@ -10358,7 +10368,8 @@ body.body--dark .inset-shadow-down {
text-decoration: none;
}
.q-link--focusable:focus-visible {
text-decoration: underline dashed currentColor 1px;
-webkit-text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
}
body.electron .q-electron-drag {
@@ -10375,7 +10386,8 @@ img.responsive {
}
.non-selectable {
user-select: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.scroll,
@@ -11033,7 +11045,8 @@ body.q-ios-padding .fullscreen {
}
.q-touch {
user-select: none;
-webkit-user-select: none;
user-select: none;
user-drag: none;
-khtml-user-drag: none;
-webkit-user-drag: none;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+35 -11
View File
@@ -5,8 +5,12 @@ from collections.abc import Callable, Coroutine
from loguru import logger
from lnbits.core.models import Payment
from lnbits.core.services.payments import update_invoice_callback
from lnbits.core.crud import (
get_standalone_payment,
update_payment,
)
from lnbits.core.models import Payment, PaymentState
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
from lnbits.settings import settings
from lnbits.wallets import get_funding_source
@@ -112,10 +116,7 @@ async def internal_invoice_listener() -> None:
while settings.lnbits_running:
checking_id = await internal_invoice_queue.get()
logger.info(f"got an internal payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"internal invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id, is_internal=True)
async def invoice_listener() -> None:
@@ -128,10 +129,7 @@ async def invoice_listener() -> None:
funding_source = get_funding_source()
async for checking_id in funding_source.paid_invoices_stream():
logger.info(f"got a payment notification {checking_id}")
payment = await update_invoice_callback(checking_id)
if payment:
logger.success(f"fundingsource invoice {checking_id} settled")
await invoice_callback_dispatcher(payment)
await invoice_callback_dispatcher(checking_id)
def wait_for_paid_invoices(
@@ -167,7 +165,33 @@ def run_interval(
return wrapper
async def invoice_callback_dispatcher(payment: Payment):
async def invoice_callback_dispatcher(checking_id: str, is_internal: bool = False):
"""
Takes an incoming payment, checks its status, and dispatches it to
invoice_listeners from core and extensions.
"""
payment = await get_standalone_payment(checking_id, incoming=True)
if not payment:
logger.warning(f"No payment found for '{checking_id}'.")
return
if not payment.is_in:
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
return
from lnbits.core.services.payments import check_payment_status
status = await check_payment_status(
payment, skip_internal_payment_notifications=True
)
payment.fee = status.fee_msat or payment.fee
# only overwrite preimage if status.preimage provides it
payment.preimage = status.preimage or payment.preimage
payment.status = PaymentState.SUCCESS
await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
internal = "internal" if is_internal else ""
logger.success(f"{internal} invoice {checking_id} settled")
for name, send_chan in invoice_listeners.items():
logger.trace(f"invoice listeners: sending to `{name}`")
await send_chan.put(payment)
+1 -3
View File
@@ -90,9 +90,7 @@
window.g.isPublicPage = false
{% endif %}
</script>
{% endif %} {% for url in INCLUDED_EXTENSION_I18N %}
<script src="{{ url }}"></script>
{% endfor %}
{% endif %}
<!-- app init -->
<script>
window.app = Vue.createApp({
+5 -35
View File
@@ -774,13 +774,7 @@ include('components/lnbits-error.vue') %}
v-model="password"
name="password"
:label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
type="password"
></q-input>
<div class="row justify-end">
<q-btn
@@ -809,28 +803,16 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'"
type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<q-input
dense
filled
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
:type="showPwdRepeat ? 'text' : 'password'"
type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input>
<div
v-if="confirmationMethodsCount > 1"
@@ -943,28 +925,16 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
:type="showPwd ? 'text' : 'password'"
type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<q-input
dense
filled
v-model="passwordRepeat"
:label="$t('password_repeat') + ' *'"
:type="showPwdRepeat ? 'text' : 'password'"
type="password"
:rules="[val => !val || val.length >= 8 || $t('invalid_password')]"
><template v-slot:append>
<q-icon
:name="showPwdRepeat ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwdRepeat = !showPwdRepeat"
/> </template
></q-input>
<div class="row justify-end">
<q-btn
@@ -587,544 +587,12 @@
<q-item-section> Square </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.square_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
<div class="row items-center">Disabled</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.square_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.square_access_token"
:label="$t('access_token')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_location_id"
:label="$t('location_id')"
:hint="$t('square_location_id_hint')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_api_version"
:label="$t('api_version')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.square_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('square')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
<span v-text="$t('webhook_square_description')"></span>
</q-card-section>
<q-card-section>
<div class="row items-center q-gutter-sm q-mt-md">
<div class="col">
<q-input
filled
type="text"
v-model="formData.square_payment_webhook_url"
:label="$t('webhook_url')"
:hint="$t('square_webhook_url_hint')"
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="
copyWebhookUrl(formData.square_payment_webhook_url)
"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.square_webhook_signature_key"
:label="$t('signing_secret')"
:hint="$t('square_webhook_signature_key_hint')"
></q-input>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li><code>payment.updated</code></li>
<li><code>invoice.payment_made</code></li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_fee_percent"
@update:model-value="formData.touch = null"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_max_fee_sats"
@update:model-value="formData.touch = null"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.square_limits.service_fee_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_min_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.square_limits.service_max_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.square_limits.service_faucet_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
<q-item>
<q-item-section>
<q-item-label v-text="$t('faucest_wallet')"></q-item-label>
<q-item-label caption>
<ul>
<li>
<span
v-text="
$t('faucest_wallet_desc_1', {
provider: 'square'
})
"
></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_2', {
provider: 'square'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_3')"></span>
</li>
<li>
<span
v-text="
$t('faucest_wallet_desc_4', {
provider: 'square'
})
"
></span>
</li>
<li>
<span v-text="$t('faucest_wallet_desc_5')"></span>
</li>
</ul>
<br />
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddSquareUser"
@keydown.enter="addSquareAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="
$t('allowed_users_hint_feature', {
feature: 'Square'
})
"
>
<q-btn
@click="addSquareAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.square_limits.allowed_users"
@update:model-value="formData.touch = null"
:key="user"
removable
@remove="removeSquareAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
</q-card>
</q-expansion-item>
<q-separator></q-separator>
<q-expansion-item header-class="text-primary text-bold">
<template v-slot:header>
<q-item-section avatar>
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
</q-item-section>
<q-item-section> Revolut </q-item-section>
<q-item-section side>
<div class="row items-center">
<q-toggle
size="md"
:label="$t('enabled')"
v-model="formData.revolut_enabled"
color="green"
unchecked-icon="clear"
/>
</div>
</q-item-section>
</template>
<q-card class="q-pb-xl">
<q-expansion-item :label="$t('api')" default-opened>
<q-card-section class="q-pa-md">
<q-input
filled
type="text"
v-model="formData.revolut_api_endpoint"
:label="$t('endpoint')"
></q-input>
<q-input
filled
class="q-mt-md"
:type="hideInputToggle ? 'password' : 'text'"
v-model="formData.revolut_api_secret_key"
label="API secret key"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.revolut_api_version"
:label="$t('api_version')"
></q-input>
<q-input
filled
class="q-mt-md"
type="text"
v-model="formData.revolut_payment_success_url"
:label="$t('callback_success_url')"
:hint="$t('callback_success_url_hint')"
></q-input>
</q-card-section>
<q-card-section class="q-pa-md">
<div class="row">
<div class="col">
<q-btn
outline
color="grey"
class="float-right"
:label="$t('check_connection')"
@click="checkFiatProvider('revolut')"
></q-btn>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('webhook')" default-opened>
<q-card-section>
Configure a Revolut Merchant webhook that points to your LNbits
server. LNbits will create it through the Revolut API and
subscribe to <code>ORDER_AUTHORISED</code>,
<code>ORDER_COMPLETED</code>, and
<code>SUBSCRIPTION_INITIATED</code>.
</q-card-section>
<q-card-section>
<div class="row items-center q-gutter-sm q-mt-md">
<div class="col">
<q-input
filled
type="text"
disable
:model-value="revolutWebhookUrl"
:label="$t('webhook_url')"
readonly
></q-input>
</div>
<div class="col-auto">
<q-btn
outline
color="grey"
icon="content_copy"
@click="copyWebhookUrl(revolutWebhookUrl)"
:aria-label="$t('copy_webhook_url')"
>
<q-tooltip>
<span v-text="$t('copy_webhook_url')"></span>
</q-tooltip>
</q-btn>
</div>
</div>
<div class="row items-center q-gutter-sm q-mt-md">
<q-btn
type="button"
color="primary"
icon="add_link"
label="Create webhook"
:loading="creatingRevolutWebhook"
@click="createRevolutWebhook"
></q-btn>
<q-chip
v-if="formData.revolut_webhook_signing_secret"
dense
color="positive"
text-color="white"
icon="verified"
>
Signing secret saved
</q-chip>
</div>
</q-card-section>
<q-card-section>
<span v-text="$t('webhook_events_list')"></span>
<ul>
<li>
<code>ORDER_AUTHORISED</code>
</li>
<li>
<code>ORDER_COMPLETED</code>
</li>
<li>
<code>SUBSCRIPTION_INITIATED</code>
</li>
</ul>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('service_fee')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_fee_percent"
@update:model-value="formData.touch = null"
:label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_max_fee_sats"
@update:model-value="formData.touch = null"
:label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="text"
v-model="formData.revolut_limits.service_fee_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('fee_wallet_label')"
:hint="$t('fee_wallet_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('amount_limits')">
<q-card-section>
<div class="row">
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_min_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('min_incoming_payment_amount')"
:hint="$t('min_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
type="number"
min="0"
v-model="formData.revolut_limits.service_max_amount_sats"
@update:model-value="formData.touch = null"
:label="$t('max_incoming_payment_amount')"
:hint="$t('max_incoming_payment_amount_desc')"
></q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
class="q-ma-sm"
v-model="formData.revolut_limits.service_faucet_wallet_id"
@update:model-value="formData.touch = null"
:label="$t('faucest_wallet_id')"
:hint="$t('faucest_wallet_id_hint')"
></q-input>
</div>
</div>
</q-card-section>
</q-expansion-item>
<q-expansion-item :label="$t('allowed_users')">
<q-card-section>
<q-input
filled
v-model="formAddRevolutUser"
@keydown.enter="addRevolutAllowedUser"
type="text"
:label="$t('allowed_users_label')"
:hint="
$t('allowed_users_hint_feature', {
feature: 'Revolut'
})
"
>
<q-btn
@click="addRevolutAllowedUser"
dense
flat
icon="add"
></q-btn>
</q-input>
<div>
<q-chip
v-for="user in formData.revolut_limits.allowed_users"
@update:model-value="formData.touch = null"
:key="user"
removable
@remove="removeRevolutAllowedUser(user)"
color="primary"
text-color="white"
:label="user"
class="ellipsis"
>
</q-chip>
</div>
</q-card-section>
</q-expansion-item>
<q-card>
<q-card-section> Coming Soon </q-card-section>
</q-card>
</q-expansion-item>
</q-list>
@@ -1172,22 +640,9 @@
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">Square</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
<q-chip dense color="warning" text-color="black" icon="schedule"
>Subscriptions coming soon</q-chip
>
<q-chip dense color="negative" text-color="white" icon="close"
>Tap-to-pay</q-chip
>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Square-supported countries</q-chip
>
</div>
<div class="row items-center q-gutter-sm">
<div class="text-bold" style="min-width: 140px">Revolut</div>
<div class="text-bold" style="min-width: 140px">
Square (coming soon)
</div>
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
@@ -1198,7 +653,7 @@
>Tap-to-pay</q-chip
>
<q-chip dense color="grey-9" text-color="white" icon="public"
>Regions: Revolut-supported countries</q-chip
>Regions: Global</q-chip
>
</div>
</div>
+29 -29
View File
@@ -75,35 +75,7 @@
</p>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<div class="row q-col-gutter-md q-mt-lg">
<div class="row q-col-gutter-md">
<div class="col-12">
<h6 class="q-my-none">
<span v-text="$t('routing_fee_reserve_calculations')"></span>
@@ -210,6 +182,34 @@
></q-input>
</div>
</div>
<div v-if="isSuperUser">
<lnbits-admin-funding-sources
:form-data="formData"
:allowed-funding-sources="settings.lnbits_allowed_funding_sources"
/>
<div class="row q-col-gutter-md q-my-md">
<div class="col-12 col-sm-8">
<q-item tag="div">
<q-item-section>
<q-item-label
v-text="$t('funding_source_retries')"
></q-item-label>
<q-item-label
caption
v-text="$t('funding_source_retries_desc')"
></q-item-label>
</q-item-section>
<q-item-section>
<q-input
filled
v-model="formData.funding_source_max_retries"
type="number"
/>
</q-item-section>
</q-item>
</div>
</div>
</div>
<q-separator></q-separator>
<h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p>
@@ -52,7 +52,6 @@
:label="prop.label"
:hint="prop.hint"
:value="prop.value"
:readonly="prop.readonly || false"
>
<q-btn
v-if="prop.copy"
@@ -74,15 +73,6 @@
></q-btn>
</q-input>
</div>
<p
v-if="fund === 'PhoenixdWallet' && key === 'phoenixd_mnemonic'"
class="col-12 q-my-md"
>
<span>
<q-icon name="warning" color="orange" size="xs"></q-icon>
<span v-text="$t('phoenixd_warning')"></span>
</span>
</p>
</div>
<q-expansion-item
v-if="
@@ -251,27 +251,10 @@
type="text"
v-model="formData.lnbits_default_bgimage"
label="Background Image"
@update:model-value="applyGlobalBgimage"
hint="This must be a trusted source. It can change the content and it can log your IP address."
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.adminBackgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
</q-input>
<input
type="file"
ref="adminBackgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
@@ -320,15 +303,6 @@
>
</q-toggle>
</div>
<div class="col-12 col-sm-6 col-lg-2">
<q-toggle
type="bool"
v-model="formData.lnbits_default_burger_menu_background"
color="primary"
:label="$t('burger_menu_background')"
>
</q-toggle>
</div>
</div>
</div>
</q-card-section>
+68 -158
View File
@@ -492,28 +492,10 @@
siteCustomisationChanged({bgimageChoice: $event})
"
>
<template v-slot:append>
<q-btn
dense
flat
round
icon="upload"
@click="$refs.backgroundImageInput.click()"
>
<q-tooltip>Upload background image</q-tooltip>
</q-btn>
</template>
<q-tooltip
><span v-text="$t('background_image')"></span
></q-tooltip>
</q-input>
<input
type="file"
ref="backgroundImageInput"
accept="image/*"
style="display: none"
@change="onBackgroundImageInput"
/>
</div>
</div>
<div class="row q-mb-md">
@@ -601,30 +583,6 @@
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('burger_menu_background')"></span>
</div>
<div class="col-8">
<q-toggle
dense
flat
round
icon="menu_open"
v-model="g.burgerMenuChoice"
@update:model-value="
siteCustomisationChanged({burgerMenuChoice: $event})
"
>
<q-tooltip
><span
v-text="$t('toggle_burger_menu_background')"
></span
></q-tooltip>
</q-toggle>
</div>
</div>
<div class="row q-mb-md">
<div class="col-4">
<span v-text="$t('toggle_darkmode')"></span>
@@ -1150,130 +1108,82 @@
<q-separator></q-separator>
<q-card-section>
<div
class="row items-center no-wrap q-col-gutter-sm"
<q-btn-dropdown
color="grey"
dense
outline
no-caps
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
>
<div class="col">
<q-btn-dropdown
color="grey"
dense
outline
no-caps
class="full-width"
:label="props.row.name"
:icon="props.row.is_public ? 'public' : ''"
<q-list>
<q-item
clickable
v-close-popup
@click="copyAssetLinkToClipboard(props.row)"
>
<q-list>
<q-item
clickable
v-close-popup
@click="
copyAssetLinkToClipboard(props.row)
"
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
<q-item-section avatar>
<q-avatar icon="content_copy" />
</q-item-section>
<q-item-section>
<q-item-label>Copy Link</q-item-label>
<q-item-label caption
>Copy asset link to
clipboard</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="
toggleAssetPublicAccess(props.row)
<q-item
clickable
v-close-popup
@click="toggleAssetPublicAccess(props.row)"
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section v-if="props.row.is_public">
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset private</q-item-label
>
<q-item-section avatar>
<q-avatar
:icon="
props.row.is_public
? 'public_off'
: 'public'
"
text-color="primary"
/>
</q-item-section>
<q-item-section
v-if="props.row.is_public"
>
<q-item-label>Unpublish</q-item-label>
<q-item-label caption
>Make this asset
private</q-item-label
>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset
public</q-item-label
>
</q-item-section>
</q-item>
</q-item-section>
<q-item-section v-else>
<q-item-label>Publish</q-item-label>
<q-item-label caption
>Make this asset public</q-item-label
>
</q-item-section>
</q-item>
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
<q-item
clickable
v-close-popup
@click="deleteAsset(props.row)"
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
<q-item-section avatar>
<q-avatar
icon="delete"
text-color="negative"
/>
</q-item-section>
<q-item-section>
<q-item-label>Delete</q-item-label>
<q-item-label caption
>Permanently delete this
asset</q-item-label
>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</div>
<div class="col-auto">
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="primary"
dense
flat
round
icon="image"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-tooltip>Full image</q-tooltip>
</q-btn>
</div>
<div
class="col-auto"
v-if="props.row.thumbnail_base64"
>
<q-btn
type="a"
target="_blank"
rel="noopener noreferrer"
color="secondary"
dense
flat
round
icon="photo_size_select_small"
:href="`/api/v1/assets/${props.row.id}/thumbnail`"
>
<q-tooltip>Thumbnail</q-tooltip>
</q-btn>
</div>
</div>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</q-card-section>
</q-card>
</div>
-38
View File
@@ -394,44 +394,6 @@
<span v-text="$t('pay_with', {provider: 'PayPal'})"></span>
</q-item-section>
</q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('square')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('square')"
:active="receive.fiatProvider === 'square'"
@click="receive.fiatProvider = 'square'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar>
<q-img src="/static/images/square_logo.png"></q-img>
</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Square'})"></span>
</q-item-section>
</q-item>
<q-separator
v-if="g.user.fiat_providers?.includes('revolut')"
></q-separator>
<q-item
v-if="g.user.fiat_providers?.includes('revolut')"
:active="receive.fiatProvider === 'revolut'"
@click="receive.fiatProvider = 'revolut'"
active-class="bg-teal-1 text-grey-8 text-weight-bold"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar color="deep-orange-7" text-color="white">R</q-avatar>
</q-item-section>
<q-item-section>
<span v-text="$t('pay_with', {provider: 'Revolut'})"></span>
</q-item-section>
</q-item>
</q-list>
</div>
+2 -52
View File
@@ -4,11 +4,9 @@ import hashlib
import json
import urllib.parse
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any
import httpx
from embit.bip39 import mnemonic_is_valid
from httpx import RequestError, TimeoutException
from loguru import logger
from websockets import connect
@@ -63,8 +61,6 @@ class PhoenixdWallet(Wallet):
}
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
self._seed_mnemonic_to_persist: str | None = None
self._load_mnemonic_from_seed_file()
async def cleanup(self):
try:
@@ -73,7 +69,6 @@ class PhoenixdWallet(Wallet):
logger.warning(f"Error closing wallet connection: {e}")
async def status(self) -> StatusResponse:
await self._persist_loaded_mnemonic()
try:
r = await self.client.get("/getinfo", timeout=10)
r.raise_for_status()
@@ -106,6 +101,7 @@ class PhoenixdWallet(Wallet):
unhashed_description: bytes | None = None,
**kwargs,
) -> InvoiceResponse:
try:
msats_amount = amount
data: dict[str, Any] = {
@@ -313,7 +309,7 @@ class PhoenixdWallet(Wallet):
and message_json.get("type") == "payment_received"
):
logger.info(
f"payment-received: {message_json['paymentHash']}"
f'payment-received: {message_json["paymentHash"]}'
)
yield message_json["paymentHash"]
@@ -323,49 +319,3 @@ class PhoenixdWallet(Wallet):
"retrying in 5 seconds"
)
await asyncio.sleep(5)
def _load_mnemonic_from_seed_file(self):
data_dir = settings.phoenixd_data_dir
if not data_dir:
return
seed_path = Path(data_dir).expanduser() / "seed.dat"
if not seed_path.is_file():
return
try:
mnemonic = seed_path.read_text(encoding="utf-8").strip()
if mnemonic == settings.phoenixd_mnemonic:
return
except OSError as exc:
logger.warning(f"Failed to read Phoenixd seed file '{seed_path}': {exc}")
return
if not mnemonic:
logger.warning(f"Phoenixd seed file '{seed_path}' is empty.")
return
if not mnemonic_is_valid(mnemonic):
logger.warning(
f"Phoenixd seed file '{seed_path}' does not contain a valid "
"BIP39 mnemonic."
)
return
settings.phoenixd_mnemonic = mnemonic
self._seed_mnemonic_to_persist = mnemonic
async def _persist_loaded_mnemonic(self):
if not self._seed_mnemonic_to_persist:
return
logger.info("Updating 'PHOENIXD_MNEMONIC' mnemonic settings.")
try:
from lnbits.core.crud.settings import set_settings_field
await set_settings_field(
"phoenixd_mnemonic", self._seed_mnemonic_to_persist
)
self._seed_mnemonic_to_persist = None
except Exception as exc:
logger.warning(f"Failed to persist Phoenixd mnemonic: {exc}")
+122 -23
View File
@@ -80,22 +80,40 @@ class SparkL2Wallet(Wallet):
async def status(self) -> StatusResponse:
try:
res = await self._request("POST", "/v1/balance")
status = res.get("status")
r = await self.client.post("/v1/balance", timeout=30)
r.raise_for_status()
data = r.json()
if not isinstance(data, dict) or len(data) == 0:
return StatusResponse("no data", 0)
error_message = self._extract_error_message(data)
if error_message:
return StatusResponse(self._server_error_message(error_message), 0)
status = data.get("status")
if status == "missing_mnemonic":
await self._check_sidecar_mnemonic()
return StatusResponse("Spark sidecar mnemonic not set", 0)
balance_msat = res.get("balance_msat")
balance_msat = data.get("balance_msat")
if balance_msat is not None:
return StatusResponse(None, int(balance_msat))
balance_sats = res.get("balance_sats")
balance_sats = data.get("balance_sats")
if balance_sats is None:
return StatusResponse("Spark sidecar: missing balance.", 0)
return StatusResponse("no data", 0)
return StatusResponse(None, int(balance_sats) * 1000)
except json.JSONDecodeError as e:
logger.warning(e)
return StatusResponse("Server error: 'invalid json response'", 0)
except httpx.HTTPStatusError as e:
logger.warning(e)
error_message = self._extract_http_error_message(e.response)
if error_message:
return StatusResponse(self._server_error_message(error_message), 0)
return StatusResponse(self._connect_error_message(), 0)
except Exception as e:
logger.warning(e)
return StatusResponse(f"Spark sidecar status error: {e}", 0)
return StatusResponse(self._connect_error_message(), 0)
async def create_invoice(
self,
@@ -121,13 +139,28 @@ class SparkL2Wallet(Wallet):
"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")
r = await self.client.post("/v1/invoices", json=payload, timeout=30)
r.raise_for_status()
data = r.json()
if not isinstance(data, dict):
return InvoiceResponse(
ok=False, error_message=self._server_error_message(r.text)
)
error_message = self._extract_error_message(data)
if error_message:
return InvoiceResponse(
ok=False,
error_message=self._server_error_message(error_message),
)
bolt11 = data.get("payment_request")
checking_id = data.get("checking_id")
if not bolt11 or not checking_id:
return InvoiceResponse(
ok=False,
error_message="Spark sidecar invoice response missing fields.",
error_message="Server error: 'missing required fields'",
)
self.pending_invoices.append(checking_id)
@@ -135,10 +168,24 @@ class SparkL2Wallet(Wallet):
ok=True,
payment_request=bolt11,
checking_id=checking_id,
preimage=res.get("preimage", None),
preimage=data.get("preimage", None),
)
except json.JSONDecodeError:
return InvoiceResponse(
ok=False, error_message="Server error: 'invalid json response'"
)
except httpx.HTTPStatusError as e:
logger.warning(e)
error_message = self._extract_http_error_message(e.response)
if error_message:
return InvoiceResponse(
ok=False,
error_message=self._server_error_message(error_message),
)
return InvoiceResponse(ok=False, error_message=self._connect_error_message())
except Exception as e:
return InvoiceResponse(ok=False, error_message=str(e))
logger.warning(e)
return InvoiceResponse(ok=False, error_message=self._connect_error_message())
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
try:
@@ -158,27 +205,53 @@ class SparkL2Wallet(Wallet):
"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")
r = await self.client.post("/v1/payments", json=payload, timeout=30)
r.raise_for_status()
data = r.json()
if not isinstance(data, dict):
return PaymentResponse(error_message=self._server_error_message(r.text))
error_message = self._extract_error_message(data)
if error_message:
return PaymentResponse(error_message=error_message)
if len(data) == 0:
return PaymentResponse(
error_message="Server error: 'missing required fields'"
)
status = data.get("status")
fee_msat = data.get("fee_msat")
preimage = data.get("preimage")
ok = self._map_payment_ok(status) if status else None
if ok is False:
return PaymentResponse(ok=False)
checking_id = payment_hash or data.get("checking_id")
if not checking_id:
return PaymentResponse(
ok=False,
error_message="Spark sidecar payment response missing checking_id.",
error_message="Server error: 'missing required fields'"
)
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"),
preimage=preimage,
)
except json.JSONDecodeError:
return PaymentResponse(error_message="Server error: 'invalid json response'")
except httpx.HTTPStatusError as e:
logger.warning(e)
error_message = self._extract_http_error_message(e.response)
if error_message:
return PaymentResponse(error_message=error_message)
return PaymentResponse(error_message=self._connect_error_message())
except Exception as e:
return PaymentResponse(ok=False, error_message=str(e))
logger.warning(e)
return PaymentResponse(error_message=self._connect_error_message())
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
try:
@@ -330,6 +403,32 @@ class SparkL2Wallet(Wallet):
return False
return None
def _connect_error_message(self) -> str:
return f"Unable to connect to {self.endpoint}."
@staticmethod
def _server_error_message(message: str) -> str:
return f"Server error: '{message}'"
@staticmethod
def _extract_error_message(data: Any) -> str | None:
if not isinstance(data, dict):
return None
for key in ("error", "message", "detail", "reason"):
value = data.get(key)
if value:
return str(value)
return None
def _extract_http_error_message(self, response: httpx.Response | None) -> str | None:
if response is None:
return None
try:
data = response.json()
except Exception:
return None
return self._extract_error_message(data)
async def _check_sidecar_mnemonic(self):
if settings.spark_l2_mnemonic:
valid = mnemonic_is_valid(settings.spark_l2_mnemonic)
+197 -673
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -15,24 +15,24 @@
"devDependencies": {
"clean-css-cli": "^5.6.3",
"concat": "^1.0.3",
"prettier": "^3.8.3",
"prettier": "^3.7.4",
"pyright": "1.1.289",
"sass": "^1.99.0",
"terser": "^5.47.1"
"sass": "^1.94.2",
"terser": "^5.44.1"
},
"dependencies": {
"axios": "^1.16.0",
"axios": "^1.13.5",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.23.3",
"qrcode.vue": "^3.9.0",
"quasar": "2.19.3",
"nostr-tools": "^2.18.2",
"qrcode.vue": "^3.6.0",
"quasar": "2.18.6",
"showdown": "^2.1.0",
"underscore": "^1.13.8",
"vue": "3.5.34",
"vue-i18n": "^11.4.2",
"vue": "3.5.25",
"vue-i18n": "^11.2.2",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "5.0.6",
"vue-router": "4.6.3",
"vuex": "4.1.0"
},
"vendor": [
Generated
+6 -88
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@@ -3388,88 +3388,6 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyinstrument"
version = "5.1.2"
description = "Call stack profiler for Python. Shows you why your code is slow!"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f224fe80ba288a00980af298d3808219f9d246fd95b4f91729c9c33a0dc54fe6"},
{file = "pyinstrument-5.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7df09fc0d5b72daf48b73cdf07738761bff7f656c81aff686b3ccdd7d2abe236"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75a7e17377d4405666bbaf126b1fd7bbb7e206d7246e6db3d62864d3d4790ae3"},
{file = "pyinstrument-5.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5381cc6583d26e04d9298acded4242f4fe71986f1472c8aee6992c6816f0cac5"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ec08a530bef8d3492d31d8b0b12d0cfde09539f2a1c4b9678662ebc3c843e478"},
{file = "pyinstrument-5.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9d671168508129b472be570bc9aee361190ba917b997c703bd134bb4de445ce7"},
{file = "pyinstrument-5.1.2-cp310-cp310-win32.whl", hash = "sha256:5957a94f84564b374a7f856d1b322345d600964280b0d687b8ddcc483f21e576"},
{file = "pyinstrument-5.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:38a2180a7801c51610b50e5d423674b21872efd019ccf05a11b7f9016cb1dcfc"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3739a05583ea6312c385eb59fe985cd20d9048e95f9eeeb6a2f6c35202e2d36e"},
{file = "pyinstrument-5.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c9ee05dc75ac5fb18498c311e624f77f7f321f7ff325b251aa09e52e46f1d6a"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a49a55ca5b75218767e29cacbe515d0b66fc18cb48a937bca0f77b8dafc7202"},
{file = "pyinstrument-5.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c45c14974ff04b1bfdc6c2a448627c6da7409c7800d0eb7bd03fb435dcb41d7"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:22b9c04b3982c41c04b1c5ed05d1bc3a2ba26533450084058119f6dc160e70a3"},
{file = "pyinstrument-5.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5c4995ee0774801790c138f0dfec17d4e7a7ef09a6d56d53cbcbf0578a711021"},
{file = "pyinstrument-5.1.2-cp311-cp311-win32.whl", hash = "sha256:fe449e4a8ee60a2a27cf509350a584670f4c3704649601be7937598f09dbe7ca"},
{file = "pyinstrument-5.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:3fb839429671a42bf349335af4c1ce5cf83386ac11f04df0bc40720d4cb7d77d"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2519865d4bf58936f2506c1c46a82d29a20f3239aa50c941df1ca9618c7da5f0"},
{file = "pyinstrument-5.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:059442106b8b5de29ae5ac1bdc20d044fed4da534b8caba434b6ffb119037bf5"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd51f2d54fc39a4cfd73ba6be27cd0187123132ce3f445b639bff5e1b23d7e26"},
{file = "pyinstrument-5.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12af1e83795b6c640d657d339014dd1ff718b182dec736d7d1f1d8a97534eb53"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2565513658e742c5eb691a779cb29d19d01bc9ee951d0eb76482e9f343c38c2e"},
{file = "pyinstrument-5.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5afd0ba788a1d112da49fb77966918e01df1f9e7d62e72894d82f7acb0996c2d"},
{file = "pyinstrument-5.1.2-cp312-cp312-win32.whl", hash = "sha256:554077b031b278593cb2301f0057be771ea62a729878c69aaf29fcdfb7b71281"},
{file = "pyinstrument-5.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:55a905384ba43efc924b8863aa6cfd276f029e4aa70c4a0e3b7389e27b191e45"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29"},
{file = "pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a"},
{file = "pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5"},
{file = "pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e"},
{file = "pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0"},
{file = "pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0baed297beee2bb9897e737bbd89e3b9d45a2fbbea9f1ad4e809007d780a9b1e"},
{file = "pyinstrument-5.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ebb910a32a45bde6c3fc30c578efc28a54517990e11e94b5e48a0d5479728568"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bad403c157f9c6dba7f731a6fca5bfcd8ca2701a39bcc717dcc6e0b10055ffc4"},
{file = "pyinstrument-5.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f456cabdb95fd343c798a7f2a56688b028f981522e283c5f59bd59195b66df5"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e9c4dcc1f2c4a0cd6b576e3604abc37496a7868243c9a1443ad3b9db69d590f"},
{file = "pyinstrument-5.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:acf93b128328c6d80fdb85431068ac17508f0f7845e89505b0ea6130dead5ca6"},
{file = "pyinstrument-5.1.2-cp314-cp314-win32.whl", hash = "sha256:9c7f0167903ecff8b1d744f7e37b2bd4918e05a69cca724cb112f5ed59d1e41b"},
{file = "pyinstrument-5.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:ce3f6b1f9a2b5d74819ecc07d631eadececf915f551474a75ad65ac580ec5a0e"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:af8651b239049accbeecd389d35823233f649446f76f47fd005316b05d08cef2"},
{file = "pyinstrument-5.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c6082f1c3e43e1d22834e91ba8975f0080186df4018a04b4dd29f9623c59df1d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c031eb066ddc16425e1e2f56aad5c1ce1e27b2432a70329e5385b85e812decee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f447ec391cad30667ba412dce41607aaa20d4a2496a7ab867e0c199f0fe3ae3d"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:50299bddfc1fe0039898f895b10ef12f9db08acffb4d85326fad589cda24d2ee"},
{file = "pyinstrument-5.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a193ff08825ece115ececa136832acb14c491c77ab1e6b6a361905df8753d5c6"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win32.whl", hash = "sha256:de887ba19e1057bd2d86e6584f17788516a890ae6fe1b7eed9927873f416b4d8"},
{file = "pyinstrument-5.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b6a71f5e7f53c86c9b476b30cf19509463a63581ef17ddbd8680fee37ae509db"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:47f14f248108f1202d48f34903bddc053a47c62ce46908aee848c1f667a1925b"},
{file = "pyinstrument-5.1.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc3f2688981af764fa2c5f5a00d7040c0c12771ca5a026730f2d826f7a28d277"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7afb24d11d24fb1762059240ed97a1a4607779d5f221f91d62b67ae089bd506d"},
{file = "pyinstrument-5.1.2-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d21221a29718d5dcdc1453dbbbdd100734525672ef1e34ff03d49bc5d688ca4"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3b68aa9f0d5217c67370762c99a80750ce56f66e5e9281c31b5baa3e4b88894d"},
{file = "pyinstrument-5.1.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:755702f01800934c3bec3c0d30483f97b6ff1fc1d2afcf6d816584703c9f1235"},
{file = "pyinstrument-5.1.2-cp38-cp38-win32.whl", hash = "sha256:2bacb980c95d4c9ea6a253e5ccf3e99993082de29ff7e7fc397e9484355577b1"},
{file = "pyinstrument-5.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:2588bc34c25d50f29d3a117c7dfac06513ee59c507b65081e66ca9569bcf45e0"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bea0687665c181c6e62677fb560739a473c4286816582a43f8eb0aa6094ed529"},
{file = "pyinstrument-5.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:64246d2bd475870b62ed5df4808bfb33328135e8dfbdff823f9cb7d1358eb40b"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff6fd3c7907e57f082cdd405bec1b34768c1810a165538299d259ee1bff5d7b6"},
{file = "pyinstrument-5.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:af09af38ee8407ca273407e24a8e6470d2444561d01005ee6a8db5f2fd908c08"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1249d2799cdd57151b4444167e5c7736e2c9b5e79bd781b0779d631338509553"},
{file = "pyinstrument-5.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d653206f50260f20bc78339c3d7aa0f19f8cf9c9f71939fbf02e2ea30353487"},
{file = "pyinstrument-5.1.2-cp39-cp39-win32.whl", hash = "sha256:d0b0c6e289725f14d0ff73f8190c953bdcb98f21c5c29c3eafb0dca8025583cb"},
{file = "pyinstrument-5.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:db8243e602aca43dc7ce8e40ed7d0ca4820d024c3c03824870c5a9e98f84e953"},
{file = "pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0"},
]
[package.extras]
bin = ["click", "nox"]
docs = ["furo (==2024.7.18)", "myst-parser (==3.0.1)", "sphinx (==7.4.7)", "sphinx-autobuild (==2024.4.16)", "sphinxcontrib-programoutput (==0.17)"]
examples = ["django", "litestar", "numpy"]
test = ["cffi (>=1.17.0)", "flaky", "greenlet (>=3)", "ipython", "pytest", "pytest-asyncio (==0.23.8)", "trio"]
types = ["typing_extensions"]
[[package]]
name = "pyjwt"
version = "2.12.1"
@@ -4560,14 +4478,14 @@ files = [
[[package]]
name = "urllib3"
version = "2.7.0"
version = "2.6.3"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
]
[package.extras]
@@ -5110,4 +5028,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "4050934800e6dfcc5242d1847d3db69eb6e91d634c09368a28255ad3c908b568"
content-hash = "42751c05e1fa8250ff90981def5e0ce8d1b284eeb2acc2c7ac9ae5847cf99e17"
+2 -7
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.4"
version = "1.5.3"
requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -51,8 +51,6 @@ dependencies = [
"pillow~=12.1.0",
"python-dotenv~=1.2.1",
"greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
]
[project.scripts]
@@ -84,12 +82,9 @@ dev = [
"pytest-mock~=3.15.1",
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"grpcio-tools~=1.76.0"
]
[tool.uv]
exclude-newer = "1 week"
[tool.poetry]
packages = [
{include = "lnbits"},
-109
View File
@@ -1,9 +1,6 @@
from pathlib import Path
import pytest
from httpx import AsyncClient
from lnbits.server import server_restart
from lnbits.settings import Settings
@@ -52,109 +49,3 @@ async def test_admin_update_noneditable_settings(
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 400
@pytest.mark.anyio
async def test_admin_audit_monitor_and_test_email(
client: AsyncClient, superuser_token: str, mocker
):
mocker.patch(
"lnbits.core.views.admin_api.get_balance_delta",
mocker.AsyncMock(
return_value={"lnbits_balance_sats": 21, "node_balance_sats": 13}
),
)
mocker.patch(
"lnbits.core.views.admin_api.send_email_notification",
mocker.AsyncMock(return_value={"status": "queued"}),
)
audit = await client.get(
"/admin/api/v1/audit",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert audit.status_code == 200
assert audit.json()["lnbits_balance_sats"] == 21
monitor = await client.get(
"/admin/api/v1/monitor",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert monitor.status_code == 200
assert "invoice_listeners" in monitor.json()
test_email = await client.get(
"/admin/api/v1/testemail",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert test_email.status_code == 200
assert test_email.json()["status"] == "queued"
@pytest.mark.anyio
async def test_admin_partial_reset_restart_and_backup(
client: AsyncClient,
superuser_token: str,
settings: Settings,
tmp_path,
):
response = await client.patch(
"/admin/api/v1/settings",
json={"lnbits_site_title": "PATCHED TITLE"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["status"] == "Success"
default_value = await client.get(
"/admin/api/v1/settings/default",
params={"field_name": "lnbits_site_title"},
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert default_value.status_code == 200
assert "default_value" in default_value.json()
backup_path = Path("lnbits-backup.zip")
original_data_folder = settings.lnbits_data_folder
try:
data_folder = tmp_path / "backup_data"
data_folder.mkdir(parents=True, exist_ok=True)
(data_folder / "sample.txt").write_text("backup me")
settings.lnbits_data_folder = str(data_folder)
backup = await client.get(
"/admin/api/v1/backup",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert backup.status_code == 200
assert backup.headers["content-type"] == "application/zip"
assert backup.content.startswith(b"PK")
assert backup_path.is_file()
finally:
settings.lnbits_data_folder = original_data_folder
backup_path.unlink(missing_ok=True)
server_restart.clear()
restart = await client.get(
"/admin/api/v1/restart",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert restart.status_code == 200
assert restart.json()["status"] == "Success"
assert server_restart.is_set() is True
server_restart.clear()
@pytest.mark.anyio
async def test_admin_delete_settings_requires_superuser(
client: AsyncClient, superuser_token: str
):
server_restart.clear()
response = await client.delete(
"/admin/api/v1/settings",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert server_restart.is_set() is True
server_restart.clear()
+2 -2
View File
@@ -507,9 +507,9 @@ async def test_api_payment_without_key(invoice: Payment):
# check api_payment() internal function call (NOT API): payment status
@pytest.mark.anyio
async def test_api_payment_with_key(invoice: Payment, inkey_headers_to):
async def test_api_payment_with_key(invoice: Payment, inkey_headers_from):
# check the payment status
response = await api_payment(invoice.payment_hash, inkey_headers_to["X-Api-Key"])
response = await api_payment(invoice.payment_hash, inkey_headers_from["X-Api-Key"])
assert isinstance(response, dict)
assert response["paid"] is True
assert "details" in response
-186
View File
@@ -1,186 +0,0 @@
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.assets import get_user_asset
from lnbits.core.services.assets import create_user_asset
from tests.helpers import get_png_bytes, get_user_token_headers, make_upload_file
@pytest.mark.anyio
async def test_asset_api_upload_list_update_and_delete(
client: AsyncClient,
user_headers_from: dict[str, str],
):
payload = get_png_bytes()
upload = await client.post(
"/api/v1/assets?public_asset=false",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("note.png", payload, "image/png")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.png"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
assert page.status_code == 200
assert any(item["id"] == asset["id"] for item in page.json()["data"])
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.png"
data = await client.get(
f"/api/v1/assets/{asset['id']}/data", headers=user_headers_from
)
assert data.status_code == 200
assert data.content == payload
assert data.headers["content-type"] == "image/png"
assert data.headers["content-disposition"] == 'inline; filename="note.png"'
assert data.headers["x-content-type-options"] == "nosniff"
assert data.headers["content-security-policy"].startswith("sandbox")
updated = await client.put(
f"/api/v1/assets/{asset['id']}",
headers=user_headers_from,
json={"name": "renamed.png", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.png"
assert updated.json()["is_public"] is True
public_data = await client.get(f"/api/v1/assets/{asset['id']}/data")
assert public_data.status_code == 200
assert public_data.content == payload
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert deleted.status_code == 200
assert deleted.json()["success"] is True
missing = await client.get(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
)
assert missing.status_code == 404
@pytest.mark.anyio
async def test_asset_api_enforces_visibility_and_supports_admin_updates(
client: AsyncClient,
from_user,
to_user,
superuser_token: str,
):
private_asset = await create_user_asset(
from_user.id,
make_upload_file(
get_png_bytes(),
filename=f"private_{uuid4().hex[:8]}.png",
content_type="image/png",
),
is_public=False,
)
other_user_headers = await get_user_token_headers(client, to_user.id)
anonymous = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert anonymous.status_code == 404
wrong_user = await client.get(
f"/api/v1/assets/{private_asset.id}/data", headers=other_user_headers
)
assert wrong_user.status_code == 404
admin_updated = await client.put(
f"/api/v1/assets/{private_asset.id}",
headers={"Authorization": f"Bearer {superuser_token}"},
json={"is_public": True, "name": "admin-visible.png"},
)
assert admin_updated.status_code == 200
assert admin_updated.json()["is_public"] is True
assert admin_updated.json()["name"] == "admin-visible.png"
image_data = await client.get(f"/api/v1/assets/{private_asset.id}/data")
assert image_data.status_code == 200
assert image_data.headers["content-type"] == "image/png"
assert image_data.headers["content-disposition"] == (
'inline; filename="admin-visible.png"'
)
assert image_data.headers["x-content-type-options"] == "nosniff"
thumbnail = await client.get(f"/api/v1/assets/{private_asset.id}/thumbnail")
assert thumbnail.status_code == 200
assert thumbnail.content
assert thumbnail.headers["content-type"] == "image/png"
assert thumbnail.headers["content-disposition"] == (
'inline; filename="admin-visible.png"'
)
@pytest.mark.anyio
async def test_asset_api_blocks_non_image_uploads(
client: AsyncClient,
user_headers_from: dict[str, str],
):
payload = (
b'<?xml version="1.0"?>'
b'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform">'
b'<xsl:template match="/">'
b"<script>alert(1)</script>"
b"</xsl:template>"
b"</xsl:stylesheet>"
)
blocked = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.xsl", payload, "text/xml")},
)
assert blocked.status_code == 400
assert blocked.json()["detail"] == "File type 'text/xml' not allowed."
@pytest.mark.anyio
async def test_asset_api_validates_uploads_and_missing_assets(
client: AsyncClient,
to_user,
user_headers_from: dict[str, str],
):
invalid = await client.post(
"/api/v1/assets",
headers={"Authorization": user_headers_from["Authorization"]},
files={"file": ("payload.exe", b"boom", "application/x-msdownload")},
)
assert invalid.status_code == 400
assert "not allowed" in invalid.json()["detail"]
fake_image_headers = await get_user_token_headers(client, to_user.id)
fake_image = await client.post(
"/api/v1/assets",
headers={"Authorization": fake_image_headers["Authorization"]},
files={"file": ("fake.png", b"<root></root>", "image/png")},
)
assert fake_image.status_code == 400
assert "does not match declared file type" in fake_image.json()["detail"]
missing = await client.delete(
f"/api/v1/assets/{uuid4().hex}",
headers=user_headers_from,
)
assert missing.status_code == 404
missing_thumb = await client.get(f"/api/v1/assets/{uuid4().hex}/thumbnail")
assert missing_thumb.status_code == 404
stored = await create_user_asset(
"missing-user-check",
make_upload_file(
get_png_bytes(), filename="content.png", content_type="image/png"
),
is_public=True,
)
fetched = await get_user_asset("missing-user-check", stored.id)
assert fetched is not None
-64
View File
@@ -1,64 +0,0 @@
from datetime import datetime, timezone
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.crud.audit import create_audit_entry
from lnbits.core.models import AuditEntry
@pytest.mark.anyio
async def test_audit_api_requires_admin(client: AsyncClient, user_headers_from):
response = await client.get("/audit/api/v1", headers=user_headers_from)
assert response.status_code == 403
@pytest.mark.anyio
async def test_audit_api_returns_entries_and_stats(
client: AsyncClient,
superuser_token: str,
):
component = f"audit_component_{uuid4().hex[:8]}"
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.1",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="GET",
response_code="200",
duration=0.12,
created_at=datetime.now(timezone.utc),
)
)
await create_audit_entry(
AuditEntry(
component=component,
ip_address="127.0.0.2",
user_id=uuid4().hex,
path="/api/v1/test",
request_method="POST",
response_code="400",
duration=2.5,
created_at=datetime.now(timezone.utc),
)
)
headers = {"Authorization": f"Bearer {superuser_token}"}
page = await client.get(f"/audit/api/v1?component={component}", headers=headers)
assert page.status_code == 200
page_data = page.json()
assert page_data["total"] == 2
assert {item["request_method"] for item in page_data["data"]} == {"GET", "POST"}
stats = await client.get(
f"/audit/api/v1/stats?component={component}",
headers=headers,
)
assert stats.status_code == 200
payload = stats.json()
assert {item["field"] for item in payload["request_method"]} == {"GET", "POST"}
assert {item["field"] for item in payload["response_code"]} == {"200", "400"}
assert payload["component"][0]["field"] == component
assert payload["long_duration"][0]["field"] == "/api/v1/test"
-203
View File
@@ -1,203 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi.responses import RedirectResponse
from httpx import AsyncClient
from lnbits.core.crud.users import get_account, update_account
from lnbits.core.models.users import Account
from lnbits.core.services.users import create_user_account
from lnbits.core.views.auth_api import get_account_by_email
from lnbits.settings import Settings
class _FakeSSO:
def __init__(self, userinfo: object | None = None, state: str = ""):
self.userinfo = userinfo
self.state = state
self.redirect_uri: str | None = None
def __enter__(self):
return self
def __exit__(self, *_args):
return False
async def get_login_redirect(self, state: str):
self.state = state
return RedirectResponse("https://example.com/sso/login")
async def verify_and_process(self, _request):
return self.userinfo
@pytest.mark.anyio
async def test_auth_api_logout_and_update_ui_customization(
http_client: AsyncClient,
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
response = await http_client.patch(
f"/api/v1/auth/ui?usr={user.id}",
json={"theme": "amber", "walletLayout": "grid"},
)
assert response.status_code == 200
assert response.json()["ui_customization"]["theme"] == "amber"
assert response.json()["ui_customization"]["walletLayout"] == "grid"
logout = await http_client.post("/api/v1/auth/logout")
assert logout.status_code == 200
assert logout.json()["status"] == "success"
assert "cookie_access_token=" in logout.headers["set-cookie"]
@pytest.mark.anyio
async def test_auth_api_sso_login_and_callback(http_client: AsyncClient, mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
provider = "github"
login_sso = _FakeSSO()
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=login_sso)
response = await http_client.get(
f"/api/v1/auth/{provider}", params={"user_id": user.id}
)
assert response.status_code == 307
assert response.headers["location"] == "https://example.com/sso/login"
assert login_sso.redirect_uri == f"{http_client.base_url}/api/v1/auth/github/token"
assert login_sso.state
email = f"sso_{uuid4().hex[:8]}@lnbits.com"
callback_sso = _FakeSSO(userinfo=SimpleNamespace(email=email), state="")
mocker.patch("lnbits.core.views.auth_api._new_sso", return_value=callback_sso)
callback = await http_client.get(f"/api/v1/auth/{provider}/token")
assert callback.status_code == 307
assert callback.headers["location"] == "/wallet"
account = await get_account_by_email(email, active_only=False)
assert account is not None
assert account.email == email
assert account.extra.email_verified is True
@pytest.mark.anyio
async def test_auth_api_first_install_success_and_validation(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_first_install_token = settings.first_install_token
first_install_token = f"install_{uuid4().hex[:8]}"
new_username = f"reinstall_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.first_install_token = first_install_token
missing_token = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert missing_token.status_code == 401
assert missing_token.json()["detail"] == "Missing first_install_token."
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
"first_install_token": first_install_token,
},
)
assert success.status_code == 200
assert success.json()["access_token"]
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" not in success.headers["set-cookie"]
updated_superuser = await get_account(settings.super_user, active_only=False)
assert updated_superuser is not None
assert updated_superuser.username == new_username
assert settings.first_install is False
forbidden = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": f"blocked_{uuid4().hex[:8]}",
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert forbidden.status_code == 403
assert forbidden.json()["detail"] == "This is not your first install"
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.first_install_token = original_first_install_token
@pytest.mark.anyio
async def test_auth_api_first_install_uses_secure_cookie_when_enabled(
http_client: AsyncClient, settings: Settings
):
superuser = await get_account(settings.super_user, active_only=False)
assert superuser is not None
original_username = superuser.username
original_password_hash = superuser.password_hash
original_first_install = settings.first_install
original_auth_https_only = settings.auth_https_only
new_username = f"secure_{uuid4().hex[:8]}"
try:
settings.first_install = True
settings.auth_https_only = True
success = await http_client.put(
"/api/v1/auth/first_install",
json={
"username": new_username,
"password": "secret1234",
"password_repeat": "secret1234",
},
)
assert success.status_code == 200
assert "cookie_access_token=" in success.headers["set-cookie"]
assert "Secure" in success.headers["set-cookie"]
finally:
restored_superuser = await get_account(settings.super_user, active_only=False)
assert restored_superuser is not None
restored_superuser.username = original_username
restored_superuser.password_hash = original_password_hash
await update_account(restored_superuser)
settings.first_install = original_first_install
settings.auth_https_only = original_auth_https_only
-633
View File
@@ -1,633 +0,0 @@
import json
from uuid import uuid4
import pytest
from httpx import AsyncClient
from lnbits.core.models import Account, CreateInvoice, Payment
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.callback_api import (
handle_paypal_event,
handle_revolut_event,
handle_square_event,
handle_stripe_event,
)
from lnbits.fiat.revolut import RevolutWallet
from lnbits.fiat.square import SquareWallet
from lnbits.settings import Settings
@pytest.mark.anyio
async def test_callback_api_generic_webhook_handler_routes_providers(
http_client: AsyncClient, mocker
):
stripe_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_stripe_event", mocker.AsyncMock()
)
paypal_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_paypal_event", mocker.AsyncMock()
)
square_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_square_event", mocker.AsyncMock()
)
revolut_mock = mocker.patch(
"lnbits.core.views.callback_api.handle_revolut_event", mocker.AsyncMock()
)
mocker.patch("lnbits.core.views.callback_api.check_stripe_signature")
mocker.patch("lnbits.core.views.callback_api.check_square_signature")
mocker.patch("lnbits.core.views.callback_api.check_revolut_signature")
mocker.patch(
"lnbits.core.views.callback_api.verify_paypal_webhook", mocker.AsyncMock()
)
stripe = await http_client.post(
"/api/v1/callback/stripe",
headers={"Stripe-Signature": "sig"},
json={"id": "evt_1", "type": "payment_intent.succeeded"},
)
assert stripe.status_code == 200
assert stripe.json()["success"] is True
stripe_mock.assert_awaited_once()
paypal = await http_client.post(
"/api/v1/callback/paypal",
json={"id": "evt_2", "event_type": "CHECKOUT.ORDER.APPROVED"},
)
assert paypal.status_code == 200
assert paypal.json()["success"] is True
paypal_mock.assert_awaited_once()
square = await http_client.post(
"/api/v1/callback/square",
headers={"x-square-hmacsha256-signature": "sig"},
json={"event_id": "evt_3", "type": "payment.updated"},
)
assert square.status_code == 200
assert square.json()["success"] is True
square_mock.assert_awaited_once()
revolut = await http_client.post(
"/api/v1/callback/revolut",
headers={
"Revolut-Signature": "sig",
"Revolut-Request-Timestamp": "1700000000",
},
json={"event": "ORDER_COMPLETED", "order_id": "order_1"},
)
assert revolut.status_code == 200
assert revolut.json()["success"] is True
revolut_mock.assert_awaited_once()
unknown = await http_client.post("/api/v1/callback/unknown", json={"id": "evt_3"})
assert unknown.status_code == 200
assert unknown.json()["success"] is False
@pytest.mark.anyio
async def test_callback_api_handles_paid_events_with_real_payments(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=11, memo="fiat callback")
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_stripe",
"type": "payment_intent.succeeded",
"data": {
"object": {
"object": "payment_intent",
"metadata": {"payment_hash": payment.payment_hash},
}
},
}
)
await handle_paypal_event(
{
"id": "evt_paypal",
"event_type": "CHECKOUT.ORDER.APPROVED",
"resource": {
"purchase_units": [{"invoice_id": payment.payment_hash}],
},
}
)
await handle_stripe_event({"id": "evt_unhandled", "type": "customer.created"})
assert fiat_status_mock.await_count == 2
@pytest.mark.anyio
async def test_callback_api_handles_square_paid_events(mocker):
payment = mocker.Mock()
get_payment = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_square_event(
{
"event_id": "evt_square",
"type": "payment.updated",
"data": {
"object": {
"payment": {
"id": "payment_1",
"order_id": "order_1",
"status": "COMPLETED",
}
}
},
}
)
get_payment.assert_awaited_once_with("fiat_square_order_order_1")
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_paid_events(mocker):
payment = mocker.Mock()
get_payment = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "ORDER_COMPLETED",
"order_id": "order_1",
}
)
get_payment.assert_awaited_once_with("fiat_revolut_order_order_1")
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_subscription_event(
mocker, settings: Settings
):
wallet_id = "wallet_1"
payment = mocker.Mock()
payment.extra = {}
payment.msat = 925_000
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet()
mocker.patch.object(
revolut_provider,
"get_subscription",
return_value={
"id": "SUBSCRIPTION_1",
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1", "customer_id": "customer_1"},
"memo": "Revolut Members",
}
),
},
)
mocker.patch.object(
revolut_provider,
"get_subscription_cycle",
return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"},
)
mocker.patch.object(
revolut_provider,
"get_order",
return_value={
"id": "ORDER_SUB_1",
"amount": 925,
"currency": "USD",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_1",
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=revolut_provider),
)
mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None]),
)
create_wallet_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice",
mocker.AsyncMock(return_value=payment),
)
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "SUBSCRIPTION_INITIATED",
"subscription_id": "SUBSCRIPTION_1",
}
)
assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
assert called_wallet_id == "wallet_1"
assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_revolut_subscription_order_event(
mocker, settings: Settings
):
wallet_id = "wallet_1"
payment = mocker.Mock()
payment.extra = {}
payment.msat = 925_000
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
revolut_provider = RevolutWallet()
subscription = {
"id": "SUBSCRIPTION_1",
"state": "active",
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1"},
"memo": "Revolut Members",
}
),
}
order = {
"id": "ORDER_SUB_1",
"type": "payment",
"state": "completed",
"amount": 925,
"currency": "USD",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_1",
"channel_data": {
"subscription_id": "SUBSCRIPTION_1",
"subscription_cycle_id": "CYCLE_1",
},
}
get_order_mock = mocker.patch.object(
revolut_provider, "get_order", side_effect=[order, order]
)
get_subscription_mock = mocker.patch.object(
revolut_provider,
"get_subscription",
return_value=subscription,
)
mocker.patch.object(
revolut_provider,
"get_subscription_cycle",
return_value={"id": "CYCLE_1", "order_id": "ORDER_SUB_1"},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=revolut_provider),
)
get_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None, None]),
)
create_wallet_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_wallet_invoice",
mocker.AsyncMock(return_value=payment),
)
mocker.patch("lnbits.core.views.callback_api.service_fee_fiat", return_value=2)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment", mocker.AsyncMock()
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_revolut_event(
{
"event": "ORDER_COMPLETED",
"order_id": "ORDER_SUB_1",
}
)
assert get_payment_mock.await_count == 2
get_payment_mock.assert_any_await("fiat_revolut_order_ORDER_SUB_1")
assert get_order_mock.await_count == 2
assert [call.args for call in get_subscription_mock.await_args_list] == [
("SUBSCRIPTION_1",),
("SUBSCRIPTION_1",),
]
assert create_wallet_invoice_mock.await_count == 1
called_wallet_id, invoice = create_wallet_invoice_mock.await_args.args
assert called_wallet_id == "wallet_1"
assert invoice.amount == 9.25
assert invoice.memo == "Revolut Members"
assert invoice.external_id == "SUBSCRIPTION_1"
assert invoice.internal is True
assert invoice.extra["fiat_method"] == "subscription"
assert invoice.extra["subscription"]["checking_id"] == "order_ORDER_SUB_1"
assert payment.fiat_provider == "revolut"
assert payment.fee == -2
assert payment.extra["fiat_checking_id"] == "order_ORDER_SUB_1"
assert payment.checking_id == "fiat_revolut_order_ORDER_SUB_1"
update_payment_mock.assert_awaited_once_with(
payment, "fiat_revolut_order_ORDER_SUB_1"
)
fiat_status_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_callback_api_handles_subscription_flows_and_validation(
mocker, settings: Settings
):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=15, memo="subscription")
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(return_value=payment),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_stripe_event(
{
"id": "evt_invoice_paid",
"type": "invoice.paid",
"data": {
"object": {
"id": "invoice_1",
"currency": "usd",
"amount_paid": 500,
"hosted_invoice_url": "https://stripe.example/invoice",
"customer_email": "alice@example.com",
"lines": {"data": [{"description": "Gold Plan"}]},
"parent": {
"type": "subscription_details",
"subscription_details": {
"metadata": {
"alan_action": "subscription",
"wallet_id": wallet.id,
"tag": "gold",
"memo": "Monthly Gold",
"extra": json.dumps({"plan": "gold"}),
}
},
},
}
},
}
)
create_fiat_invoice_mock.assert_awaited()
fiat_status_mock.assert_awaited()
await handle_paypal_event(
{
"id": "evt_sale_completed",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {
"id": "sale_1",
"billing_agreement_id": "agreement_1",
"amount": {"currency": "USD", "total": "7.50"},
"custom_id": json.dumps(
[wallet.id, "vip", "subscription_1", "link-1", "VIP Plan"]
),
},
}
)
assert create_fiat_invoice_mock.await_count == 2
await handle_square_event(
{
"event_id": "evt_square_subscription",
"type": "payment.updated",
"data": {
"object": {
"payment": {
"id": "PAYMENT_SUB_1",
"order_id": "ORDER_SUB_1",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
"note": json.dumps(
[
wallet.id,
"members",
"subscription_square_1",
"link-1",
"Square Members",
]
),
}
}
},
}
)
assert create_fiat_invoice_mock.await_count == 3
square_call = create_fiat_invoice_mock.await_args.kwargs
assert square_call["wallet_id"] == wallet.id
square_invoice = square_call["invoice_data"]
assert square_invoice.fiat_provider == "square"
assert square_invoice.amount == 9.25
assert square_invoice.memo == "Square Members"
assert square_invoice.extra["fiat_method"] == "subscription"
assert square_invoice.extra["tag"] == "members"
assert (
square_invoice.extra["subscription"]["checking_id"] == "payment_PAYMENT_SUB_1"
)
payment.extra = {
"subscription_request_id": "subscription_square_1",
"tag": "members",
"link": "link-1",
}
payment.external_id = "SUBSCRIPTION_1"
payment.memo = "Square Members"
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
settings.square_api_version = "2026-01-22"
square_provider = SquareWallet()
mocker.patch.object(
square_provider,
"get_payment_for_order",
return_value={
"id": "PAYMENT_SUB_2",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=square_provider),
)
mocker.patch(
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[payment]),
)
await handle_square_event(
{
"event_id": "evt_square_invoice",
"type": "invoice.payment_made",
"data": {
"object": {
"invoice": {
"order_id": "ORDER_SUB_2",
"subscription_id": "SUBSCRIPTION_1",
"public_url": "https://square.example/invoice",
}
}
},
}
)
assert create_fiat_invoice_mock.await_count == 4
square_invoice_call = create_fiat_invoice_mock.await_args.kwargs
square_invoice = square_invoice_call["invoice_data"]
assert square_invoice.external_id == "SUBSCRIPTION_1"
assert "square_subscription_id" not in square_invoice.extra
assert (
square_invoice.extra["subscription"]["payment_request"]
== "https://square.example/invoice"
)
with pytest.raises(
ValueError, match="PayPal subscription event missing custom metadata."
):
await handle_paypal_event(
{
"id": "evt_bad_sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"resource": {"amount": {"currency": "USD", "total": "5.00"}},
}
)
@pytest.mark.anyio
async def test_square_invoice_payment_updates_existing_subscription_external_id(
settings: Settings, mocker
):
payment = Payment(
checking_id="fiat_square_payment_PAYMENT_SUB_1",
payment_hash="hash_square_subscription",
wallet_id="wallet_1",
amount=925000,
fee=0,
bolt11="lnbc1square",
fiat_provider="square",
extra={
"subscription_request_id": "subscription_square_1",
"tag": "members",
"link": "link-1",
},
memo="Square Members",
)
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
settings.square_api_version = "2026-01-22"
square_provider = SquareWallet()
mocker.patch.object(
square_provider,
"get_payment_for_order",
return_value={
"id": "PAYMENT_SUB_1",
"status": "COMPLETED",
"amount_money": {"amount": 925, "currency": "USD"},
},
)
mocker.patch(
"lnbits.core.views.callback_api.get_fiat_provider",
mocker.AsyncMock(return_value=square_provider),
)
get_standalone_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(return_value=payment),
)
update_payment_mock = mocker.patch(
"lnbits.core.views.callback_api.update_payment",
mocker.AsyncMock(),
)
get_payments_mock = mocker.patch(
"lnbits.core.views.callback_api.get_payments",
mocker.AsyncMock(return_value=[]),
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
mocker.AsyncMock(),
)
fiat_status_mock = mocker.patch(
"lnbits.core.views.callback_api.check_fiat_status", mocker.AsyncMock()
)
await handle_square_event(
{
"event_id": "evt_square_invoice",
"type": "invoice.payment_made",
"data": {
"object": {
"invoice": {
"order_id": "ORDER_SUB_1",
"subscription_id": "SUBSCRIPTION_1",
}
}
},
}
)
get_standalone_payment_mock.assert_awaited_with("fiat_square_payment_PAYMENT_SUB_1")
assert payment.external_id == "SUBSCRIPTION_1"
update_payment_mock.assert_awaited_once_with(payment)
fiat_status_mock.assert_awaited_once_with(payment)
get_payments_mock.assert_not_awaited()
create_fiat_invoice_mock.assert_not_awaited()
-430
View File
@@ -1,430 +0,0 @@
from types import SimpleNamespace
from uuid import uuid4
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from lnbits.core.crud.db_versions import get_db_version, update_migration_version
from lnbits.core.crud.extensions import (
create_installed_extension,
get_installed_extension,
get_user_extension,
)
from lnbits.core.crud.users import get_account
from lnbits.core.crud.wallets import create_wallet
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.extensions import (
CreateExtension,
CreateExtensionReview,
Extension,
ExtensionConfig,
ExtensionRelease,
InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
)
from lnbits.core.models.users import AccountId
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.extension_api import (
api_activate_extension,
api_deactivate_extension,
api_disable_extension,
api_enable_extension,
api_extension_details,
api_get_user_extensions,
api_install_extension,
api_uninstall_extension,
api_update_pay_to_enable,
create_extension_review,
delete_extension_db,
extensions,
get_extension_release,
get_extension_releases,
get_extension_reviews,
get_extension_reviews_tags,
get_pay_to_enable_invoice,
get_pay_to_install_invoice,
)
from tests.helpers import make_extension_release, make_installable_extension
class _MockHTTPResponse:
def __init__(
self,
*,
json_data=None,
text: str = "",
status_code: int = 200,
is_error: bool = False,
):
self._json_data = json_data
self.text = text
self.status_code = status_code
self.is_error = is_error
def json(self):
return self._json_data
def raise_for_status(self):
if self.status_code >= 400:
raise ValueError(self.text or "request failed")
class _MockHTTPClient:
def __init__(self, responses: dict[str, _MockHTTPResponse]):
self.responses = responses
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return False
async def get(self, url: str):
return self.responses[url]
async def post(self, url: str, json=None):
return self.responses[url]
@pytest.mark.anyio
async def test_extension_api_install_details_and_release_endpoints(mocker):
ext_id = f"ext_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
create_data = CreateExtension(
ext_id=ext_id,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch(
"lnbits.core.views.extension_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
installed = await api_install_extension(create_data)
assert installed.code == ext_id
activate_mock.assert_awaited_once()
mocker.patch.object(
InstallableExtension,
"get_extension_releases",
mocker.AsyncMock(return_value=[release]),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_details",
mocker.AsyncMock(return_value={"description": "Extension details"}),
)
details = await api_extension_details(ext_id, release.details_link or "")
assert details["description"] == "Extension details"
assert details["icon"] == release.icon
assert details["repo"] == release.repo
installed_ext = make_installable_extension(
ext_id,
payments=[
ReleasePaymentInfo(
amount=55,
pay_link=release.pay_link,
payment_hash=f"payment_{uuid4().hex[:8]}",
)
],
)
await create_installed_extension(installed_ext)
releases = await get_extension_releases(ext_id)
assert releases[0].paid_sats == 55
config = ExtensionConfig(
name=ext_id,
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
)
mocker.patch.object(
ExtensionConfig,
"fetch_github_release_config",
mocker.AsyncMock(return_value=config),
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_account = await get_account(admin_user.id)
assert admin_account is not None
admin_wallet = await create_wallet(
user_id=admin_account.id, wallet_name="extension sales"
)
ext_id = f"paid_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(
required=True, amount=10, wallet=admin_wallet.id
),
)
)
updated = await api_update_pay_to_enable(
ext_id,
PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
account=admin_account,
)
assert updated.success is True
stored_extension = await get_installed_extension(ext_id)
assert stored_extension is not None
assert stored_extension.meta is not None
assert stored_extension.meta.pay_to_enable is not None
assert stored_extension.meta.pay_to_enable.amount == 21
enable_invoice = await create_wallet_invoice(
admin_wallet.id, CreateInvoice(out=False, amount=21, memo="enable extension")
)
mocker.patch(
"lnbits.core.views.extension_api.create_invoice",
mocker.AsyncMock(return_value=enable_invoice),
)
invoice_response = await get_pay_to_enable_invoice(
ext_id,
PayToEnableInfo(amount=21),
account_id=AccountId(id=regular_user.id),
)
assert invoice_response["payment_hash"] == enable_invoice.payment_hash
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.extra is not None
assert user_ext.extra.payment_hash_to_enable == enable_invoice.payment_hash
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(return_value=[Extension(code=ext_id, is_valid=True)]),
)
mocker.patch(
"lnbits.core.views.extension_api.check_transaction_status",
mocker.AsyncMock(return_value=SimpleNamespace(paid=True)),
)
enabled = await api_enable_extension(ext_id, AccountId(id=regular_user.id))
assert enabled.success is True
user_ext = await get_user_extension(regular_user.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
assert user_ext.extra == UserExtensionInfo(
payment_hash_to_enable=enable_invoice.payment_hash,
paid_to_enable=True,
)
disabled = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled.success is True
disabled_again = await api_disable_extension(ext_id, AccountId(id=regular_user.id))
assert disabled_again.success is True
assert "already disabled" in disabled_again.message
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=ext_id, is_valid=True, name="Paid Extension"),
Extension(code="other", is_valid=True),
]
),
)
visible_extensions = await api_get_user_extensions(AccountId(id=regular_user.id))
assert [ext.code for ext in visible_extensions] == [ext_id]
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
)
mocker.patch.object(
InstallableExtension,
"get_installable_extensions",
mocker.AsyncMock(return_value=[catalog_entry]),
)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
@pytest.mark.anyio
async def test_extension_api_activate_uninstall_install_invoice_and_cleanup(mocker):
base_ext = f"base_{uuid4().hex[:8]}"
dependent_ext = f"dependent_{uuid4().hex[:8]}"
uninstall_ext = f"uninstall_{uuid4().hex[:8]}"
db_ext = f"db_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(base_ext))
await create_installed_extension(
make_installable_extension(dependent_ext, dependencies=[base_ext])
)
await create_installed_extension(make_installable_extension(uninstall_ext))
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extensions",
mocker.AsyncMock(
return_value=[
Extension(code=base_ext, is_valid=True, name="Base"),
Extension(code=dependent_ext, is_valid=True, name="Dependent"),
Extension(code=uninstall_ext, is_valid=True, name="Remove"),
]
),
)
with pytest.raises(HTTPException, match="depends on this one"):
await api_uninstall_extension(base_ext)
uninstall_mock = mocker.patch(
"lnbits.core.views.extension_api.uninstall_extension", mocker.AsyncMock()
)
uninstalled = await api_uninstall_extension(uninstall_ext)
assert uninstalled.success is True
uninstall_mock.assert_awaited_once_with(uninstall_ext)
mocker.patch(
"lnbits.core.views.extension_api.get_valid_extension",
mocker.AsyncMock(return_value=Extension(code=base_ext, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extension_api.activate_extension", mocker.AsyncMock()
)
deactivate_mock = mocker.patch(
"lnbits.core.views.extension_api.deactivate_extension", mocker.AsyncMock()
)
activated = await api_activate_extension(base_ext)
assert activated.success is True
deactivated = await api_deactivate_extension(base_ext)
assert deactivated.success is True
activate_mock.assert_awaited_once()
deactivate_mock.assert_awaited_once()
owner = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = owner.wallets[0]
install_invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=33, memo="install extension")
)
release = make_extension_release(base_ext, version="2.0.0")
payment_info = ReleasePaymentInfo(
amount=33,
pay_link=release.pay_link,
payment_hash=install_invoice.payment_hash,
payment_request=install_invoice.bolt11,
)
mocker.patch.object(
InstallableExtension,
"get_extension_release",
mocker.AsyncMock(return_value=release),
)
mocker.patch.object(
ExtensionRelease,
"fetch_release_payment_info",
mocker.AsyncMock(return_value=payment_info),
)
invoice = await get_pay_to_install_invoice(
base_ext,
CreateExtension(
ext_id=base_ext,
archive=release.archive,
source_repo=release.source_repo,
version=release.version,
cost_sats=33,
),
)
assert invoice.payment_hash == install_invoice.payment_hash
await update_migration_version(None, db_ext, 1)
drop_mock = mocker.patch(
"lnbits.core.views.extension_api.drop_extension_db", mocker.AsyncMock()
)
deleted = await delete_extension_db(db_ext)
assert deleted.success is True
drop_mock.assert_awaited_once_with(ext_id=db_ext)
assert await get_db_version(db_ext) is None
@pytest.mark.anyio
async def test_extension_api_review_endpoints(mocker):
ext_id = f"review_{uuid4().hex[:8]}"
reviews_base = "https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r"
tags_url = f"{reviews_base}/tags"
reviews_url = f"{reviews_base}/reviews/{ext_id}?offset=0&limit=5"
create_review_url = f"{reviews_base}/reviews"
request = Request(
{
"type": "http",
"method": "GET",
"path": f"/api/v1/extension/reviews/{ext_id}",
"query_string": b"offset=0&limit=5",
"headers": [],
}
)
mock_client = _MockHTTPClient(
{
tags_url: _MockHTTPResponse(
json_data=[{"tag": "good", "avg_rating": 900, "review_count": 3}]
),
reviews_url: _MockHTTPResponse(
json_data={
"data": [
{
"id": "1",
"name": "Alice",
"tag": "good",
"rating": 950,
"comment": "solid",
}
],
"total": 1,
}
),
create_review_url: _MockHTTPResponse(
json_data={
"payment_hash": f"hash_{uuid4().hex[:8]}",
"payment_request": "lnbc1review",
}
),
}
)
mocker.patch(
"lnbits.core.views.extension_api.httpx.AsyncClient", return_value=mock_client
)
tags = await get_extension_reviews_tags()
assert tags[0].tag == "good"
reviews = await get_extension_reviews(ext_id, request)
assert reviews.total == 1
assert reviews.data[0].comment == "solid"
payment_request = await create_extension_review(
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
-109
View File
@@ -1,109 +0,0 @@
from pathlib import Path
from uuid import uuid4
import pytest
from lnbits.core.crud.extensions import create_user_extension, get_user_extension
from lnbits.core.crud.users import get_account
from lnbits.core.models.extensions import (
Extension,
UserExtension,
)
from lnbits.core.models.users import AccountId
from lnbits.core.views.extensions_builder_api import (
api_build_extension,
api_delete_extension_builder_data,
api_deploy_extension,
api_preview_extension,
)
from lnbits.settings import Settings
from tests.helpers import make_extension_data, make_extension_release
@pytest.mark.anyio
async def test_extensions_builder_api_build_preview_and_cleanup(
tmp_path, settings: Settings, mocker, from_user
):
ext_id = f"builder_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
build_dir = tmp_path / "build"
build_dir.mkdir(parents=True, exist_ok=True)
(build_dir / "index.txt").write_text("hello")
original_data_folder = settings.lnbits_data_folder
build_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_dir)
),
)
clean_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.clean_extension_builder_data"
)
try:
settings.lnbits_data_folder = str(tmp_path)
build_response = await api_build_extension(data)
assert Path(build_response.path).is_file()
assert build_response.filename == f"{ext_id}.zip"
preview = await api_preview_extension(data, AccountId(id=from_user.id))
assert preview.success is True
assert ext_id in preview.message
cleaned = await api_delete_extension_builder_data()
assert cleaned.success is True
clean_mock.assert_called_once()
assert build_mock.await_count == 2
finally:
settings.lnbits_data_folder = original_data_folder
@pytest.mark.anyio
async def test_extensions_builder_api_deploy_updates_user_extension(
tmp_path, settings: Settings, mocker, admin_user
):
ext_id = f"deploy_{uuid4().hex[:8]}"
data = make_extension_data(ext_id)
account = await get_account(admin_user.id)
assert account is not None
build_root = tmp_path / "deploy-root" / ext_id
build_root.mkdir(parents=True, exist_ok=True)
(build_root / "manifest.json").write_text("{}")
original_data_folder = settings.lnbits_data_folder
await create_user_extension(
UserExtension(user=account.id, extension=ext_id, active=False)
)
mocker.patch(
"lnbits.core.views.extensions_builder_api.build_extension_from_data",
mocker.AsyncMock(
return_value=(make_extension_release(ext_id, "0.1.0"), build_root)
),
)
install_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.install_extension",
mocker.AsyncMock(return_value=Extension(code=ext_id, is_valid=True)),
)
activate_mock = mocker.patch(
"lnbits.core.views.extensions_builder_api.activate_extension",
mocker.AsyncMock(),
)
try:
settings.lnbits_data_folder = str(tmp_path)
deployed = await api_deploy_extension(data, account=account)
finally:
settings.lnbits_data_folder = original_data_folder
assert deployed.success is True
assert ext_id in deployed.message
install_mock.assert_awaited_once()
activate_mock.assert_awaited_once()
user_ext = await get_user_extension(account.id, ext_id)
assert user_ext is not None
assert user_ext.active is True
-227
View File
@@ -1,227 +0,0 @@
import pytest
from httpx import AsyncClient
from pytest_mock.plugin import MockerFixture
from lnbits.core.models.misc import SimpleStatus
from lnbits.fiat.base import FiatSubscriptionResponse
from lnbits.fiat.revolut import REVOLUT_WEBHOOK_EVENTS
from lnbits.settings import Settings
class _UnsetSecret:
pass
UNSET_SECRET = _UnsetSecret()
class FakeStripeWallet:
def __init__(self, secret: str | None | _UnsetSecret = UNSET_SECRET):
self._secret = (
"connection-token" if isinstance(secret, _UnsetSecret) else secret
)
async def create_terminal_connection_token(self) -> dict[str, str]:
if self._secret is None:
return {}
return {"secret": self._secret}
@pytest.mark.anyio
async def test_fiat_api_test_provider_and_subscription_lifecycle(
client: AsyncClient,
superuser_token: str,
adminkey_headers_from: dict[str, str],
from_wallet,
mocker: MockerFixture,
):
test_connection = mocker.patch(
"lnbits.core.views.fiat_api.test_connection",
mocker.AsyncMock(return_value=SimpleStatus(success=True, message="ok")),
)
response = await client.put(
"/api/v1/fiat/check/stripe",
headers={"Authorization": f"Bearer {superuser_token}"},
)
assert response.status_code == 200
assert response.json()["success"] is True
test_connection.assert_awaited_once_with("stripe")
provider = mocker.Mock()
provider.create_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(
ok=True,
subscription_request_id="sub-1",
checkout_session_url="https://stripe.example/checkout",
)
)
provider.cancel_subscription = mocker.AsyncMock(
return_value=FiatSubscriptionResponse(ok=True, subscription_request_id="sub-1")
)
get_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=provider),
)
mismatch = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"wallet_id": "wrong-wallet"},
},
)
assert mismatch.status_code == 403
created = await client.post(
"/api/v1/fiat/stripe/subscription",
headers=adminkey_headers_from,
json={
"subscription_id": "sub-1",
"quantity": 2,
"payment_options": {"memo": "hello", "wallet_id": from_wallet.id},
},
)
assert created.status_code == 200
assert created.json()["checkout_session_url"] == "https://stripe.example/checkout"
provider.create_subscription.assert_awaited_once()
assert provider.create_subscription.await_args is not None
assert provider.create_subscription.await_args.args[2].wallet_id == from_wallet.id
cancelled = await client.delete(
"/api/v1/fiat/stripe/subscription/sub-1",
headers=adminkey_headers_from,
)
assert cancelled.status_code == 200
provider.cancel_subscription.assert_awaited_once_with("sub-1", from_wallet.id)
assert get_provider.await_count == 3
@pytest.mark.anyio
async def test_fiat_api_connection_token_validates_provider_configuration(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
headers = {"Authorization": f"Bearer {superuser_token}"}
not_found = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=None),
)
missing = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert missing.status_code == 404
assert not_found.await_count == 1
unsupported_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=object()),
)
unsupported = await client.post(
"/api/v1/fiat/paypal/connection_token", headers=headers
)
assert unsupported.status_code == 400
assert unsupported_provider.await_count == 1
mocker.patch("lnbits.core.views.fiat_api.StripeWallet", FakeStripeWallet)
bad_wallet = FakeStripeWallet(secret=None)
bad_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=bad_wallet),
)
no_secret = await client.post(
"/api/v1/fiat/stripe/connection_token", headers=headers
)
assert no_secret.status_code == 500
assert no_secret.json()["detail"] == "Failed to create connection token"
assert bad_provider.await_count == 1
good_wallet = FakeStripeWallet(secret="tok_live")
good_provider = mocker.patch(
"lnbits.core.views.fiat_api.get_fiat_provider",
mocker.AsyncMock(return_value=good_wallet),
)
ok = await client.post("/api/v1/fiat/stripe/connection_token", headers=headers)
assert ok.status_code == 200
assert ok.json() == {"secret": "tok_live"}
assert good_provider.await_count == 1
@pytest.mark.anyio
async def test_fiat_api_creates_revolut_webhook(
client: AsyncClient,
superuser_token: str,
settings: Settings,
mocker: MockerFixture,
):
create_webhook = mocker.patch(
"lnbits.core.views.fiat_api.RevolutWallet.create_webhook",
mocker.AsyncMock(
return_value={
"id": "webhook_1",
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
"signing_secret": "whsec_1",
}
),
)
response = await client.post(
"/api/v1/fiat/revolut/webhook",
headers={"Authorization": f"Bearer {superuser_token}"},
json={
"url": "https://lnbits.example/api/v1/callback/revolut",
"endpoint": "https://sandbox-merchant.revolut.com",
"api_secret_key": "secret_1",
"api_version": "2026-04-20",
},
)
assert response.status_code == 200
assert response.json() == {
"id": "webhook_1",
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
"signing_secret": "whsec_1",
"already_exists": False,
}
create_webhook.assert_awaited_once_with(
url="https://lnbits.example/api/v1/callback/revolut",
endpoint="https://sandbox-merchant.revolut.com",
api_secret_key="secret_1",
api_version="2026-04-20",
)
assert settings.revolut_payment_webhook_url == (
"https://lnbits.example/api/v1/callback/revolut"
)
assert settings.revolut_webhook_signing_secret == "whsec_1"
@pytest.mark.anyio
async def test_fiat_api_rejects_local_revolut_webhook(
client: AsyncClient,
superuser_token: str,
mocker: MockerFixture,
):
create_webhook = mocker.patch(
"lnbits.core.views.fiat_api.RevolutWallet.create_webhook",
mocker.AsyncMock(
side_effect=ValueError("Revolut webhook URL must be a clearnet URL.")
),
)
response = await client.post(
"/api/v1/fiat/revolut/webhook",
headers={"Authorization": f"Bearer {superuser_token}"},
json={
"url": "http://localhost:5000/api/v1/callback/revolut",
"endpoint": "https://sandbox-merchant.revolut.com",
"api_secret_key": "secret_1",
"api_version": "2026-04-20",
},
)
assert response.status_code == 400
assert response.json()["detail"] == ("Revolut webhook URL must be a clearnet URL.")
create_webhook.assert_awaited_once()
-142
View File
@@ -1,142 +0,0 @@
from uuid import uuid4
import pytest
from fastapi import HTTPException
from lnurl import (
LnAddress,
LnurlAuthResponse,
LnurlErrorResponse,
LnurlException,
LnurlPayActionResponse,
LnurlPayResponse,
LnurlResponseException,
)
from lnurl.models import MessageAction
from lnurl.types import CallbackUrl, LightningInvoice
from pydantic import parse_obj_as
from lnbits.core.models import Account, CreateInvoice
from lnbits.core.models.lnurl import CreateLnurlPayment, LnurlScan
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.lnurl_api import (
api_lnurlscan,
api_lnurlscan_post,
api_payments_pay_lnurl,
api_perform_lnurlauth,
)
from tests.helpers import make_lnurl_pay_response
TEST_BOLT11 = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_lnurl_api_scan_routes_validate_and_forward(mocker):
pay_response = make_lnurl_pay_response()
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=pay_response),
)
scanned = await api_lnurlscan("lnurl1example")
assert isinstance(scanned, LnurlPayResponse)
assert scanned.callback == pay_response.callback
scanned_post = await api_lnurlscan_post(
scan=LnurlScan(lnurl=LnAddress("alice@example.com"))
)
assert isinstance(scanned_post, LnurlPayResponse)
assert scanned_post.callback == pay_response.callback
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(return_value=LnurlErrorResponse(reason="blocked callback")),
)
with pytest.raises(HTTPException, match="blocked callback"):
await api_lnurlscan("lnurl1blocked")
mocker.patch(
"lnbits.core.views.lnurl_api.lnurl_handle",
mocker.AsyncMock(side_effect=LnurlException("invalid lnurl")),
)
with pytest.raises(HTTPException, match="invalid lnurl"):
await api_lnurlscan("lnurl1invalid")
@pytest.mark.anyio
async def test_lnurl_api_auth_and_pay_flow(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
wallet_info = WalletTypeInfo(key_type=KeyType.admin, wallet=wallet)
pay_response = make_lnurl_pay_response()
payment = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=21, memo="lnurl")
)
auth_response = LnurlAuthResponse(
callback=parse_obj_as(CallbackUrl, "https://example.com/auth"),
k1="k1-value",
)
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(return_value=auth_response),
)
authenticated = await api_perform_lnurlauth(auth_response, wallet_info)
assert isinstance(authenticated, LnurlAuthResponse)
assert authenticated.k1 == "k1-value"
mocker.patch(
"lnbits.core.views.lnurl_api.lnurlauth",
mocker.AsyncMock(side_effect=LnurlResponseException("denied")),
)
with pytest.raises(HTTPException, match="denied"):
await api_perform_lnurlauth(auth_response, wallet_info)
action_response = LnurlPayActionResponse(
pr=LightningInvoice(TEST_BOLT11),
disposable=False,
successAction=parse_obj_as(MessageAction, {"message": "paid"}),
)
fetch_mock = mocker.patch(
"lnbits.core.views.lnurl_api.fetch_lnurl_pay_request",
mocker.AsyncMock(return_value=(pay_response, action_response)),
)
pay_mock = mocker.patch(
"lnbits.core.views.lnurl_api.pay_invoice",
mocker.AsyncMock(return_value=payment),
)
paid = await api_payments_pay_lnurl(
CreateLnurlPayment(
res=pay_response, amount=2_000, unit="USD", comment="thanks"
),
wallet_info,
)
assert paid.payment_hash == payment.payment_hash
fetch_mock.assert_awaited_once()
pay_mock.assert_awaited_once()
assert pay_mock.await_args is not None
assert action_response.successAction is not None
assert pay_mock.await_args.kwargs["extra"] == {
"stored": True,
"success_action": action_response.successAction.json(),
"comment": "thanks",
"fiat_currency": "USD",
"fiat_amount": 2.0,
}
with pytest.raises(HTTPException, match="Missing LNURL or LnurlPayResponse data."):
await api_payments_pay_lnurl(CreateLnurlPayment(amount=1), wallet_info)
-366
View File
@@ -1,366 +0,0 @@
from collections.abc import Callable
from typing import Any, cast
from uuid import uuid4
import httpx
import pytest
from fastapi import HTTPException
from pytest_mock.plugin import MockerFixture
from lnbits.core.views import node_api
from lnbits.db import Filters, Page
from lnbits.nodes.base import (
ChannelBalance,
ChannelPoint,
ChannelState,
ChannelStats,
Node,
NodeChannel,
NodeFees,
NodeInfoResponse,
NodeInvoice,
NodePayment,
NodePeerInfo,
PublicNodeInfo,
)
from lnbits.settings import Settings
from lnbits.wallets.base import Feature
class FakeNode:
def __init__(self):
self.channel = NodeChannel(
id="chan-1",
short_id="123x1x0",
peer_id="peer-1",
name="Peer One",
color="#ffffff",
state=ChannelState.ACTIVE,
balance=ChannelBalance(local_msat=1000, remote_msat=2000, total_msat=3000),
point=ChannelPoint(funding_txid="ab" * 32, output_index=1),
fee_ppm=10,
fee_base_msat=1000,
)
self.peer = NodePeerInfo(id="peer-1", alias="Peer One", addresses=["127.0.0.1"])
self.info = NodeInfoResponse(
id="node-id",
backend_name="FakeNode",
alias="Fake Alias",
color="#ffffff",
num_peers=1,
blockheight=1,
channel_stats=ChannelStats(
counts={ChannelState.ACTIVE: 1},
avg_size=3000,
biggest_size=3000,
smallest_size=3000,
total_capacity=3000,
),
addresses=["127.0.0.1:9735"],
onchain_balance_sat=1,
onchain_confirmed_sat=1,
fees=NodeFees(total_msat=0),
balance_msat=3000,
)
self.fees_updated: tuple[str, int | None, int | None] | None = None
async def get_public_info(self) -> PublicNodeInfo:
return PublicNodeInfo(**self.info.dict())
async def get_info(self) -> NodeInfoResponse:
return self.info
async def get_channels(self) -> list[NodeChannel]:
return [self.channel]
async def get_channel(self, channel_id: str) -> NodeChannel | None:
return self.channel if channel_id == self.channel.id else None
async def open_channel(
self,
peer_id: str,
funding_amount: int,
push_amount: int | None = None,
fee_rate: int | None = None,
) -> ChannelPoint:
assert peer_id == "peer-1"
assert funding_amount == 10_000
assert push_amount == 100
assert fee_rate == 5
return ChannelPoint(funding_txid="cd" * 32, output_index=0)
async def close_channel(
self,
short_id: str | None = None,
point: ChannelPoint | None = None,
force: bool = False,
) -> list[NodeChannel]:
assert short_id == self.channel.short_id
assert point is None
assert force is True
return [self.channel]
async def set_channel_fee(
self, channel_id: str, fee_base_msat: int | None, fee_ppm: int | None
) -> None:
self.fees_updated = (channel_id, fee_base_msat, fee_ppm)
async def get_payments(self, filters: Filters[Any]) -> Page[NodePayment]:
return Page(
data=[
NodePayment(
pending=False,
amount=1,
fee=0,
memo="payment",
time=1,
preimage="11" * 32,
payment_hash="22" * 32,
)
],
total=1,
)
async def get_invoices(self, filters: Filters[Any]) -> Page[NodeInvoice]:
return Page(
data=[
NodeInvoice(
pending=False,
amount=1,
memo="invoice",
bolt11="lnbc1dummy",
preimage="11" * 32,
payment_hash="33" * 32,
)
],
total=1,
)
async def get_peers(self) -> list[NodePeerInfo]:
return [self.peer]
async def connect_peer(self, uri: str) -> dict[str, str]:
return {"uri": uri}
async def disconnect_peer(self, peer_id: str) -> dict[str, str]:
return {"peer_id": peer_id}
async def get_id(self) -> str:
return "fake-node-id"
class FakeFundingSource:
def __init__(
self,
features: list[Feature],
node_factory: Callable[[Any], Any] | None,
):
self.features = features
self.__node_cls__ = node_factory
class MockHTTPResponse:
def __init__(
self, json_data: dict[str, Any], status_error: Exception | None = None
):
self._json_data = json_data
self._status_error = status_error
def raise_for_status(self) -> None:
if self._status_error:
raise self._status_error
def json(self) -> dict[str, Any]:
return self._json_data
class MockHTTPClient:
def __init__(self, response: MockHTTPResponse):
self.response = response
self.calls: list[str] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def get(self, url: str, timeout: int):
self.calls.append(url)
return self.response
@pytest.mark.anyio
async def test_node_api_dependency_guards(settings: Settings, mocker: MockerFixture):
original_node_ui = settings.lnbits_node_ui
original_public = settings.lnbits_public_node_ui
try:
settings.lnbits_node_ui = True
funding_source = FakeFundingSource([], None)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=funding_source,
)
with pytest.raises(HTTPException) as excinfo:
node_api.require_node()
assert excinfo.value.status_code == 501
node_enabled_source = FakeFundingSource(
[Feature.nodemanager], lambda wallet: "fake-node"
)
mocker.patch(
"lnbits.core.views.node_api.get_funding_source",
return_value=node_enabled_source,
)
settings.lnbits_node_ui = False
with pytest.raises(HTTPException) as disabled:
node_api.require_node()
assert disabled.value.status_code == 503
settings.lnbits_node_ui = True
assert node_api.require_node() == "fake-node"
settings.lnbits_public_node_ui = False
with pytest.raises(HTTPException) as public_disabled:
node_api.check_public()
assert public_disabled.value.status_code == 503
finally:
settings.lnbits_node_ui = original_node_ui
settings.lnbits_public_node_ui = original_public
@pytest.mark.anyio
async def test_node_api_route_functions_with_fake_node(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = True
rank_response = MockHTTPResponse(
{
"noderank": {
"capacity": 1,
"channelcount": 2,
"age": 3,
"growth": 4,
"availability": 5,
}
}
)
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(rank_response),
)
try:
assert await node_api.api_get_ok() is None
public_info = await node_api.api_get_public_info(node=node)
assert public_info.backend_name == "FakeNode"
info = await node_api.api_get_info(node=node)
assert info is not None
assert info.id == "node-id"
channels = await node_api.api_get_channels(node=node)
assert channels is not None
assert channels[0].id == "chan-1"
channel = await node_api.api_get_channel("chan-1", node=node)
assert channel is not None
assert channel.peer_id == "peer-1"
created = await node_api.api_create_channel(
node=node,
peer_id="peer-1",
funding_amount=10_000,
push_amount=100,
fee_rate=5,
)
assert created.output_index == 0
deleted = await node_api.api_delete_channel(
short_id="123x1x0",
funding_txid=None,
output_index=None,
force=True,
node=node,
)
assert deleted is not None
assert deleted[0].id == "chan-1"
await node_api.api_set_channel_fees(
"chan-1",
node=node,
fee_ppm=42,
fee_base_msat=7,
)
assert fake_node.fees_updated == ("chan-1", 7, 42)
payments = await node_api.api_get_payments(node=node, filters=Filters())
assert payments is not None
assert payments.total == 1
invoices = await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices is not None
assert invoices.total == 1
peers = await node_api.api_get_peers(node=node)
assert peers[0].id == "peer-1"
connect = await node_api.api_connect_peer(
uri="peer-1@127.0.0.1:9735", node=node
)
assert connect["uri"] == "peer-1@127.0.0.1:9735"
disconnect = await node_api.api_disconnect_peer("peer-1", node=node)
assert disconnect["peer_id"] == "peer-1"
rank = await node_api.api_get_1ml_stats(node=node)
assert rank is not None
rank_data = node_api.NodeRank.parse_obj(rank)
assert rank_data.channelcount == 2
finally:
settings.lnbits_node_ui_transactions = original_transactions
@pytest.mark.anyio
async def test_node_api_transactions_and_rank_errors(
settings: Settings,
mocker: MockerFixture,
):
fake_node = FakeNode()
node = cast(Node, fake_node)
original_transactions = settings.lnbits_node_ui_transactions
settings.lnbits_node_ui_transactions = False
request = httpx.Request("GET", f"https://1ml.com/node/{uuid4().hex}/json")
mocker.patch(
"lnbits.core.views.node_api.httpx.AsyncClient",
return_value=MockHTTPClient(
MockHTTPResponse(
{},
status_error=httpx.HTTPStatusError(
"not found", request=request, response=httpx.Response(404)
),
)
),
)
try:
with pytest.raises(HTTPException) as payments:
await node_api.api_get_payments(node=node, filters=Filters())
assert payments.value.status_code == 503
with pytest.raises(HTTPException) as invoices:
await node_api.api_get_invoices(node=node, filters=Filters())
assert invoices.value.status_code == 503
with pytest.raises(HTTPException) as rank:
await node_api.api_get_1ml_stats(node=node)
assert rank.value.status_code == 404
assert rank.value.detail == "Node not found on 1ml.com"
finally:
settings.lnbits_node_ui_transactions = original_transactions
-242
View File
@@ -1,242 +0,0 @@
import json
from hashlib import sha256
from uuid import uuid4
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.models import Account, CreateInvoice, PaymentFilters, PaymentState
from lnbits.core.models.payments import CancelInvoice, CreatePayment, SettleInvoice
from lnbits.core.models.users import AccountId
from lnbits.core.models.wallets import KeyType, WalletTypeInfo
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.core.services.users import create_user_account
from lnbits.core.views.payment_api import (
api_all_payments_paginated,
api_payments_cancel,
api_payments_counting_stats,
api_payments_daily_stats,
api_payments_fee_reserve,
api_payments_settle,
api_payments_wallets_stats,
)
from lnbits.db import Filter, Filters
from lnbits.wallets.base import InvoiceResponse
ZERO_AMOUNT_INVOICE = (
"lnbc1pnsu5z3pp57getmdaxhg5kc9yh2a2qsh7cjf4gnccgkw0qenm8vsqv50w7s"
"ygqdqj0fjhymeqv9kk7atwwscqzzsxqyz5vqsp5e2yyqcp0a3ujeesp24ya0glej"
"srh703md8mrx0g2lyvjxy5w27ss9qxpqysgqyjreasng8a086kpkczv48er5c6l5"
"73aym6ynrdl9nkzqnag49vt3sjjn8qdfq5cr6ha0vrdz5c5r3v4aghndly0hplmv"
"6hjxepwp93cq398l3s"
)
@pytest.mark.anyio
async def test_payment_api_stats_and_all_paginated(admin_user):
first_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
second_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
first_wallet = first_user.wallets[0]
second_wallet = second_user.wallets[0]
await _create_payment(first_wallet.id, amount_msat=2_000, tag="coffee")
await _create_payment(first_wallet.id, amount_msat=-1_000, tag="coffee")
await _create_payment(second_wallet.id, amount_msat=5_000, tag="books")
count_stats = await api_payments_counting_stats(
count_by="tag",
filters=Filters(limit=20),
account_id=AccountId(id=first_user.id),
)
assert any(item.field == "coffee" for item in count_stats)
assert all(item.field != "books" for item in count_stats)
wallet_stats = await api_payments_wallets_stats(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert any(item.wallet_id == first_wallet.id for item in wallet_stats)
assert all(item.wallet_id != second_wallet.id for item in wallet_stats)
daily_stats = await api_payments_daily_stats(
account_id=AccountId(id=first_user.id),
filters=Filters(limit=20),
)
assert daily_stats
assert daily_stats[0].payments_count >= 1
regular_page = await api_all_payments_paginated(
filters=Filters(limit=20), account_id=AccountId(id=first_user.id)
)
assert regular_page.total >= 2
assert all(payment.wallet_id == first_wallet.id for payment in regular_page.data)
admin_page = await api_all_payments_paginated(
filters=Filters(limit=50), account_id=AccountId(id=admin_user.id)
)
wallet_ids = {payment.wallet_id for payment in admin_page.data}
assert first_wallet.id in wallet_ids
assert second_wallet.id in wallet_ids
@pytest.mark.anyio
async def test_payment_external_id_is_stored_and_validated():
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
first_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=21,
memo="external reference",
external_id="provider_payment_123",
),
)
second_payment = await create_wallet_invoice(
wallet.id,
CreateInvoice(
out=False,
amount=22,
memo="external reference newest",
external_id="provider_payment_123",
),
)
assert first_payment.external_id == "provider_payment_123"
assert second_payment.external_id == "provider_payment_123"
stored_payments = await get_payments(
wallet_id=wallet.id,
filters=Filters(
filters=[
Filter.parse_query(
"external_id", ["provider_payment_123"], PaymentFilters
)
],
model=PaymentFilters,
sortby="created_at",
direction="desc",
),
)
assert [payment.checking_id for payment in stored_payments] == [
second_payment.checking_id,
first_payment.checking_id,
]
with pytest.raises(ValidationError, match="Invalid external id"):
CreateInvoice(out=False, amount=21, external_id="provider payment 123")
@pytest.mark.anyio
async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
wallet = user.wallets[0]
invoice = await create_wallet_invoice(
wallet.id, CreateInvoice(out=False, amount=42, memo="reserve")
)
reserve = await api_payments_fee_reserve(invoice.bolt11)
assert json.loads(reserve.body)["fee_reserve"] >= 0
with pytest.raises(HTTPException, match="Invoice has no amount."):
await api_payments_fee_reserve(ZERO_AMOUNT_INVOICE)
preimage = "11" * 32
payment_hash = sha256(bytes.fromhex(preimage)).hexdigest()
await _create_payment(
wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
settle_mock = mocker.patch(
"lnbits.core.views.payment_api.settle_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=True,
checking_id="settled",
preimage=preimage,
)
),
)
settled = await api_payments_settle(
SettleInvoice(preimage=preimage),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert settled.success is True
settle_mock.assert_awaited_once()
cancel_hash = (uuid4().hex * 2)[:64]
await _create_payment(
wallet.id,
amount_msat=2_000,
payment_hash=cancel_hash,
status=PaymentState.PENDING,
)
cancel_mock = mocker.patch(
"lnbits.core.views.payment_api.cancel_hold_invoice",
mocker.AsyncMock(
return_value=InvoiceResponse(
ok=False,
checking_id="cancelled",
error_message="cancelled",
)
),
)
cancelled = await api_payments_cancel(
CancelInvoice(payment_hash=cancel_hash),
WalletTypeInfo(key_type=KeyType.admin, wallet=wallet),
)
assert cancelled.failed is True
cancel_mock.assert_awaited_once()
async def _create_payment(
wallet_id: str,
*,
amount_msat: int,
status: PaymentState = PaymentState.SUCCESS,
payment_hash: str | None = None,
tag: str | None = None,
) -> str:
checking_id = f"checking_{uuid4().hex[:8]}"
await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet_id,
payment_hash=payment_hash or uuid4().hex,
bolt11=f"bolt11_{checking_id}",
amount_msat=amount_msat,
memo=f"payment_{checking_id}",
extra={"tag": tag} if tag else {},
),
status=status,
)
return checking_id

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