Compare commits

..
Author SHA1 Message Date
Vlad Stan d5783b4291 chore: bundle 2026-05-25 15:52:29 +03:00
Vlad Stan da8fba8a9b chore: code check 2026-05-25 15:50:46 +03:00
Vlad Stan 7f73da1f22 chore: update deps 2026-05-25 15:04:34 +03:00
ArcandVlad Stan 939a7f242b cleanup 2026-05-25 14:56:22 +03:00
ArcandVlad Stan 18c31280ef cleanup 2026-05-25 14:56:20 +03:00
ArcandVlad Stan 7d20c81ff9 Hopefully this didnt change too much 2026-05-25 14:56:18 +03:00
arcbtcandVlad Stan 46a3a24ce4 make
add wasmtime

bundle

mypy fix

mypy

mypy

pyright

pyright

pyright

make

lock file
2026-05-25 14:56:15 +03:00
ArcandVlad Stan 46bf984c05 tweaks 2026-05-25 14:56:04 +03:00
ArcandVlad Stan f3dd667f4a minimal 2026-05-25 14:56:04 +03:00
dni ⚡andGitHub 190a466c0a fix: update_payment should return the updated payment (#3983) 2026-05-25 14:29:39 +03:00
Riccardo BalboandGitHub d01e3523d8 Merge commit from fork 2026-05-25 14:28:11 +03:00
krviandGitHub 88672501d8 fix: make payments tags chart display dependant on showPaymentTags (#3943) 2026-05-25 10:24:40 +03:00
dni ⚡andGitHub ce57d08163 chore: update to v1.5.5-rc1 (#3990) 2026-05-24 21:41:05 +02:00
52304e0730 feat: allow extra appending (#3974)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:29:13 +01:00
6664eebf5a feat: add visibility toggle to inputs (#3940)
Co-authored-by: Arc <ben@arc.wales>
2026-05-22 14:14:19 +01:00
2a2af81827 feat: hide burger bg toggle (#3969)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-05-22 14:04:46 +01:00
ArcandGitHub 9b47f6323f fIx: pyinstrument import needed for nix (#3985) 2026-05-22 13:56:06 +03:00
Vlad StanandGitHub a61807a257 feat: square integration (#3962) 2026-05-22 12:37:51 +03:00
Vlad StanandGitHub 30e0522419 fix: stricter asset upload (#3982) 2026-05-21 12:44:23 +02:00
dni ⚡andGitHub 8f033a6047 fix: OIDC SSO login issues. closes #3970 (#3979) 2026-05-20 09:27:02 +02:00
dni ⚡andGitHub a2c817a56b chore: update python packages (#3977) 2026-05-19 10:58:14 +02:00
555350085e feat: add min-release-age to .npmrc + update packages (#3975)
Co-authored-by: alan <alan@lnbits.com>
2026-05-19 09:41:32 +02:00
dni ⚡andGitHub fc5061a67f chore: remove unused library Chart (#3976) 2026-05-19 09:36:14 +02:00
Tiago VasconcelosandGitHub c9c68bd8d7 Fix: Use default reaction on bootstrap (#3965) 2026-05-14 04:56:41 +02:00
65 changed files with 7301 additions and 27793 deletions
+2
View File
@@ -119,6 +119,8 @@ 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"
######################################
+1
View File
@@ -0,0 +1 @@
min-release-age=7
+40 -1
View File
@@ -24,7 +24,7 @@ from lnbits.core.crud import (
update_installed_extension_state,
)
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.helpers import migrate_extension_database
from lnbits.core.helpers import get_extension_type, migrate_extension_database
from lnbits.core.models.notifications import NotificationType
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
from lnbits.core.services.notifications import enqueue_admin_notification
@@ -423,6 +423,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks."""
if ext.extension_type == "wasm":
return
ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"):
@@ -432,6 +434,43 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = get_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
try:
register_wasm_ext_routes = None
for module_name in (
"wasm.wasm_host.extension_host",
"lnbits.extensions.wasm.wasm_host.extension_host",
):
try:
module = importlib.import_module(module_name)
except Exception:
logger.warning(
f"Could not import module {module_name} for wasm extension "
f"{ext.code}."
)
continue
register_wasm_ext_routes = getattr(
module, "register_wasm_ext_routes", None
)
if register_wasm_ext_routes is not None:
break
except Exception: # pragma: no cover - optional parent extension
logger.error(
"WASM host extension not installed; cannot register wasm extension "
f"{ext.code}."
)
return
if register_wasm_ext_routes is None:
logger.error(
"WASM host extension missing register_wasm_ext_routes; cannot "
f"register wasm extension {ext.code}."
)
return
register_wasm_ext_routes(app, ext)
return
ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext")
+4 -5
View File
@@ -306,7 +306,7 @@ async def update_payment_checking_id(
await (conn or db).execute(
f"""
UPDATE apipayments
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
WHERE checking_id = :old_id
""", # noqa: S608
{
@@ -321,13 +321,15 @@ async def update_payment(
payment: Payment,
new_checking_id: str | None = None,
conn: Connection | None = None,
) -> None:
) -> Payment:
payment.updated_at = datetime.now(timezone.utc)
await (conn or db).update(
"apipayments", payment, "WHERE checking_id = :checking_id"
)
if new_checking_id and new_checking_id != payment.checking_id:
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
payment.checking_id = new_checking_id
return payment
async def get_payments_history(
@@ -399,7 +401,6 @@ async def get_payment_count_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentCountStat]:
if not filters:
filters = Filters()
extra_stmts = []
@@ -432,7 +433,6 @@ async def get_daily_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
if not filters:
filters = Filters()
@@ -482,7 +482,6 @@ async def get_wallets_stats(
user_id: str | None = None,
conn: Connection | None = None,
) -> list[PaymentWalletStats]:
if not filters:
filters = Filters()
+61
View File
@@ -1,5 +1,7 @@
import importlib
import json
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
from uuid import UUID
@@ -22,6 +24,8 @@ from lnbits.settings import settings
async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None
):
if _is_wasm_extension(ext):
return
try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -34,6 +38,63 @@ async def migrate_extension_database(
await run_migration(ext_conn, ext_migrations, ext.id, current_version)
def get_extension_type(ext_id: str, ext_dir: Path | None = None) -> str | None:
candidate_dirs = []
if ext_dir:
candidate_dirs.append(ext_dir)
candidate_dirs.extend(
[
Path(settings.lnbits_extensions_path, "extensions", ext_id),
Path(settings.lnbits_extensions_path, ext_id),
Path(settings.lnbits_path, "lnbits", "extensions", ext_id),
Path(settings.lnbits_path, "extensions", ext_id),
Path.cwd() / "lnbits" / "extensions" / ext_id,
Path.cwd() / "extensions" / ext_id,
]
)
for base in candidate_dirs:
try:
conf_path = base / "config.json"
if not conf_path.is_file():
continue
with open(conf_path) as json_file:
config_json = json.load(json_file)
ext_type = config_json.get("extension_type")
if ext_type:
return ext_type
except Exception as exc:
logger.debug(
"Failed to read extension config.json for '{}' in '{}': {}",
ext_id,
base,
exc,
)
for base in candidate_dirs:
try:
wasm_dir = base / "wasm"
if (wasm_dir / "module.wasm").is_file() or (
wasm_dir / "module.wat"
).is_file():
return "wasm"
except Exception as exc:
logger.debug(
"Failed to probe wasm files for '{}' in '{}': {}",
ext_id,
base,
exc,
)
return None
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and getattr(ext.meta, "extension_type", None) == "wasm":
return True
return get_extension_type(ext.id, ext.ext_dir) == "wasm"
async def run_migration(
db: Connection,
migrations_module: Any,
+2
View File
@@ -25,6 +25,7 @@ from .payments import (
PaymentState,
PaymentWalletStats,
SettleInvoice,
UpdatePaymentExtra,
)
from .tinyurl import TinyURL
from .users import (
@@ -90,6 +91,7 @@ __all__ = [
"SimpleStatus",
"TinyURL",
"UpdateBalance",
"UpdatePaymentExtra",
"UpdateSuperuserPassword",
"UpdateUser",
"UpdateUserPassword",
+3
View File
@@ -115,6 +115,8 @@ class PayToEnableInfo(BaseModel):
class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None
granted_permissions: list[str] | None = None
granted_payment_tags: list[str] | None = None
class UserExtension(BaseModel):
@@ -147,6 +149,7 @@ class Extension(BaseModel):
short_description: str | None = None
tile: str | None = None
upgrade_hash: str | None = ""
extension_type: str | None = None
@property
def module_name(self) -> str:
+5
View File
@@ -35,6 +35,11 @@ class PaymentExtra(BaseModel):
lnurl_response: str | None = None
class UpdatePaymentExtra(BaseModel):
payment_hash: str
extra: dict = Field(default_factory=dict)
class PayInvoice(BaseModel):
payment_request: str
description: str | None = None
+127 -3
View File
@@ -1,7 +1,9 @@
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
@@ -10,11 +12,48 @@ 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.")
if file.content_type.lower() not in settings.lnbits_assets_allowed_mime_types:
content_type = normalize_asset_mime_type(file.content_type)
filename = file.filename or "unnamed"
if content_type not in allowed_asset_mime_types():
raise ValueError(f"File type '{file.content_type}' not allowed.")
if not settings.is_unlimited_assets_user(user_id):
@@ -30,14 +69,26 @@ 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=file.content_type,
mime_type=stored_mime_type,
is_public=is_public,
name=file.filename or "unnamed",
name=filename,
size_bytes=len(contents),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
@@ -51,6 +102,79 @@ 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 -21
View File
@@ -1,7 +1,6 @@
import hashlib
import hmac
import json
import math
import time
from base64 import b64encode
@@ -220,35 +219,27 @@ def check_revolut_signature(
logger.warning("Revolut webhook signing secret is not set.")
raise ValueError("Revolut webhook cannot be verified.")
timestamp = int(timestamp_header)
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
if not math.isfinite(timestamp_seconds):
try:
timestamp = int(timestamp_header)
except ValueError as exc:
logger.warning("Invalid Revolut timestamp.")
raise ValueError("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}")
candidates = [
b"v1." + timestamp_header.encode() + b"." + payload,
payload,
f"{timestamp_header}.{payload.decode()}".encode(),
timestamp_header.encode() + b"." + payload,
]
signatures = []
for candidate in candidates:
digest = hmac.new(
key=secret.encode(), msg=candidate, digestmod=hashlib.sha256
).digest()
signatures.extend(
[digest.hex(), f"v1={digest.hex()}", b64encode(digest).decode()]
)
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, provided)
for expected in signatures
hmac.compare_digest(expected_signature, provided)
for provided in provided_signatures
):
logger.warning("Revolut signature verification failed.")
+8 -7
View File
@@ -171,15 +171,15 @@ async def create_fiat_invoice(
internal_payment.fiat_provider = fiat_provider_name
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
# todo: move to payent
# TODO: move to payment
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
new_checking_id = (
f"fiat_{fiat_provider_name}_"
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
)
await update_payment(internal_payment, new_checking_id, conn=conn)
internal_payment.checking_id = new_checking_id
internal_payment = await update_payment(
internal_payment, new_checking_id, conn=conn
)
return internal_payment
@@ -374,7 +374,7 @@ async def update_pending_payment(
status = await check_payment_status(payment)
if status.failed:
payment.status = PaymentState.FAILED
await update_payment(payment, conn=conn)
payment = await update_payment(payment, conn=conn)
elif status.success:
payment = await update_payment_success_status(payment, status, conn=conn)
return payment
@@ -876,7 +876,7 @@ async def update_payment_success_status(
payment.status = PaymentState.SUCCESS
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
payment.preimage = payment.preimage or status.preimage
await update_payment(payment, conn=conn)
payment = await update_payment(payment, conn=conn)
return payment
@@ -1099,8 +1099,9 @@ async def update_invoice_callback(checking_id: str) -> Payment | None:
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)
payment = await update_payment(payment)
if payment.fiat_provider:
await handle_fiat_payment_confirmation(payment)
return payment
+25 -9
View File
@@ -16,7 +16,14 @@ 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 create_user_asset
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.db import Filters, Page
from lnbits.decorators import (
check_account_id_exists,
@@ -75,11 +82,7 @@ async def api_get_asset_data(
if not asset:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
content=asset.data,
media_type=asset.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset.name}"'},
)
return asset_response(asset.data, asset.mime_type, asset.name)
@asset_router.get(
@@ -101,14 +104,14 @@ async def api_get_asset_thumbnail(
if not asset_info:
raise HTTPException(HTTPStatus.NOT_FOUND, "Asset not found.")
return Response(
return asset_response(
content=(
base64.b64decode(asset_info.thumbnail_base64)
if asset_info.thumbnail_base64
else b""
),
media_type=asset_info.mime_type,
headers={"Content-Disposition": f'inline; filename="{asset_info.name}"'},
media_type=thumbnail_media_type(),
filename=asset_info.name,
)
@@ -172,3 +175,16 @@ 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),
},
)
+5 -1
View File
@@ -348,7 +348,11 @@ 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.")
user_id = decrypt_internal_message(provider_sso.state)
if provider_sso.state is None or provider_sso.state == "null":
user_id = None
else:
user_id = decrypt_internal_message(provider_sso.state)
request.session.pop("user", None)
return await _handle_sso_login(userinfo, user_id)
+94 -13
View File
@@ -18,7 +18,11 @@ from lnbits.core.services.fiat_providers import (
check_stripe_signature,
verify_paypal_webhook,
)
from lnbits.core.services.payments import create_fiat_invoice
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.fiat.base import FiatSubscriptionPaymentOptions
@@ -355,6 +359,7 @@ async def handle_revolut_event(event: dict):
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)
@@ -377,16 +382,37 @@ async def handle_revolut_event(event: dict):
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_fiat_provider("revolut")
if not isinstance(fiat_provider, RevolutWallet):
logger.warning("Revolut fiat provider is not configured.")
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")
)
@@ -429,21 +455,76 @@ async def _handle_revolut_subscription_initiated(event: dict):
"payment_request": order.get("checkout_url") or "",
},
}
lnbits_payment = await create_fiat_invoice(
lnbits_payment = await _create_revolut_subscription_payment(
wallet_id=reference.wallet_id,
invoice_data=CreateInvoice(
unit=currency,
amount=amount_minor / 100,
memo=reference.memo or "",
extra=extra,
fiat_provider="revolut",
external_id=subscription_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))
+53
View File
@@ -1,6 +1,8 @@
import json
import sys
import traceback
from http import HTTPStatus
from pathlib import Path
import httpx
from bolt11 import decode as bolt11_decode
@@ -11,6 +13,7 @@ from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids
from lnbits.core.db import db
from lnbits.core.helpers import get_extension_type
from lnbits.core.models import (
SimpleStatus,
)
@@ -67,6 +70,53 @@ extension_router = APIRouter(
)
def _load_wasm_extension_config(ext_id: str) -> dict:
candidate_dirs = [
Path(settings.lnbits_extensions_path, "extensions", ext_id),
Path(settings.lnbits_path, "lnbits", "extensions", ext_id),
]
for base in candidate_dirs:
conf_path = base / "config.json"
if not conf_path.is_file():
continue
try:
with open(conf_path) as json_file:
config = json.load(json_file)
return config if isinstance(config, dict) else {}
except Exception:
return {}
return {}
def _ensure_wasm_permissions_saved(ext_id: str, user_ext: UserExtension) -> None:
if get_extension_type(ext_id) != "wasm":
return
config = _load_wasm_extension_config(ext_id)
required = [
permission.get("id")
for permission in config.get("permissions", [])
if isinstance(permission, dict) and permission.get("id")
]
granted = user_ext.extra.granted_permissions if user_ext.extra else []
missing = [
permission for permission in required if permission not in (granted or [])
]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Save WASM permissions before enabling this extension.",
)
payment_tags = config.get("payment_tags", [])
granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else []
has_granted_payment_tag = any(tag in (granted_tags or []) for tag in payment_tags)
if payment_tags and not has_granted_payment_tag:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Select at least one WASM payment tag before enabling this extension.",
)
@extension_router.post("", dependencies=[Depends(check_admin)])
async def api_install_extension(data: CreateExtension):
release = await InstallableExtension.get_extension_release(
@@ -197,6 +247,8 @@ async def api_enable_extension(
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
_ensure_wasm_permissions_saved(ext_id, user_ext)
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
@@ -596,6 +648,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"isPaymentRequired": ext.requires_payment,
"inProgress": False,
"selectedForUpdate": False,
"extensionType": get_extension_type(ext.id) or "python",
}
for ext in installable_exts
]
+33
View File
@@ -34,6 +34,7 @@ from lnbits.core.models import (
PaymentWalletStats,
SettleInvoice,
SimpleStatus,
UpdatePaymentExtra,
)
from lnbits.core.models.payments import UpdatePaymentLabels
from lnbits.core.models.users import AccountId
@@ -297,6 +298,38 @@ async def api_update_payment_labels(
return SimpleStatus(success=True, message="Payment labels updated.")
@payment_router.patch(
"/extra",
name="Update payment extra",
description="Append new extra metadata to a payment.",
response_model=Payment,
)
async def api_update_payment_extra(
data: UpdatePaymentExtra,
key_type: WalletTypeInfo = Depends(require_admin_key),
) -> Payment:
payment = await get_standalone_payment(
data.payment_hash, wallet_id=key_type.wallet.id
)
if payment is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
if not payment.success:
raise HTTPException(
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
)
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
if duplicate_keys:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
)
payment.extra.update(data.extra)
await update_payment(payment)
return payment
@payment_router.get("/fee-reserve")
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
invoice_obj = bolt11.decode(invoice)
+4
View File
@@ -95,6 +95,10 @@ 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"
+170 -20
View File
@@ -2,6 +2,7 @@ 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
@@ -56,6 +57,37 @@ REVOLUT_WEBHOOK_EVENTS = [
"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"""
@@ -93,7 +125,11 @@ class RevolutWallet(FiatProvider):
return FiatStatusResponse(balance=0)
try:
r = await self.client.get("/api/orders", params={"limit": 1}, timeout=15)
r = await self.client.get(
"/api/orders",
params={"limit": 1},
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
_ = r.json()
return FiatStatusResponse(balance=0)
@@ -118,7 +154,7 @@ class RevolutWallet(FiatProvider):
ok=False, error_message="Invalid Revolut options"
)
amount_minor = int(amount * 100)
amount_minor = self.amount_to_minor_units(amount, currency)
checkout = opts.checkout or RevolutCheckoutOptions()
success_url = (
checkout.success_url
@@ -139,7 +175,9 @@ class RevolutWallet(FiatProvider):
}
try:
r = await self.client.post("/api/orders", json=payload)
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")
@@ -183,13 +221,6 @@ class RevolutWallet(FiatProvider):
)
extra = payment_options.extra or {}
customer_id = extra.get("customer_id")
if not customer_id:
return FiatSubscriptionResponse(
ok=False,
error_message="Revolut subscriptions require extra.customer_id.",
)
if not payment_options.subscription_request_id:
payment_options.subscription_request_id = urlsafe_short_hash()
@@ -202,7 +233,6 @@ class RevolutWallet(FiatProvider):
)
payload: dict[str, Any] = {
"plan_variation_id": subscription_id,
"customer_id": customer_id,
"external_reference": self._serialize_subscription_reference(reference),
"setup_order_redirect_url": (
payment_options.success_url
@@ -219,8 +249,17 @@ class RevolutWallet(FiatProvider):
}
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
"/api/subscriptions",
json=payload,
headers=headers,
timeout=REVOLUT_REQUEST_TIMEOUT,
)
r.raise_for_status()
data = r.json()
@@ -244,7 +283,7 @@ class RevolutWallet(FiatProvider):
return FiatSubscriptionResponse(
ok=True,
checkout_session_url=checkout_url,
subscription_request_id=revolut_subscription_id,
subscription_request_id=payment_options.subscription_request_id,
)
except json.JSONDecodeError:
return FiatSubscriptionResponse(
@@ -263,7 +302,10 @@ class RevolutWallet(FiatProvider):
**kwargs,
) -> FiatSubscriptionResponse:
try:
r = await self.client.post(f"/api/subscriptions/{subscription_id}/cancel")
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:
@@ -304,12 +346,16 @@ class RevolutWallet(FiatProvider):
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}")
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}")
r = await self.client.get(
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()
@@ -317,7 +363,60 @@ class RevolutWallet(FiatProvider):
self, subscription_id: str, cycle_id: str
) -> dict[str, Any]:
r = await self.client.get(
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}"
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()
@@ -354,13 +453,15 @@ class RevolutWallet(FiatProvider):
existing["already_exists"] = True
return existing
response = await client.post("/api/webhooks", json=payload, timeout=15)
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=15)
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
@@ -385,7 +486,9 @@ class RevolutWallet(FiatProvider):
if webhook_id and (
not webhook.get("events") or not webhook.get("signing_secret")
):
response = await client.get(f"/api/webhooks/{webhook_id}", timeout=15)
response = await client.get(
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
)
response.raise_for_status()
webhook = response.json()
@@ -445,6 +548,25 @@ class RevolutWallet(FiatProvider):
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:
@@ -485,3 +607,31 @@ class RevolutWallet(FiatProvider):
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
+4
View File
@@ -137,6 +137,10 @@ class SquareWallet(FiatProvider):
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
+3 -5
View File
@@ -300,6 +300,7 @@ 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):
@@ -323,11 +324,6 @@ 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)
@@ -1286,6 +1282,7 @@ 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")
@@ -1349,6 +1346,7 @@ 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,6 +395,13 @@ 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;
+2
View File
@@ -490,6 +490,8 @@ 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)',
+3 -1
View File
@@ -452,7 +452,9 @@ window.app.component('username-password', {
confirmationMethod: 'code',
confirmationEmail: '',
confirmationCode: this.invitationCode || '',
showConfirmationCode: false
showConfirmationCode: false,
showPwd: false,
showPwdRepeat: false
}
},
methods: {
@@ -69,6 +69,14 @@ 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) {
@@ -150,6 +158,9 @@ 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(
+6 -5
View File
@@ -1,16 +1,17 @@
function eventReaction(amount) {
localUrl = ''
reaction = localStorage.getItem('lnbits.reactions')
if (!reaction || reaction === 'None') {
const reaction =
Quasar.LocalStorage.getItem('lnbits.reactions') || SETTINGS.defaultReaction
if (!reaction || reaction.toLowerCase() === 'none') {
return
}
try {
if (amount < 0) {
return
}
reaction = localStorage.getItem('lnbits.reactions')
if (reaction) {
window[reaction.split('|')[1]]()
if (typeof window[reaction] === 'function') {
window[reaction]()
}
} catch (e) {
console.log(e)
+4
View File
@@ -29,6 +29,10 @@ 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',
+2 -1
View File
@@ -732,7 +732,8 @@ window.PageAccount = {
darkChoice: this.g.settings.defaultDark,
cardRoundedChoice: this.g.settings.defaultCardRounded,
cardGradientChoice: this.g.settings.defaultCardGradient,
cardShadowChoice: this.g.settings.defaultCardShadow
cardShadowChoice: this.g.settings.defaultCardShadow,
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
}
this.siteCustomisationChanged(defaults)
}
+157
View File
@@ -28,6 +28,14 @@ window.PageExtensions = {
paylinkWebsocket: null,
searchToggle: false,
reviewsUrl: null,
permissionsDialog: {
show: false,
extension: null,
checked: [],
missing: [],
tags: [],
tagOptions: []
},
reviewsDialog: {
show: false,
extension: null,
@@ -92,6 +100,20 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val)
}
},
computed: {
permissionsAllChecked() {
const ext = this.permissionsDialog.extension
if (!ext || !Array.isArray(ext.permissions)) return true
const required = ext.permissions.map(p => p.id)
return required.every(p => this.permissionsDialog.checked.includes(p))
},
permissionsHasMissingEndpoints() {
return (
this.permissionsDialog.missing &&
this.permissionsDialog.missing.length > 0
)
}
},
methods: {
filterExtensions(term, tab) {
// Filter the extensions list
@@ -240,7 +262,81 @@ window.PageExtensions = {
extension.inProgress = false
})
},
async loadWasmCapabilities(extension) {
const {data} = await LNbits.api.request(
'GET',
`/wasm/api/v1/extensions/${extension.id}/capabilities`,
this.g.user.wallets[0].adminkey
)
const capabilities = data || {}
extension.permissions = capabilities.permissions || []
extension.paymentTags = capabilities.payment_tags || []
extension.grantedPermissions = capabilities.granted_permissions || []
extension.grantedPaymentTags = capabilities.granted_payment_tags || []
extension._grantedPermissions = extension.grantedPermissions.slice()
extension._grantedPaymentTags = extension.grantedPaymentTags.slice()
return capabilities
},
showWasmPermissionsDialog(extension, data = {}) {
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = (
data.granted_permissions ||
extension._grantedPermissions ||
extension.grantedPermissions ||
[]
).slice()
this.permissionsDialog.missing = data.missing_permissions || []
this.permissionsDialog.tags = (
data.granted_payment_tags ||
extension._grantedPaymentTags ||
extension.grantedPaymentTags ||
[]
).slice()
this.permissionsDialog.tagOptions =
data.payment_tags || extension.paymentTags || []
this.permissionsDialog.show = true
},
async ensureWasmPermissionsReady(extension) {
if (extension.extensionType !== 'wasm') return true
const wasmHost = this.extensions.find(ext => ext.id === 'wasm')
if (!wasmHost || !wasmHost.isInstalled || !wasmHost.isActive) {
Quasar.Notify.create({
type: 'warning',
message:
'Enable the WASM! host extension before using this extension.'
})
return false
}
let data = {}
try {
data = await this.loadWasmCapabilities(extension)
} catch (err) {
LNbits.utils.notifyApiError(err)
return false
}
const required = (data.permissions || []).map(p => p.id).filter(Boolean)
const missing = required.filter(
permission => !extension.grantedPermissions.includes(permission)
)
const tags = data.payment_tags || []
const missingTags =
tags.length &&
!tags.some(tag => extension.grantedPaymentTags.includes(tag))
if (!missing.length && !missingTags) return true
Quasar.Notify.create({
type: 'warning',
message: 'Save WASM permissions before using this extension.'
})
this.showWasmPermissionsDialog(extension, data)
return false
},
async openExtension(extension) {
if (!(await this.ensureWasmPermissionsReady(extension))) return
window.location.href = `${extension.id}/`
},
async enableExtensionForUser(extension) {
if (!(await this.ensureWasmPermissionsReady(extension))) return
if (extension.isPaymentRequired) {
this.showPayToEnable(extension)
return
@@ -294,6 +390,67 @@ window.PageExtensions = {
this.selectedExtension.payToEnable.showQRCode = false
this.showPayToEnableDialog = true
},
cancelPermissionsDialog() {
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
},
async openPermissionsForExtension(extension) {
if (extension.extensionType !== 'wasm') {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not use WASM permissions.'
})
return
}
try {
const data = await this.loadWasmCapabilities(extension)
this.showWasmPermissionsDialog(extension, data)
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
async confirmPermissionsDialog() {
const ext = this.permissionsDialog.extension
const granted = this.permissionsDialog.checked.slice()
const tags = this.permissionsDialog.tags.slice()
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
const missing = this.permissionsDialog.missing || []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
if (missing.length) {
Quasar.Notify.create({
type: 'negative',
message: 'Missing API endpoints for one or more permissions.'
})
return
}
if (!ext) return
ext._grantedPermissions = granted
ext.grantedPermissions = granted
ext._grantedPaymentTags = tags
ext.grantedPaymentTags = tags
try {
await LNbits.api.request(
'PUT',
`/wasm/api/v1/extensions/${ext.id}/permissions`,
this.g.user.wallets[0].adminkey,
{permissions: granted, payment_tags: tags}
)
Quasar.Notify.create({
type: 'positive',
message: 'Permissions saved.'
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
updatePayToInstallData(extension) {
LNbits.api
.request(
+9
View File
@@ -70,3 +70,12 @@ 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;
}
}
File diff suppressed because it is too large Load Diff
-47
View File
@@ -1,47 +0,0 @@
/*
* 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;
}
+867 -497
View File
File diff suppressed because it is too large Load Diff
+2773 -3121
View File
File diff suppressed because it is too large Load Diff
+222 -132
View File
@@ -1,5 +1,5 @@
/*!
* qrcode.vue v3.6.0
* qrcode.vue v3.9.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,7 +909,18 @@ 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,
@@ -929,74 +940,139 @@ 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 ops = [];
modules.forEach(function (row, y) {
var pathSegments = [];
for (var y = 0; y < modules.length; y++) {
var row = modules[y];
var start = null;
row.forEach(function (cell, x) {
for (var x = 0; x < row.length; x++) {
var cell = row[x];
if (!cell && start !== null) {
// M0 0h7v1H0z injects the space with the move and drops the comma,
// saving a char per operation
ops.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
pathSegments.push("M".concat(start + margin, " ").concat(y + margin, "h").concat(x - start, "v1H").concat(start + margin, "z"));
start = null;
return;
continue;
}
// 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.
return;
continue;
}
if (start === null) {
// Just a single dark module.
ops.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
pathSegments.push("M".concat(x + margin, ",").concat(y + margin, " h1v1H").concat(x + margin, "z"));
}
else {
// Otherwise finish the current line.
ops.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
pathSegments.push("M".concat(start + margin, ",").concat(y + margin, " h").concat(x + 1 - start, "v1H").concat(start + margin, "z"));
}
return;
continue;
}
if (cell && start === null) {
start = x;
}
});
});
return ops.join('');
}
}
return pathSegments.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 * 0.1);
var defaultSize = Math.floor(size * DEFAULT_IMAGE_SIZE_RATIO);
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 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 };
var borderRadius = (imageSettings.borderRadius || 0) * scale;
return { x: x, y: y, h: h, w: w, borderRadius: borderRadius };
}
function excavateModules(modules, excavation) {
return modules.slice().map(function (row, y) {
if (y < excavation.y || y >= excavation.y + excavation.h) {
return row;
}
return row.map(function (cell, x) {
if (x < excavation.x || x >= excavation.x + excavation.w) {
return cell;
}
return false;
});
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);
}
return generatePath(cells.value, margin.value);
});
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: {
@@ -1006,7 +1082,7 @@ var QRCodeProps = {
},
size: {
type: Number,
default: 100,
default: DEFAULT_QR_SIZE,
},
level: {
type: String,
@@ -1024,7 +1100,7 @@ var QRCodeProps = {
margin: {
type: Number,
required: false,
default: 0,
default: DEFAULT_MARGIN,
},
imageSettings: {
type: Object,
@@ -1052,6 +1128,12 @@ 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,
@@ -1063,36 +1145,11 @@ var QrcodeSvg = vue.defineComponent({
name: 'QRCodeSvg',
props: QRCodeProps,
setup: function (props) {
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 () {
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 () {
if (!props.gradient)
return null;
var gradientProps = props.gradientType === 'linear'
@@ -1109,7 +1166,7 @@ var QrcodeSvg = vue.defineComponent({
fx: '50%',
fy: '50%',
};
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: 'qr-gradient' }, gradientProps), [
return vue.h(props.gradientType === 'linear' ? 'linearGradient' : 'radialGradient', __assign({ id: qrGradientId }, gradientProps), [
vue.h('stop', {
offset: '0%',
style: { stopColor: props.gradientStartColor },
@@ -1119,27 +1176,52 @@ var QrcodeSvg = vue.defineComponent({
style: { stopColor: props.gradientEndColor },
}),
]);
};
generate();
vue.onUpdated(generate);
});
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,
}),
]);
});
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', {}, [renderGradient()]),
vue.h('defs', {}, [gradientVNode.value, clipPathVNode.value]),
vue.h('rect', {
width: '100%',
height: '100%',
fill: props.background,
}),
vue.h('path', {
fill: props.gradient ? 'url(#qr-gradient)' : props.foreground,
fill: props.gradient ? "url(#".concat(qrGradientId, ")") : props.foreground,
d: fgPath.value,
}),
props.imageSettings.src && vue.h('image', __assign({ href: props.imageSettings.src }, imageProps)),
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, ")") } : {}))),
]); };
},
});
@@ -1147,81 +1229,89 @@ 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 imageRef = 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 generate = function () {
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 size = props.size, background = props.background, foreground = props.foreground, gradient = props.gradient, gradientType = props.gradientType, gradientStartColor = props.gradientStartColor, gradientEndColor = props.gradientEndColor;
var canvas = canvasEl.value;
if (!canvas) {
return;
}
var ctx = canvas.getContext('2d');
if (!ctx) {
var canvasCtx = canvas.getContext('2d');
if (!canvasCtx) {
return;
}
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;
var image = imageEl.value;
var devicePixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
var scale = (size / numCells.value) * devicePixelRatio;
canvas.height = canvas.width = size * devicePixelRatio;
ctx.scale(scale, scale);
ctx.fillStyle = background;
ctx.fillRect(0, 0, numCells, numCells);
canvasCtx.setTransform(scale, 0, 0, scale, 0, 0);
canvasCtx.fillStyle = background;
canvasCtx.fillRect(0, 0, numCells.value, numCells.value);
if (gradient) {
var grad = void 0;
if (gradientType === 'linear') {
grad = ctx.createLinearGradient(0, 0, numCells, numCells);
grad = canvasCtx.createLinearGradient(0, 0, numCells.value, numCells.value);
}
else {
grad = ctx.createRadialGradient(numCells / 2, numCells / 2, 0, numCells / 2, numCells / 2, numCells / 2);
grad = canvasCtx.createRadialGradient(numCells.value / 2, numCells.value / 2, 0, numCells.value / 2, numCells.value / 2, numCells.value / 2);
}
grad.addColorStop(0, gradientStartColor);
grad.addColorStop(1, gradientEndColor);
ctx.fillStyle = grad;
canvasCtx.fillStyle = grad;
}
else {
ctx.fillStyle = foreground;
canvasCtx.fillStyle = foreground;
}
if (SUPPORTS_PATH2D) {
ctx.fill(new Path2D(generatePath(cells, margin)));
canvasCtx.fill(new Path2D(fgPath.value));
}
else {
cells.forEach(function (row, rdx) {
cells.value.forEach(function (row, rdx) {
row.forEach(function (cell, cdx) {
if (cell) {
ctx.fillRect(cdx + margin, rdx + margin, 1, 1);
canvasCtx.fillRect(cdx + margin.value, rdx + margin.value, 1, 1);
}
});
});
}
if (showImage) {
ctx.drawImage(image, imageProps.x, imageProps.y, imageProps.width, imageProps.height);
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);
}
}
};
vue.onMounted(generate);
vue.onUpdated(generate);
var style = ctx.attrs.style;
vue.watchEffect(generate);
return function () { return vue.h(vue.Fragment, [
vue.h('canvas', __assign(__assign({}, ctx.attrs), { ref: canvasEl, style: __assign(__assign({}, style), { width: "".concat(props.size, "px"), height: "".concat(props.size, "px") }) })),
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") }) })),
props.imageSettings.src && vue.h('img', {
ref: imageRef,
ref: imageEl,
src: props.imageSettings.src,
style: { display: 'none' },
onLoad: generate,
@@ -1231,23 +1321,23 @@ var QrcodeCanvas = vue.defineComponent({
});
var QrcodeVue = vue.defineComponent({
name: 'Qrcode',
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,
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,
}); };
},
});
exports.QrcodeCanvas = QrcodeCanvas;
+15 -28
View File
@@ -43,8 +43,7 @@ summary {
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
text-decoration: underline dotted;
}
/**
@@ -200,8 +199,7 @@ input[type=search]::-webkit-search-decoration {
.material-symbols-outlined,
.material-symbols-rounded,
.material-symbols-sharp {
-webkit-user-select: none;
user-select: none;
user-select: none;
cursor: inherit;
font-size: inherit;
display: inline-flex;
@@ -998,8 +996,7 @@ input[type=search]::-webkit-search-decoration {
height: 1px;
}
.q-checkbox__bg, .q-checkbox__icon-container {
-webkit-user-select: none;
user-select: none;
user-select: none;
}
.q-checkbox__bg {
top: 25%;
@@ -2212,8 +2209,7 @@ body.q-ios-padding .q-dialog__inner > div {
width: 100%;
min-width: 0;
outline: 0 !important;
-webkit-user-select: auto;
user-select: auto;
user-select: auto;
}
.q-field__native:-webkit-autofill, .q-field__input:-webkit-autofill {
-webkit-animation-name: q-autofill;
@@ -3039,8 +3035,7 @@ body.body--dark .q-knob--editable:focus:before {
z-index: 2001;
height: 100%;
width: 15px;
-webkit-user-select: none;
user-select: none;
user-select: none;
}
.q-layout, .q-header, .q-footer, .q-page {
@@ -3297,8 +3292,7 @@ body.platform-ios .q-layout--containerized {
height: 1px;
}
.q-radio__bg, .q-radio__icon-container {
-webkit-user-select: none;
user-select: none;
user-select: none;
}
.q-radio__bg {
top: 25%;
@@ -3782,8 +3776,7 @@ 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;
-webkit-user-select: none;
user-select: none;
user-select: none;
cursor: pointer;
}
@@ -4156,8 +4149,7 @@ 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);
-webkit-user-select: none;
user-select: none;
user-select: none;
position: relative;
z-index: 1;
}
@@ -4246,8 +4238,7 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
color: #000;
}
.q-stepper__tab--navigation {
-webkit-user-select: none;
user-select: none;
user-select: none;
cursor: pointer;
}
.q-stepper__tab--active, .q-stepper__tab--done {
@@ -4479,8 +4470,7 @@ body.desktop .q-slider.q-slider--enabled .q-slider__track-container:hover .q-sli
.q-table th {
font-weight: 500;
font-size: 12px;
-webkit-user-select: none;
user-select: none;
user-select: none;
}
.q-table th.sortable {
cursor: pointer;
@@ -5484,8 +5474,7 @@ 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);
-webkit-user-select: none;
user-select: none;
user-select: none;
z-index: 0;
}
.q-toggle__thumb:after {
@@ -10337,6 +10326,7 @@ 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;
@@ -10368,8 +10358,7 @@ body.body--dark .inset-shadow-down {
text-decoration: none;
}
.q-link--focusable:focus-visible {
-webkit-text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
text-decoration: underline dashed currentColor 1px;
}
body.electron .q-electron-drag {
@@ -10386,8 +10375,7 @@ img.responsive {
}
.non-selectable {
-webkit-user-select: none !important;
user-select: none !important;
user-select: none !important;
}
.scroll,
@@ -11045,8 +11033,7 @@ body.q-ios-padding .fullscreen {
}
.q-touch {
-webkit-user-select: none;
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
+5
View File
@@ -92,6 +92,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
invoice_listeners[name] = send_chan
def unregister_invoice_listener(name: str) -> None:
if name in invoice_listeners:
invoice_listeners.pop(name, None)
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
+35 -5
View File
@@ -774,7 +774,13 @@ include('components/lnbits-error.vue') %}
v-model="password"
name="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : 'password'"
><template v-slot:append>
<q-icon
:name="showPwd ? 'visibility' : 'visibility_off'"
class="cursor-pointer"
@click="showPwd = !showPwd"
/> </template
></q-input>
<div class="row justify-end">
<q-btn
@@ -803,16 +809,28 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : '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="password"
:type="showPwdRepeat ? 'text' : '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"
@@ -925,16 +943,28 @@ include('components/lnbits-error.vue') %}
filled
v-model="password"
:label="$t('password') + ' *'"
type="password"
:type="showPwd ? 'text' : '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="password"
:type="showPwdRepeat ? 'text' : '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
@@ -866,6 +866,7 @@
</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>
@@ -1175,8 +1176,8 @@
<q-chip dense color="positive" text-color="white" icon="check"
>Checkout</q-chip
>
<q-chip dense color="positive" text-color="white" icon="check"
>Subscriptions</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
@@ -320,6 +320,15 @@
>
</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>
+24
View File
@@ -601,6 +601,30 @@
</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>
+98 -2
View File
@@ -291,8 +291,7 @@
"
flat
color="primary"
type="a"
:href="extension.id + '/'"
@click="openExtension(extension)"
:label="$t('open')"
></q-btn>
<q-btn
@@ -329,6 +328,18 @@
<span v-text="$t('enable_extension_details')">
</span> </q-tooltip
></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.extensionType === 'wasm'
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn
@click="showManageExtension(extension)"
@@ -921,6 +932,91 @@
</q-card>
</q-dialog>
<q-dialog v-model="permissionsDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
<q-card-section>
<div class="text-h6">Permissions required</div>
<div class="text-caption text-grey">This extension can:</div>
<q-list v-if="permissionsDialog.extension">
<q-item
v-for="perm in permissionsDialog.extension.permissions"
:key="perm.id || perm"
clickable
>
<q-item-section>
<q-item-label v-text="perm.label || perm"></q-item-label>
<q-item-label
caption
v-if="perm.description"
v-text="perm.description"
></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox
v-model="permissionsDialog.checked"
:val="perm.id || perm"
/>
</q-item-section>
</q-item>
</q-list>
<div
v-if="
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Allow this extension to listen for payment tags:
</div>
<q-list>
<q-item
v-for="tag in permissionsDialog.tagOptions"
:key="tag"
clickable
>
<q-item-section>
<q-item-label v-text="tag"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
</q-item-section>
</q-item>
</q-list>
</div>
<div
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
class="q-mt-md text-negative"
>
<div class="text-caption">
Missing API endpoints required by this extension:
</div>
<q-chip
v-for="perm in permissionsDialog.missing"
:key="perm"
:label="perm"
color="red-2"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="grey"
v-text="$t('cancel')"
@click="cancelPermissionsDialog"
></q-btn>
<q-btn
color="primary"
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
label="Save"
@click="confirmPermissionsDialog"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showExtensionDetailsDialog" position="top">
<q-card
v-if="selectedExtensionDetails"
+1 -1
View File
@@ -136,7 +136,7 @@
</q-card>
</div>
<div
v-show="chartData.showPaymentStatus"
v-show="chartData.showPaymentTags"
class="col-lg-3 col-md-6 col-sm-12 text-center"
>
<q-card class="q-pt-sm">
+8 -2
View File
@@ -88,6 +88,8 @@ class NWCWallet(Wallet):
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": payment["checking_id"]}
)
if payment_data.get("payment_hash") != payment["checking_id"]:
raise Exception("Mismatched payment hash")
settled = (
"settled_at" in payment_data
and payment_data["settled_at"]
@@ -264,6 +266,8 @@ class NWCWallet(Wallet):
payment_data = await self.conn.call(
"lookup_invoice", {"payment_hash": checking_id}
)
if payment_data.get("payment_hash") != checking_id:
raise Exception("Mismatched payment hash")
settled = payment_data.get("settled_at", None) and payment_data.get(
"preimage", None
)
@@ -520,8 +524,9 @@ class NWCConnection:
"""
sub_id = cast(str, msg[1])
event = cast(dict, msg[2])
if not verify_event(event): # Ensure the event is valid (do not trust relays)
raise Exception("Invalid event signature")
# Ensure the event is valid (do not trust relays)
if not verify_event(event) or event.get("pubkey") != self.service_pubkey_hex:
raise Exception("Invalid event")
tags = event["tags"]
if event["kind"] == 13194: # An info event
# info events are handled specially,
@@ -687,6 +692,7 @@ class NWCConnection:
"#p": [self.account_public_key_hex],
"#e": [event["id"]],
"since": event["created_at"],
"authors": [self.service_pubkey_hex],
}
sub_id = self._get_new_subid()
# register a future to receive the response asynchronously
+670 -198
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.7.4",
"prettier": "^3.8.3",
"pyright": "1.1.289",
"sass": "^1.94.2",
"terser": "^5.44.1"
"sass": "^1.99.0",
"terser": "^5.47.1"
},
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.16.0",
"chart.js": "^4.5.1",
"moment": "^2.30.1",
"nostr-tools": "^2.18.2",
"qrcode.vue": "^3.6.0",
"quasar": "2.18.6",
"nostr-tools": "^2.23.3",
"qrcode.vue": "^3.9.0",
"quasar": "2.19.3",
"showdown": "^2.1.0",
"underscore": "^1.13.8",
"vue": "3.5.25",
"vue-i18n": "^11.2.2",
"vue": "3.5.34",
"vue-i18n": "^11.4.2",
"vue-qrcode-reader": "^5.7.3",
"vue-router": "4.6.3",
"vue-router": "5.0.6",
"vuex": "4.1.0"
},
"vendor": [
Generated
+56 -1467
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "lnbits"
version = "1.5.4"
version = "1.5.5-rc1"
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,6 +51,8 @@ dependencies = [
"pillow~=12.1.0",
"python-dotenv~=1.2.1",
"greenlet~=3.3.0",
"urllib3>=2.7.0",
"pyinstrument>=5.1.2",
]
[project.scripts]
@@ -61,6 +63,7 @@ lnbits-cli = "lnbits.commands:main"
breez = ["breez-sdk~=0.8.0", "breez-sdk-liquid~=0.11.11"]
liquid = ["wallycore~=1.5.1"]
migration = ["psycopg2-binary~=2.9.11"]
wasm = ["wasmtime>=18.0.0"]
[dependency-groups]
dev = [
@@ -83,9 +86,11 @@ dev = [
"types-mock~=5.2.0.20250924",
"mock~=5.2.0",
"grpcio-tools~=1.76.0",
"pyinstrument>=5.1.2",
]
[tool.uv]
exclude-newer = "1 week"
[tool.poetry]
packages = [
{include = "lnbits"},
+60 -9
View File
@@ -13,14 +13,15 @@ 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.txt", b"hello world", "text/plain")},
files={"file": ("note.png", payload, "image/png")},
)
assert upload.status_code == 200
asset = upload.json()
assert asset["name"] == "note.txt"
assert asset["name"] == "note.png"
assert asset["is_public"] is False
page = await client.get("/api/v1/assets/paginated", headers=user_headers_from)
@@ -29,27 +30,30 @@ async def test_asset_api_upload_list_update_and_delete(
info = await client.get(f"/api/v1/assets/{asset['id']}", headers=user_headers_from)
assert info.status_code == 200
assert info.json()["name"] == "note.txt"
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 == b"hello world"
assert data.headers["content-disposition"] == 'inline; filename="note.txt"'
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.txt", "is_public": True},
json={"name": "renamed.png", "is_public": True},
)
assert updated.status_code == 200
assert updated.json()["name"] == "renamed.txt"
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 == b"hello world"
assert public_data.content == payload
deleted = await client.delete(
f"/api/v1/assets/{asset['id']}", headers=user_headers_from
@@ -98,15 +102,51 @@ async def test_asset_api_enforces_visibility_and_supports_admin_updates(
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(
@@ -117,6 +157,15 @@ async def test_asset_api_validates_uploads_and_missing_assets(
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,
@@ -128,7 +177,9 @@ async def test_asset_api_validates_uploads_and_missing_assets(
stored = await create_user_asset(
"missing-user-check",
make_upload_file(b"content", filename="content.txt", content_type="text/plain"),
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)
+127 -19
View File
@@ -185,17 +185,10 @@ async def test_callback_api_handles_revolut_paid_events(mocker):
async def test_callback_api_handles_revolut_subscription_event(
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")
)
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"
@@ -209,7 +202,7 @@ async def test_callback_api_handles_revolut_subscription_event(
"current_cycle_id": "CYCLE_1",
"external_reference": json.dumps(
{
"wallet_id": wallet.id,
"wallet_id": wallet_id,
"tag": "members",
"subscription_request_id": "request_1",
"extra": {"link": "link-1", "customer_id": "customer_1"},
@@ -241,10 +234,14 @@ async def test_callback_api_handles_revolut_subscription_event(
"lnbits.core.views.callback_api.get_standalone_payment",
mocker.AsyncMock(side_effect=[None]),
)
create_fiat_invoice_mock = mocker.patch(
"lnbits.core.views.callback_api.create_fiat_invoice",
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()
)
@@ -256,16 +253,127 @@ async def test_callback_api_handles_revolut_subscription_event(
}
)
assert create_fiat_invoice_mock.await_count == 1
revolut_call = create_fiat_invoice_mock.await_args.kwargs
assert revolut_call["wallet_id"] == wallet.id
invoice = revolut_call["invoice_data"]
assert invoice.fiat_provider == "revolut"
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)
+33
View File
@@ -162,6 +162,39 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
assert release_info["is_version_compatible"] is True
@pytest.mark.anyio
async def test_wasm_extension_enable_requires_saved_permissions(mocker):
user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
ext_id = f"wasm_{uuid4().hex[:8]}"
await create_installed_extension(make_installable_extension(ext_id))
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.get_extension_type",
return_value="wasm",
)
mocker.patch(
"lnbits.core.views.extension_api._load_wasm_extension_config",
return_value={
"extension_type": "wasm",
"permissions": [{"id": "ext.db.read_write"}],
"payment_tags": ["paidtasks"],
},
)
with pytest.raises(HTTPException) as exc:
await api_enable_extension(ext_id, AccountId(id=user.id))
assert exc.value.status_code == 400
@pytest.mark.anyio
async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user):
regular_user = await create_user_account(
+159 -1
View File
@@ -6,7 +6,7 @@ import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from lnbits.core.crud.payments import create_payment, get_payments
from lnbits.core.crud.payments import create_payment, get_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
@@ -218,6 +218,164 @@ async def test_payment_api_fee_reserve_and_hold_invoice_actions(mocker):
cancel_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_payment_extra_update_appends_new_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="splitpayments",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={
"payment_hash": payment_hash,
"extra": {"child": "daughter", "compliance_note": "reviewed"},
},
)
assert response.status_code == 200
extra = response.json()["extra"]
assert extra["tag"] == "splitpayments"
assert extra["child"] == "daughter"
assert extra["compliance_note"] == "reviewed"
payment = await get_payment(checking_id)
assert payment.extra == extra
@pytest.mark.anyio
async def test_payment_extra_update_creates_extra_when_missing(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "reviewed"}},
)
assert response.status_code == 200
assert response.json()["extra"] == {"note": "reviewed"}
payment = await get_payment(checking_id)
assert payment.extra == {"note": "reviewed"}
@pytest.mark.anyio
async def test_payment_extra_update_rejects_existing_keys(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
checking_id = await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
tag="original",
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"tag": "overwritten"}},
)
assert response.status_code == 400
assert response.json()["detail"] == "Extra keys already exist: tag."
payment = await get_payment(checking_id)
assert payment.extra == {"tag": "original"}
@pytest.mark.anyio
async def test_payment_extra_update_requires_admin_key(
client,
to_wallet,
inkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=inkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "invoice key"}},
)
assert response.status_code == 403
assert response.json()["detail"] == "Invalid adminkey."
@pytest.mark.anyio
async def test_payment_extra_update_is_wallet_scoped(
client,
from_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
from_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "wrong wallet"}},
)
assert response.status_code == 404
assert response.json()["detail"] == "Payment does not exist."
@pytest.mark.anyio
async def test_payment_extra_update_requires_successful_payment(
client,
to_wallet,
adminkey_headers_to,
):
payment_hash = uuid4().hex
await _create_payment(
to_wallet.id,
amount_msat=1_000,
payment_hash=payment_hash,
status=PaymentState.PENDING,
)
response = await client.patch(
"/api/v1/payments/extra",
headers=adminkey_headers_to,
json={"payment_hash": payment_hash, "extra": {"note": "too early"}},
)
assert response.status_code == 400
assert (
response.json()["detail"] == "Payment extra can only be updated after success."
)
async def _create_payment(
wallet_id: str,
*,
-1
View File
@@ -1 +0,0 @@
98ae578877a3479bb0424d2e418b427a
-1
View File
@@ -1 +0,0 @@
6355d3c6c5df49dbb562a74f9deb19ce
+360 -13
View File
@@ -74,6 +74,7 @@ class MockHTTPClient:
@pytest.fixture(autouse=True)
def fiat_provider_test_settings(settings: Settings):
original_lnbits_running = settings.lnbits_running
original_allowed_currencies = settings.lnbits_allowed_currencies
original_paypal_enabled = settings.paypal_enabled
original_square_enabled = settings.square_enabled
@@ -98,6 +99,7 @@ def fiat_provider_test_settings(settings: Settings):
settings.square_enabled = False
settings.revolut_enabled = False
yield
settings.lnbits_running = original_lnbits_running
settings.lnbits_allowed_currencies = original_allowed_currencies
settings.paypal_enabled = original_paypal_enabled
settings.square_enabled = original_square_enabled
@@ -445,6 +447,7 @@ async def test_square_wallet_create_invoice(settings: Settings):
@pytest.mark.anyio
async def test_square_wallet_create_subscription(settings: Settings):
settings.lnbits_running = False
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
@@ -520,6 +523,7 @@ async def test_square_wallet_create_subscription(settings: Settings):
@pytest.mark.anyio
async def test_square_wallet_create_subscription_from_plan_id(settings: Settings):
settings.lnbits_running = False
settings.square_api_endpoint = "https://connect.squareupsandbox.com"
settings.square_access_token = "square-token"
settings.square_location_id = "LOC123"
@@ -808,6 +812,48 @@ async def test_revolut_wallet_create_invoice(settings: Settings):
assert payload["redirect_url"] == "https://lnbits.example/success"
@pytest.mark.anyio
async def test_revolut_wallet_create_invoice_uses_currency_minor_units(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"id": "ORDER_JPY",
"checkout_url": "https://checkout.revolut.com/payment-link/jpy",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER_KWD",
"checkout_url": "https://checkout.revolut.com/payment-link/kwd",
}
),
]
)
wallet.client = client # type: ignore[assignment]
await wallet.create_invoice(
amount=123,
payment_hash="hash_jpy",
currency="JPY",
)
await wallet.create_invoice(
amount=1.234,
payment_hash="hash_kwd",
currency="KWD",
)
assert client.calls[0][1]["json"]["amount"] == 123
assert client.calls[1][1]["json"]["amount"] == 1234
@pytest.mark.anyio
async def test_revolut_wallet_get_invoice_status(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
@@ -834,6 +880,16 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"customers": [
{
"id": "CUSTOMER123",
"email": "customer@example.com",
}
]
}
),
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
@@ -854,7 +910,8 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
wallet_id="wallet_1",
memo="Monthly Gold",
tag="gold",
extra={"customer_id": "CUSTOMER123", "link": "link-1"},
customer_email="customer@example.com",
extra={"link": "link-1"},
success_url="https://lnbits.example/subscription-success",
)
@@ -863,24 +920,312 @@ async def test_revolut_wallet_create_subscription(settings: Settings):
)
assert response.ok is True
assert response.subscription_request_id == "SUBSCRIPTION123"
assert response.subscription_request_id is not None
assert (
response.checkout_session_url
== "https://checkout.revolut.com/payment-link/sub_123"
)
assert client.calls[0][0] == "/api/subscriptions"
payload = client.calls[0][1]["json"]
assert client.calls[0][0] == "/api/customers"
assert client.calls[0][1]["params"] == {"limit": 500}
assert client.calls[0][1]["timeout"] == 30
assert client.calls[1][0] == "/api/subscriptions"
payload = client.calls[1][1]["json"]
assert payload["plan_variation_id"] == "PLAN_VARIATION_123"
assert payload["customer_id"] == "CUSTOMER123"
assert client.calls[1][1]["timeout"] == 30
assert client.calls[1][1]["headers"]["Idempotency-Key"] == (
response.subscription_request_id
)
assert payload["setup_order_redirect_url"] == (
"https://lnbits.example/subscription-success"
)
reference = json.loads(payload["external_reference"])
assert reference["wallet_id"] == "wallet_1"
assert reference["tag"] == "gold"
assert reference["subscription_request_id"] == response.subscription_request_id
assert reference["memo"] == "Monthly Gold"
assert reference["extra"]["customer_id"] == "CUSTOMER123"
assert client.calls[1][0] == "/api/orders/ORDER123"
assert reference["extra"]["link"] == "link-1"
assert client.calls[2][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription_uses_customer_email(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"customers": [
{
"id": "CUSTOMER123",
"email": "customer@example.com",
}
]
}
),
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
"setup_order_id": "ORDER123",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_123",
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
customer_email="customer@example.com",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert client.calls[0][0] == "/api/customers"
assert client.calls[0][1]["params"] == {"limit": 500}
assert client.calls[0][1]["timeout"] == 30
assert client.calls[1][0] == "/api/subscriptions"
assert client.calls[1][1]["json"]["customer_id"] == "CUSTOMER123"
assert client.calls[1][1]["timeout"] == 30
assert client.calls[2][0] == "/api/orders/ORDER123"
assert client.calls[2][1]["timeout"] == 30
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription_uses_paginated_customer_email(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"next_page_token": "PAGE2",
"customers": [
{
"id": "OTHER_CUSTOMER",
"email": "other@example.com",
}
],
}
),
MockHTTPResponse(
json_data={
"customers": [
{
"id": "CUSTOMER123",
"email": "customer@example.com",
}
],
}
),
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
"setup_order_id": "ORDER123",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_123",
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
customer_email="customer@example.com",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert client.calls[0][0] == "/api/customers"
assert client.calls[0][1]["params"] == {"limit": 500}
assert client.calls[1][0] == "/api/customers"
assert client.calls[1][1]["params"] == {
"limit": 500,
"page_token": "PAGE2",
}
assert client.calls[1][1]["timeout"] == 30
assert client.calls[2][0] == "/api/subscriptions"
assert client.calls[2][1]["json"]["customer_id"] == "CUSTOMER123"
assert client.calls[3][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription_creates_customer(settings: Settings):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient(
[
MockHTTPResponse(
json_data={
"next_page_token": "PAGE2",
"customers": [
{
"id": "OTHER_CUSTOMER",
"email": "other@example.com",
}
],
}
),
MockHTTPResponse(json_data={"customers": []}),
MockHTTPResponse(json_data={"id": "CUSTOMER123"}),
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
"setup_order_id": "ORDER123",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_123",
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
customer_email="customer@example.com",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert client.calls[0][0] == "/api/customers"
assert client.calls[0][1]["params"] == {"limit": 500}
assert client.calls[1][0] == "/api/customers"
assert client.calls[1][1]["params"] == {
"limit": 500,
"page_token": "PAGE2",
}
assert client.calls[2][0] == "/api/customers"
assert client.calls[2][1]["json"] == {"email": "customer@example.com"}
assert client.calls[2][1]["timeout"] == 30
assert client.calls[3][0] == "/api/subscriptions"
assert client.calls[3][1]["json"]["customer_id"] == "CUSTOMER123"
assert client.calls[4][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription_stops_customer_lookup_after_20_pages(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
customer_pages = [
MockHTTPResponse(
json_data={
"next_page_token": f"PAGE{page + 2}",
"customers": [
{
"id": f"OTHER_CUSTOMER_{page}",
"email": f"other-{page}@example.com",
}
],
}
)
for page in range(20)
]
client = MockHTTPClient(
[
*customer_pages,
MockHTTPResponse(json_data={"id": "CUSTOMER123"}),
MockHTTPResponse(
json_data={
"id": "SUBSCRIPTION123",
"setup_order_id": "ORDER123",
}
),
MockHTTPResponse(
json_data={
"id": "ORDER123",
"checkout_url": "https://checkout.revolut.com/payment-link/sub_123",
}
),
]
)
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(
wallet_id="wallet_1",
customer_email="customer@example.com",
)
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is True
assert [call[0] for call in client.calls[:20]] == ["/api/customers"] * 20
assert client.calls[0][1]["params"] == {"limit": 500}
assert client.calls[19][1]["params"] == {
"limit": 500,
"page_token": "PAGE20",
}
assert client.calls[20][0] == "/api/customers"
assert client.calls[20][1]["json"] == {"email": "customer@example.com"}
assert client.calls[21][0] == "/api/subscriptions"
assert client.calls[21][1]["json"]["customer_id"] == "CUSTOMER123"
assert client.calls[22][0] == "/api/orders/ORDER123"
@pytest.mark.anyio
async def test_revolut_wallet_create_subscription_requires_customer_email(
settings: Settings,
):
settings.revolut_api_endpoint = "https://sandbox-merchant.revolut.com"
settings.revolut_api_secret_key = "revolut-secret"
settings.revolut_api_version = "2026-04-20"
wallet = RevolutWallet()
client = MockHTTPClient([])
wallet.client = client # type: ignore[assignment]
payment_options = FiatSubscriptionPaymentOptions(wallet_id="wallet_1")
response = await wallet.create_subscription(
"PLAN_VARIATION_123", 1, payment_options
)
assert response.ok is False
assert response.error_message == "Revolut subscriptions require customer_email."
assert client.calls == []
@pytest.mark.anyio
@@ -936,7 +1281,7 @@ async def test_revolut_wallet_create_webhook(mocker: MockerFixture):
(
"/api/webhooks",
{
"timeout": 15,
"timeout": 30,
},
),
(
@@ -946,7 +1291,7 @@ async def test_revolut_wallet_create_webhook(mocker: MockerFixture):
"url": "https://lnbits.example/api/v1/callback/revolut",
"events": REVOLUT_WEBHOOK_EVENTS,
},
"timeout": 15,
"timeout": 30,
},
),
]
@@ -986,7 +1331,7 @@ async def test_revolut_wallet_reuses_existing_webhook(mocker: MockerFixture):
(
"/api/webhooks",
{
"timeout": 15,
"timeout": 30,
},
)
]
@@ -1005,20 +1350,22 @@ async def test_revolut_wallet_rejects_local_webhook_url():
def test_check_revolut_signature():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time()))
timestamp = str(int(time.time() * 1000))
secret = "revolut-secret"
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
signed_payload = b"v1." + timestamp.encode() + b"." + payload
sig = "v1=" + hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
check_revolut_signature(payload, sig, timestamp, secret)
def test_check_revolut_signature_millisecond_timestamp():
def test_check_revolut_signature_rejects_payload_only_signature():
payload = b'{"event":"ORDER_COMPLETED","order_id":"ORDER123"}'
timestamp = str(int(time.time() * 1000))
secret = "revolut-secret"
sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
check_revolut_signature(payload, sig, timestamp, secret)
with pytest.raises(ValueError, match="signature verification failed"):
check_revolut_signature(payload, sig, timestamp, secret)
def test_check_revolut_signature_v1_header():
+91 -14
View File
@@ -15,7 +15,7 @@ from tests.helpers import make_upload_file
@pytest.mark.anyio
async def test_create_user_asset_validates_upload_constraints(
settings: Settings, mocker: MockerFixture
app, settings: Settings, mocker: MockerFixture
):
file_without_type = make_upload_file(b"hello", filename="a.txt", content_type=None)
with pytest.raises(ValueError, match="File must have a content type."):
@@ -31,6 +31,40 @@ async def test_create_user_asset_validates_upload_constraints(
):
await create_user_asset("user-1", bad_type, is_public=False)
xsl_upload = make_upload_file(
b"hello",
filename="style.xsl",
content_type="text/xml",
)
with pytest.raises(ValueError, match="File type 'text/xml' not allowed."):
await create_user_asset("user-1", xsl_upload, is_public=False)
original_allowed_mime_types = list(settings.lnbits_assets_allowed_mime_types)
try:
settings.lnbits_assets_allowed_mime_types = [
*original_allowed_mime_types,
"text/xml",
]
xsl_content = make_upload_file(
b'<stylesheet xmlns="http://www.w3.org/1999/XSL/Transform"></stylesheet>',
filename="style.xml",
content_type="text/xml",
)
with pytest.raises(ValueError, match="File type 'text/xml' not allowed."):
await create_user_asset("user-1", xsl_content, is_public=False)
finally:
settings.lnbits_assets_allowed_mime_types = original_allowed_mime_types
fake_image = make_upload_file(
b"<?xml version='1.0'?><root></root>",
filename="fake.png",
content_type="image/png",
)
with pytest.raises(
ValueError, match="Image file content does not match declared file type."
):
await create_user_asset("user-1", fake_image, is_public=False)
original_max_assets = settings.lnbits_max_assets_per_user
original_max_size = settings.lnbits_max_asset_size_mb
original_no_limit_users = list(settings.lnbits_assets_no_limit_users)
@@ -40,14 +74,14 @@ async def test_create_user_asset_validates_upload_constraints(
settings.lnbits_assets_no_limit_users = []
limited_user = await _create_user()
allowed_type = make_upload_file(
b"hello", filename="ok.txt", content_type="text/plain"
_png_bytes(), filename="ok.png", content_type="image/png"
)
await create_user_asset(limited_user, allowed_type, is_public=False)
blocked_by_count = make_upload_file(
b"again",
filename="again.txt",
content_type="text/plain",
_png_bytes(),
filename="again.png",
content_type="image/png",
)
with pytest.raises(ValueError, match="Max upload count of 1 exceeded."):
await create_user_asset(limited_user, blocked_by_count, is_public=False)
@@ -55,9 +89,9 @@ async def test_create_user_asset_validates_upload_constraints(
settings.lnbits_max_asset_size_mb = 0.000001
oversized_user = await _create_user()
large_file = make_upload_file(
b"0123456789",
filename="ok.txt",
content_type="text/plain",
_png_bytes(),
filename="ok.png",
content_type="image/png",
)
with pytest.raises(ValueError, match="File limit of 1e-06MB exceeded."):
await create_user_asset(oversized_user, large_file, is_public=False)
@@ -68,29 +102,66 @@ async def test_create_user_asset_validates_upload_constraints(
@pytest.mark.anyio
async def test_create_user_asset_success(mocker: MockerFixture):
async def test_create_user_asset_success(app, mocker: MockerFixture):
user_id = await _create_user()
mocker.patch(
"lnbits.core.services.assets.thumbnail_from_bytes",
return_value=None,
)
file = make_upload_file(b"hello", filename="hello.txt", content_type="text/plain")
contents = _png_bytes()
file = make_upload_file(contents, filename="hello.png", content_type="image/png")
asset = await create_user_asset(user_id, file, is_public=True)
stored = await get_user_asset(user_id, asset.id)
assert asset.id
assert asset.user_id == user_id
assert asset.name == "hello.txt"
assert asset.size_bytes == 5
assert asset.data == b"hello"
assert asset.name == "hello.png"
assert asset.size_bytes == len(contents)
assert asset.data == contents
assert asset.is_public is True
assert stored is not None
assert stored.id == asset.id
assert stored.data == b"hello"
assert stored.data == contents
assert await get_user_assets_count(user_id) == 1
@pytest.mark.anyio
async def test_create_user_asset_stores_detected_image_mime_type(app):
user_id = await _create_user()
buffer = BytesIO()
Image.new("RGB", (32, 32), color="blue").save(buffer, format="JPEG")
file = make_upload_file(
buffer.getvalue(), filename="photo.jpg", content_type="image/jpg"
)
asset = await create_user_asset(user_id, file, is_public=True)
stored = await get_user_asset(user_id, asset.id)
assert asset.mime_type == "image/jpeg"
assert stored is not None
assert stored.mime_type == "image/jpeg"
@pytest.mark.anyio
async def test_create_user_asset_rejects_mismatched_image_content(app):
user_id = await _create_user()
buffer = BytesIO()
Image.new("RGB", (32, 32), color="blue").save(buffer, format="JPEG")
file = make_upload_file(
buffer.getvalue(), filename="photo.png", content_type="image/png"
)
with pytest.raises(
ValueError,
match=(
"Image file content does not match declared file type. "
"Declared: 'image/png', detected: 'image/jpeg'."
),
):
await create_user_asset(user_id, file, is_public=False)
def test_thumbnail_from_bytes_success_and_failure():
image = Image.new("RGB", (512, 512), color="red")
buffer = BytesIO()
@@ -107,3 +178,9 @@ async def _create_user() -> str:
user_id = uuid4().hex
await create_account(Account(id=user_id, username=f"user_{user_id[:8]}"))
return user_id
def _png_bytes() -> bytes:
buffer = BytesIO()
Image.new("RGB", (32, 32), color="green").save(buffer, format="PNG")
return buffer.getvalue()
+2
View File
@@ -37,12 +37,14 @@ def test_dict_to_settings_parses_known_values():
{
"lnbits_site_title": "Test Title",
"lnbits_service_fee": 5,
"lnbits_default_burger_menu_background": False,
"ignored_field": "ignored",
}
)
assert parsed.lnbits_site_title == "Test Title"
assert parsed.lnbits_service_fee == 5
assert parsed.lnbits_default_burger_menu_background is False
assert not hasattr(parsed, "ignored_field")
+8
View File
@@ -232,6 +232,14 @@ def test_installed_extensions_settings_activate_and_deactivate_paths():
assert installed.find_extension_redirect("/.well-known/lnurlp", []) is None
def test_public_settings_include_burger_menu_background(settings: Settings):
settings.lnbits_default_burger_menu_background = False
public_settings = PublicSettings.from_settings(settings)
assert public_settings.default_burger_menu_background is False
def test_installed_extensions_settings_detects_conflicting_redirects():
installed = InstalledExtensionsSettings(
lnbits_extensions_redirects=[
Generated
+517 -465
View File
File diff suppressed because it is too large Load Diff