Compare commits

...
Author SHA1 Message Date
Arc 2e71830817 clean 2026-02-25 22:24:22 +00:00
Arc 0fb2334181 fundle 2026-02-25 22:08:00 +00:00
Arc 94c7796dd5 Merge remote-tracking branch 'origin/dev' into wasm_extensions 2026-02-25 21:30:24 +00:00
Arc af4ce8da52 pull almost all out to a parent extension 2026-02-25 21:05:28 +00:00
Arc 4e8a5e050b make 2026-02-25 16:20:26 +00:00
Arc 3fd7aae10d tasks and docs 2026-02-25 16:11:14 +00:00
Arc 64ad19a65b tags 2026-02-25 13:39:26 +00:00
Arc ee6ff7179e bug fixes 2026-02-22 01:19:19 +00:00
Arc 684dfcc9fb for uninstalling wasm 2026-02-21 20:47:18 +00:00
Arc a498d4a877 show keys 2026-02-21 14:48:41 +00:00
Arc ef90c983f2 make 2026-02-21 14:10:05 +00:00
Arc da8a82c78e black 2026-02-21 13:48:27 +00:00
Arc 51f98e8130 added websocket listener back 2026-02-21 12:39:27 +00:00
Arc 7b4436b288 not needed 2026-02-21 11:55:31 +00:00
Arc ec88cf483e path proxy 2026-02-21 11:26:38 +00:00
Arc ca215063be uv lock 2026-02-21 09:51:10 +00:00
Arc 2674cfb5f1 whoops, forgot to add 2026-02-21 09:26:20 +00:00
Arc 26f0b69085 init 2026-02-21 09:18:13 +00:00
dni ⚡andGitHub 2069e3de8b chore: update to v1.5.0-rc2 (#3806) 2026-02-20 10:04:59 +01:00
Vlad StanandGitHub 800be6042f fix: paypal subscriptions (#3797) 2026-02-20 10:56:08 +02:00
dni ⚡andGitHub c65d373f23 feat: restructure docker images, add sparkl2 docker image (#3794) 2026-02-18 13:52:19 +01:00
dni ⚡andGitHub 087e095bcf feat: hide first_install_token if is not required (#3795) 2026-02-18 13:52:02 +01:00
dni ⚡andGitHub 027a161494 chore: update lnbits to version v1.5.0-rc1 (#3793) 2026-02-17 12:26:50 +01:00
83ef05696e feat: Spark L2 (#3715)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 12:25:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
42962308e6 chore(deps): bump pillow from 12.1.0 to 12.1.1 (#3785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 11:33:28 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Vlad Stan
783ee256a1 chore(deps): bump axios from 1.13.2 to 1.13.5 (#3786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 11:32:58 +01:00
4e805fd00e feat: modern theme stuff like rounded corners and card gradient (#3766)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-16 08:06:36 +01:00
dni ⚡andGitHub f54824cc44 fix: /first_install new superuser may duplicate an username (#3787) 2026-02-13 14:44:52 +00:00
ArcandGitHub 768d2cbe2a fix: fail closed on PayPal webhook verification errors (#3769) 2026-02-10 08:50:50 +01:00
krviandGitHub efd36d0221 fix: use canonical values in rate limit unit select (#3753) 2026-02-09 10:43:03 +02:00
Vlad StanandGitHub 4684939e13 fix: validation of invitation code (#3767) 2026-02-05 18:53:01 +02:00
Vlad StanandGitHub 193ee20da4 [feat] User invitation code (#3761) 2026-02-05 12:35:24 +02:00
Vlad StanandGitHub 2fe7ab4f83 [feat] Allow no exchange providers (#3763) 2026-02-05 11:11:48 +01:00
dni ⚡andGitHub 10c8ca4ca1 fix: datetime in db.py added utc offset (#3762) 2026-02-05 11:05:58 +01:00
Vlad StanandGitHub f079762b71 fix: handle validation errors when the response is binary (#3765) 2026-02-05 11:55:40 +02:00
Vlad StanandGitHub 2e042d2597 fix: extension paid/free label (#3764) 2026-02-05 11:32:46 +02:00
Vlad StanandGitHub 49b57c9f0b [feat] User activation (#3749) 2026-02-05 11:31:47 +02:00
dni ⚡andGitHub 656c6cac5b hotfix: websocket with old balance was sent. (#3759) 2026-02-03 12:23:49 +01:00
45e773b66f feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-30 11:21:52 +01:00
f692ac6f12 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-30 11:18:17 +01:00
dni ⚡andGitHub 78202a027b fix: uiCustomisation JS Error (#3752) 2026-01-30 09:27:54 +01:00
dni ⚡andGitHub 28fa0906a6 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-01-30 09:19:01 +01:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
19 changed files with 919 additions and 22 deletions
+1
View File
@@ -70,6 +70,7 @@ FORWARDED_ALLOW_IPS="*"
# Inside this directory the `extensions` and `upgrades` sub-directories will be created. # Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir" # LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist. # ID of the super user. The user ID must exist.
# SUPER_USER="" # SUPER_USER=""
+28 -2
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import glob import glob
import importlib import importlib
import json
import os import os
import shutil import shutil
import sys import sys
@@ -35,9 +36,12 @@ from lnbits.core.tasks import (
purge_audit_data, purge_audit_data,
run_by_the_minute_tasks, run_by_the_minute_tasks,
wait_for_audit_data, wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages, wait_notification_messages,
) )
from lnbits.core.tasks import (
wait_for_paid_invoices as wait_for_paid_invoices_core,
)
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.exceptions import register_exception_handlers from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse from lnbits.helpers import version_parse
from lnbits.settings import settings from lnbits.settings import settings
@@ -419,6 +423,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_ext_tasks(ext: Extension) -> None: def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks.""" """Register extension async tasks."""
if ext.extension_type == "wasm":
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"): if hasattr(ext_module, f"{ext.code}_start"):
@@ -428,6 +434,12 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension.""" """Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
register_wasm_ext_routes(app, ext)
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext") ext_route = getattr(ext_module, f"{ext.code}_ext")
@@ -453,6 +465,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
@@ -476,7 +502,7 @@ def register_async_tasks() -> None:
# core invoice listener # core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue() invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core") register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
create_permanent_task(run_by_the_minute_tasks) create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data) create_permanent_task(purge_audit_data)
+4 -1
View File
@@ -1,3 +1,5 @@
import re
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
InstallableExtension, InstallableExtension,
@@ -49,7 +51,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
) )
# Check that 'ext_id' is a valid extension id and not a malicious string # Check that 'ext_id' is a valid extension id and not a malicious string
if not row: if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found") if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", ext_id):
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id) is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db: if is_file_based_db:
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database( async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None ext: InstallableExtension, current_version: DbVersion | None = None
): ):
if _is_wasm_extension(ext):
return
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -58,6 +60,34 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception as exc:
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
return False
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
+58 -1
View File
@@ -83,6 +83,11 @@ class ExtensionConfig(BaseModel):
warning: str | None = "" warning: str | None = ""
min_lnbits_version: str | None min_lnbits_version: str | None
max_lnbits_version: str | None max_lnbits_version: str | None
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
def is_version_compatible(self) -> bool: def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -115,6 +120,20 @@ class PayToEnableInfo(BaseModel):
class UserExtensionInfo(BaseModel): class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None payment_hash_to_enable: str | None = None
granted_permissions: list[str] | None = None
granted_payment_tags: list[str] | None = None
class ExtensionPermission(BaseModel):
id: str
label: str
description: str
dangerous: bool | None = False
class ExtensionPermissionsGrant(BaseModel):
permissions: list[str] = []
payment_tags: list[str] = []
class UserExtension(BaseModel): class UserExtension(BaseModel):
@@ -147,6 +166,10 @@ class Extension(BaseModel):
short_description: str | None = None short_description: str | None = None
tile: str | None = None tile: str | None = None
upgrade_hash: str | None = "" upgrade_hash: str | None = ""
extension_type: str | None = None
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
@property @property
def module_name(self) -> str: def module_name(self) -> str:
@@ -170,6 +193,12 @@ class Extension(BaseModel):
short_description=ext_info.short_description, short_description=ext_info.short_description,
tile=ext_info.icon, tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "", upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
extension_type=ext_info.meta.extension_type if ext_info.meta else None,
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
public_wasm_functions=(
ext_info.meta.public_wasm_functions if ext_info.meta else []
),
payment_tags=ext_info.meta.payment_tags if ext_info.meta else [],
) )
@@ -331,6 +360,11 @@ class ExtensionMeta(BaseModel):
pay_to_enable: PayToEnableInfo | None = None pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = [] payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = [] dependencies: list[str] = []
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
paid_features: str | None = None paid_features: str | None = None
@@ -454,6 +488,14 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name") self.name = config_json.get("name")
self.short_description = config_json.get("short_description") self.short_description = config_json.get("short_description")
if self.meta:
self.meta.permissions = config_json.get("permissions", [])
self.meta.extension_type = config_json.get("extension_type", "python")
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
self.meta.public_wasm_functions = config_json.get(
"public_wasm_functions", []
)
self.meta.payment_tags = config_json.get("payment_tags", [])
if ( if (
self.meta self.meta
@@ -572,6 +614,11 @@ class InstallableExtension(BaseModel):
latest_release=ExtensionRelease.from_github_release( latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release source_repo, latest_release
), ),
permissions=config.permissions,
extension_type=config.extension_type,
public_kv_keys=config.public_kv_keys,
public_wasm_functions=config.public_wasm_functions,
payment_tags=config.payment_tags,
), ),
) )
except Exception as e: except Exception as e:
@@ -617,7 +664,14 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}", source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"), min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"), max_lnbits_version=config_json.get("max_lnbits_version"),
) ),
permissions=config_json.get("permissions", []),
extension_type=config_json.get("extension_type", "python"),
public_kv_keys=config_json.get("public_kv_keys", []),
public_wasm_functions=config_json.get(
"public_wasm_functions", []
),
payment_tags=config_json.get("payment_tags", []),
), ),
) )
@@ -841,3 +895,6 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
_, _, *rest = path.split("/") _, _, *rest = path.split("/")
tail = "/".join(rest) tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}" return f"https://github.com/{source_repo}/raw/main/{tail}"
ExtensionConfig.update_forward_refs(ExtensionPermission=ExtensionPermission)
+35 -1
View File
@@ -10,13 +10,18 @@ from lnbits.core.crud import (
get_db_version, get_db_version,
get_installed_extension, get_installed_extension,
update_installed_extension_state, update_installed_extension_state,
update_migration_version,
) )
from lnbits.core.crud.extensions import ( from lnbits.core.crud.extensions import (
get_installed_extensions, get_installed_extensions,
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection from lnbits.core.wasm.extension_host import (
clear_schedules_for_extension,
clear_tag_watches_for_extension,
)
from lnbits.db import COCKROACH, POSTGRES, Connection, Database
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -53,6 +58,9 @@ async def install_extension(
else: else:
await update_installed_extension(ext_info) await update_installed_extension(ext_info)
if ext_info.meta and ext_info.meta.extension_type == "wasm":
await update_migration_version(None, ext_info.id, 0)
extension = Extension.from_installable_ext(ext_info) extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension: if extension.is_upgrade_extension:
# call stop while the old routes are still active # call stop while the old routes are still active
@@ -70,10 +78,26 @@ async def uninstall_extension(ext_id: str):
extension = await get_installed_extension(ext_id) extension = await get_installed_extension(ext_id)
if extension: if extension:
if extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await _purge_wasm_extension_db(ext_id)
extension.clean_extension_files() extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=ext_id)
async def _purge_wasm_extension_db(ext_id: str) -> None:
cleaned = await Database.clean_ext_db_files(ext_id)
if cleaned:
return
try:
db = Database(f"ext_{ext_id}")
if db.type in {POSTGRES, COCKROACH}:
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE")
except Exception as exc:
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
async def activate_extension(ext: Extension): async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext) core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True) await update_installed_extension_state(ext_id=ext.code, active=True)
@@ -83,6 +107,10 @@ async def activate_extension(ext: Extension):
async def deactivate_extension(ext_id: str): async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id) settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False) await update_installed_extension_state(ext_id=ext_id, active=False)
extension = await get_installed_extension(ext_id)
if extension and extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await stop_extension_background_work(ext_id) await stop_extension_background_work(ext_id)
@@ -91,6 +119,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks. Extension must expose a `myextension_stop()` function if it is starting tasks.
""" """
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id) upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
@@ -123,6 +154,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks. Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case. Extension MUST expose a `myextension_stop()` in that case.
""" """
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id) upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
+5
View File
@@ -313,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
payment.payment_hash, payment.payment_hash,
json.dumps({"pending": payment.pending, "status": payment.status}), json.dumps({"pending": payment.pending, "status": payment.status}),
) )
if payment.tag and not payment.is_out:
await websocket_manager.send(
f"tag:{wallet.id}:{payment.tag}",
payment.json(),
)
async def send_chat_payment_notification(wallet: Wallet, payment: Payment): async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
+420 -13
View File
@@ -1,12 +1,15 @@
import json
import sys import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
import httpx import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.requests import Request from fastapi.requests import Request
from loguru import logger from loguru import logger
from starlette.routing import Match
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids from lnbits.core.crud.wallets import get_wallets_ids
@@ -20,6 +23,8 @@ from lnbits.core.models.extensions import (
Extension, Extension,
ExtensionConfig, ExtensionConfig,
ExtensionMeta, ExtensionMeta,
ExtensionPermission,
ExtensionPermissionsGrant,
ExtensionRelease, ExtensionRelease,
ExtensionReview, ExtensionReview,
ExtensionReviewPaymentRequest, ExtensionReviewPaymentRequest,
@@ -40,6 +45,11 @@ from lnbits.core.services.extensions import (
install_extension, install_extension,
uninstall_extension, uninstall_extension,
) )
from lnbits.core.wasm import WASM_HOST_MANIFEST
from lnbits.core.wasm.extension_host import (
clear_schedules_for_user,
clear_tag_watches_for_user,
)
from lnbits.db import Page from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists, check_account_exists,
@@ -174,38 +184,146 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus: ) -> SimpleStatus:
await _ensure_extension_exists(ext_id)
logger.info(f"Enabling extension: {ext_id}.")
ext = await _get_installed_active_extension(ext_id)
user_ext = await _get_or_create_user_extension(account_id.id, ext_id)
required_permissions = _get_required_permissions(ext_id, ext)
granted_permissions = _get_granted_permissions(grant, user_ext)
granted_tags = _get_granted_payment_tags(grant, user_ext)
required_tags = _get_required_payment_tags(ext_id, ext)
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(required_tags, granted_tags)
_ensure_permissions(required_permissions, granted_permissions)
if grant and grant.permissions:
await _store_granted_permissions(user_ext, granted_permissions)
if grant and grant.payment_tags:
await _store_granted_payment_tags(user_ext, granted_tags)
if account_id.is_admin_id or not ext.requires_payment:
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
return await _enable_paid_extension(ext_id, ext, user_ext)
async def _ensure_extension_exists(ext_id: str) -> None:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist." HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
) )
logger.info(f"Enabling extension: {ext_id}.")
async def _get_installed_active_extension(ext_id: str) -> InstallableExtension:
ext = await get_installed_extension(ext_id) ext = await get_installed_extension(ext_id)
if not ext: if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.") raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
return ext
user_ext = await get_user_extension(account_id.id, ext_id)
async def _get_or_create_user_extension(user_id: str, ext_id: str) -> UserExtension:
user_ext = await get_user_extension(user_id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user_id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
return user_ext
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
def _get_required_permissions(ext_id: str, ext: InstallableExtension) -> list[str]:
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
return [p.id for p in permissions_source] if permissions_source else []
def _get_granted_permissions(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.permissions:
return grant.permissions
if user_ext.extra and user_ext.extra.granted_permissions:
return user_ext.extra.granted_permissions
return []
def _get_required_payment_tags(ext_id: str, ext: InstallableExtension) -> list[str]:
tags_source = (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext_id)
)
return tags_source if tags_source else []
def _get_granted_payment_tags(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.payment_tags:
return grant.payment_tags
if user_ext.extra and user_ext.extra.granted_payment_tags:
return user_ext.extra.granted_payment_tags
return []
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
if not required:
return
missing = [p for p in required if p not in granted]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.",
)
async def _store_granted_permissions(
user_ext: UserExtension, granted_permissions: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _store_granted_payment_tags(
user_ext: UserExtension, granted_tags: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _activate_user_extension(user_ext: UserExtension) -> None:
user_ext.active = True
await update_user_extension(user_ext)
async def _enable_paid_extension(
ext_id: str, ext: InstallableExtension, user_ext: UserExtension
) -> SimpleStatus:
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable): if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException( raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment." HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
) )
if user_ext.is_paid: if user_ext.is_paid:
user_ext.active = True await _activate_user_extension(user_ext)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet: if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
@@ -222,9 +340,8 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.", f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
) )
user_ext.active = True
user_ext.extra.paid_to_enable = True user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext) await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@@ -243,10 +360,117 @@ async def api_disable_extension(
) )
logger.info(f"Disabling extension: {ext_id}.") logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False user_ext.active = False
if user_ext.extra and user_ext.extra.granted_permissions:
user_ext.extra.granted_permissions = []
if user_ext.extra and user_ext.extra.granted_payment_tags:
user_ext.extra.granted_payment_tags = []
await update_user_extension(user_ext) await update_user_extension(user_ext)
ext = await get_installed_extension(ext_id)
if ext and ext.meta and ext.meta.extension_type == "wasm":
await clear_tag_watches_for_user(ext_id, account_id.id)
await clear_schedules_for_user(ext_id, account_id.id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/permissions")
async def api_update_extension_permissions(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = (
[p.id for p in permissions_source] if permissions_source else []
)
granted_permissions = grant.permissions if grant and grant.permissions else []
granted_tags = grant.payment_tags if grant and grant.payment_tags else []
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(
_get_required_payment_tags(ext_id, ext), granted_tags
)
if required_permissions:
missing = [p for p in required_permissions if p not in granted_permissions]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to save for this extension.",
)
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
@extension_router.get("/{ext_id}/capabilities")
async def api_extension_capabilities(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
await _ensure_extension_exists(ext_id)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
permissions = [p.id for p in permissions_source] if permissions_source else []
missing = (
_missing_api_permissions(request, permissions)
if _is_wasm_extension(ext_id, ext)
else []
)
payment_tags = _get_required_payment_tags(ext_id, ext)
return {
"ok": True,
"extension": ext_id,
"is_wasm": _is_wasm_extension(ext_id, ext),
"permissions": permissions,
"missing_permissions": missing,
"payment_tags": payment_tags,
}
@extension_router.get("/wasm/manifest")
async def api_wasm_manifest(
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
return WASM_HOST_MANIFEST
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus: async def api_activate_extension(ext_id: str) -> SimpleStatus:
try: try:
@@ -519,6 +743,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions( installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn conn=conn
) )
user_exts = await get_user_extensions(account_id.id, conn=conn)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)] all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [ inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn) e.id for e in await get_installed_extensions(active=False, conn=conn)
@@ -526,6 +751,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
db_versions = await get_db_versions(conn=conn) db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
user_exts_map = {e.extension: e for e in user_exts}
installable_exts = await InstallableExtension.get_installable_extensions( installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id post_refresh_cache=account_id.is_admin_id
@@ -554,6 +780,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.name = installed_ext.name e.name = installed_ext.name
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
e.icon = installed_ext.icon e.icon = installed_ext.icon
if e.meta and not e.meta.permissions:
e.meta.permissions = _load_permissions_from_config(e.id)
if e.meta and not e.meta.payment_tags:
e.meta.payment_tags = _load_payment_tags_from_config(e.id)
extension_data = [ extension_data = [
{ {
@@ -571,6 +801,27 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"isAvailable": ext.id in all_ext_ids, "isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions, "isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions, "isActive": ext.id not in inactive_extensions,
"permissions": (
[dict(p) for p in ext.meta.permissions]
if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"paymentTags": (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext.id)
),
"kvSchema": _load_kv_schema_from_config(ext.id),
"grantedPermissions": (
user_ext.extra.granted_permissions
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"grantedPaymentTags": (
user_ext.extra.granted_payment_tags
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"latestRelease": ( "latestRelease": (
dict(ext.meta.latest_release) dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release if ext.meta and ext.meta.latest_release
@@ -598,6 +849,162 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
return extension_data return extension_data
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
permissions = config_json.get("permissions", [])
return [ExtensionPermission(**p) for p in permissions]
except Exception:
return []
def _load_kv_schema_from_config(ext_id: str) -> dict:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return {}
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
schema = config_json.get("kv_schema", {})
return schema if isinstance(schema, dict) else {}
except Exception:
return {}
def _load_payment_tags_from_config(ext_id: str) -> list[str]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
tags = config_json.get("payment_tags", [])
return tags if isinstance(tags, list) else []
except Exception:
return []
def _merge_permissions(required: list[str], granted: list[str]) -> list[str]:
merged = set()
for perm in required or []:
merged.add(perm)
for perm in granted or []:
merged.add(perm)
return list(merged)
def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> None:
if not required and granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This extension does not declare any payment tags.",
)
if required and not granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Select at least one payment tag before enabling this extension.",
)
if not required or not granted:
return
invalid = [t for t in granted if t not in required]
if invalid:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Invalid payment tags requested: {', '.join(invalid)}",
)
def _ensure_api_permissions_available(request: Request, permissions: list[str]) -> None:
if not permissions:
return
missing = []
for perm in permissions:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Permissions reference missing endpoints: {', '.join(missing)}",
)
def _parse_api_permission(perm: str) -> tuple[str, str] | None:
if not perm.startswith("api."):
return None
try:
method_part, path = perm.split(":", 1)
method = method_part.replace("api.", "").upper()
except ValueError:
return None
if not path.startswith("/"):
return None
return method, path
def _missing_api_permissions(request: Request, permissions: list[str]) -> list[str]:
missing = []
for perm in permissions or []:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
return missing
def _route_exists(request: Request, method: str, path: str) -> bool:
scope = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
"headers": [],
}
for route in request.app.router.routes:
methods = getattr(route, "methods", None)
if methods and method not in methods:
continue
try:
match, _ = route.matches(scope)
except Exception as exc:
logger.debug(f"Route match failed for {method} {path}: {exc!s}")
continue
if match == Match.FULL:
return True
return False
def _is_wasm_extension(ext_id: str, ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return False
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type") == "wasm"
except Exception:
return False
@extension_router.get( @extension_router.get(
"/reviews/tags", "/reviews/tags",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_account_exists)],
+26
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, WebSocket from fastapi import APIRouter, WebSocket
from ..crud.wallets import get_wallet_for_key
from ..models import KeyType
from ..services import websocket_manager from ..services import websocket_manager
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"]) websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@@ -11,6 +13,30 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
await websocket_manager.listen(conn) await websocket_manager.listen(conn)
@websocket_router.websocket("/tag/{tag}")
async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None:
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get(
"api-key"
)
if not api_key:
await websocket.close(code=4401)
return
wallet = await get_wallet_for_key(api_key)
if not wallet:
await websocket.close(code=4404)
return
key_type = KeyType.admin if wallet.adminkey == api_key else KeyType.invoice
if key_type not in {KeyType.admin, KeyType.invoice}:
await websocket.close(code=4403)
return
item_id = f"tag:{wallet.id}:{tag}"
conn = await websocket_manager.connect(item_id, websocket)
await websocket_manager.listen(conn)
@websocket_router.post("/{item_id}") @websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str): async def websocket_update_post(item_id: str, data: str):
try: try:
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.extension_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.runner import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.service import * # noqa: F401,F403
File diff suppressed because one or more lines are too long
+169 -3
View File
@@ -28,6 +28,14 @@ window.PageExtensions = {
paylinkWebsocket: null, paylinkWebsocket: null,
searchToggle: false, searchToggle: false,
reviewsUrl: null, reviewsUrl: null,
permissionsDialog: {
show: false,
extension: null,
checked: [],
missing: [],
tags: [],
tagOptions: []
},
reviewsDialog: { reviewsDialog: {
show: false, show: false,
extension: null, extension: null,
@@ -92,6 +100,20 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val) 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: { methods: {
filterExtensions(term, tab) { filterExtensions(term, tab) {
// Filter the extensions list // Filter the extensions list
@@ -241,18 +263,49 @@ window.PageExtensions = {
}) })
}, },
async enableExtensionForUser(extension) { async enableExtensionForUser(extension) {
if (extension.permissions && extension.permissions.length) {
if (!extension._grantedPermissions) {
Quasar.Notify.create({
type: 'warning',
message: 'Save permissions before enabling this extension.'
})
return
}
}
if (extension.paymentTags && extension.paymentTags.length) {
if (
!extension._grantedPaymentTags ||
!extension._grantedPaymentTags.length
) {
Quasar.Notify.create({
type: 'warning',
message: 'Select payment tags before enabling this extension.'
})
return
}
}
if (extension.isPaymentRequired) { if (extension.isPaymentRequired) {
this.showPayToEnable(extension) this.showPayToEnable(extension)
return return
} }
this.enableExtension(extension) this.enableExtension(extension)
}, },
async enableExtension(extension) { async enableExtension(extension, permissions) {
const granted =
permissions ||
extension._grantedPermissions ||
(extension.permissions ? extension.permissions.map(p => p.id) : [])
const tags =
extension._grantedPaymentTags ||
(extension.paymentTags ? extension.paymentTags.slice() : [])
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
`/api/v1/extension/${extension.id}/enable`, `/api/v1/extension/${extension.id}/enable`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey,
granted.length || tags.length
? {permissions: granted, payment_tags: tags}
: null
) )
.then(response => { .then(response => {
this.g.user.extensions = this.g.user.extensions.concat([extension.id]) this.g.user.extensions = this.g.user.extensions.concat([extension.id])
@@ -287,13 +340,116 @@ window.PageExtensions = {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
}, },
showPayToEnable(extension) { showPayToEnable(extension, permissions) {
this.selectedExtension = extension this.selectedExtension = extension
if (permissions) {
this.selectedExtension._grantedPermissions = permissions
}
this.selectedExtension.payToEnable.paidAmount = this.selectedExtension.payToEnable.paidAmount =
extension.payToEnable.amount extension.payToEnable.amount
this.selectedExtension.payToEnable.showQRCode = false this.selectedExtension.payToEnable.showQRCode = false
this.showPayToEnableDialog = true this.showPayToEnableDialog = true
}, },
openPermissionsDialog(extension) {
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
this.permissionsDialog.show = 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.permissions || !extension.permissions.length) {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not declare permissions.'
})
return
}
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/capabilities`,
this.g.user.wallets[0].adminkey
)
if (data && Array.isArray(data.missing_permissions)) {
this.permissionsDialog.missing = data.missing_permissions
}
if (data && Array.isArray(data.payment_tags)) {
this.permissionsDialog.tagOptions = data.payment_tags
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
this.permissionsDialog.show = true
},
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',
`/api/v1/extension/${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) { updatePayToInstallData(extension) {
LNbits.api LNbits.api
.request( .request(
@@ -832,6 +988,16 @@ window.PageExtensions = {
async fetchAllExtensions() { async fetchAllExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`) const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
if (ext.grantedPermissions && ext.grantedPermissions.length) {
ext._grantedPermissions = ext.grantedPermissions
}
if (ext.grantedPaymentTags && ext.grantedPaymentTags.length) {
ext._grantedPaymentTags = ext.grantedPaymentTags
}
const schema = ext.kvSchema || ext.kv_schema || {}
ext.kvSchemaKeys = Object.keys(schema)
})
return data return data
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
+5
View File
@@ -96,6 +96,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
invoice_listeners[name] = send_chan 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) internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
+111
View File
@@ -329,6 +329,19 @@
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
</span> </q-tooltip </span> </q-tooltip
></q-btn> ></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.permissions &&
extension.permissions.length
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn <q-btn
@click="showManageExtension(extension)" @click="showManageExtension(extension)"
@@ -921,6 +934,104 @@
</q-card> </q-card>
</q-dialog> </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"
clickable
>
<q-item-section>
<q-item-label v-text="perm.label"></q-item-label>
<q-item-label caption v-text="perm.description"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.checked" :val="perm.id" />
</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.extension &&
permissionsDialog.extension.kvSchemaKeys &&
permissionsDialog.extension.kvSchemaKeys.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Data this extension stores in its db:
</div>
<q-chip
v-for="kvKey in permissionsDialog.extension.kvSchemaKeys"
:key="kvKey"
:label="kvKey"
color="grey-3"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</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-dialog v-model="showExtensionDetailsDialog" position="top">
<q-card <q-card
v-if="selectedExtensionDetails" v-if="selectedExtensionDetails"
+1
View File
@@ -51,6 +51,7 @@ dependencies = [
"pillow>=12.1.0", "pillow>=12.1.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"greenlet (>=3.3.0,<4.0.0)", "greenlet (>=3.3.0,<4.0.0)",
"wasmtime>=16.0.0",
] ]
[project.scripts] [project.scripts]
Generated
+21
View File
@@ -1290,6 +1290,7 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "uvloop" }, { name = "uvloop" },
{ name = "wasmtime" },
{ name = "websocket-client" }, { name = "websocket-client" },
{ name = "websockets" }, { name = "websockets" },
] ]
@@ -1371,6 +1372,7 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.54" }, { name = "sqlalchemy", specifier = "==1.4.54" },
{ name = "sse-starlette", specifier = "==2.3.6" }, { name = "sse-starlette", specifier = "==2.3.6" },
{ name = "starlette", specifier = "==0.47.1" }, { name = "starlette", specifier = "==0.47.1" },
{ name = "wasmtime", specifier = ">=16.0.0" },
{ name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-extensions", specifier = "==4.15.0" },
{ name = "uvicorn", specifier = "==0.40.0" }, { name = "uvicorn", specifier = "==0.40.0" },
{ name = "uvloop", specifier = "==0.22.1" }, { name = "uvloop", specifier = "==0.22.1" },
@@ -2710,6 +2712,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/8e43c5d27753135fa8df3de333c1f1917d6dc315d119ffa60d0587d8d9ff/wallycore-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b932510c859ac3e1fc57161420ecb2bb9b6562fce7acd1d9351337cc2cc3515", size = 1728013, upload-time = "2025-08-22T04:26:40.774Z" }, { url = "https://files.pythonhosted.org/packages/49/4b/8e43c5d27753135fa8df3de333c1f1917d6dc315d119ffa60d0587d8d9ff/wallycore-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b932510c859ac3e1fc57161420ecb2bb9b6562fce7acd1d9351337cc2cc3515", size = 1728013, upload-time = "2025-08-22T04:26:40.774Z" },
] ]
[[package]]
name = "wasmtime"
version = "41.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/68/6dc0e7156f883afe0129dd89e4031c8d1163131794ba6ce9e454a09168ad/wasmtime-41.0.0.tar.gz", hash = "sha256:fc2aaacf3ba794eac8baeb739939b2f7903e12d6b78edddc0b7f3ac3a9af6dfc", size = 117354, upload-time = "2026-01-20T18:18:00.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/f9/f6aef5de536d12652d97cf162f124cbdd642150c7da61ffa7863272cdab7/wasmtime-41.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:f5a6e237b5b94188ef9867926b447f779f540c729c92e4d91cc946f2bee7c282", size = 6837018, upload-time = "2026-01-20T18:17:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/04/b9/42ec977972b2dcc8c61e3a40644d24d229b41fba151410644e44e35e6eb1/wasmtime-41.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:4a3e33d0d3cf49062eaa231f748f54af991e89e9a795c5ab9d4f0eee85736e4c", size = 7654957, upload-time = "2026-01-20T18:17:43.285Z" },
{ url = "https://files.pythonhosted.org/packages/18/ca/6cce49b03c35c7fecb4437fd98990c64694a5e0024f9279bef0ddef000f7/wasmtime-41.0.0-py3-none-any.whl", hash = "sha256:5f6721406a6cd186d11f34e6d4991c4d536387b0c577d09a56bd93b8a3cf10c2", size = 6325757, upload-time = "2026-01-20T18:17:44.789Z" },
{ url = "https://files.pythonhosted.org/packages/a0/16/d91cb80322cc7ae10bfa5db8cea4e0b9bb112f0c100b4486783ab16c1c22/wasmtime-41.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2107360212fce33ed2adcfc33b7e75ed7136380a17d3ed598a5bab376dcf9e1b", size = 7471888, upload-time = "2026-01-20T18:17:46.185Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f0/dcc80973d2ec58a1978b838887ccbd84d56900cf66dec5fb730bec3bd081/wasmtime-41.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f475df32ce9bfec4f6d0e124a49ca4a89e2ee71ccca460677f5237b1c8ee92ae", size = 6507285, upload-time = "2026-01-20T18:17:48.138Z" },
{ url = "https://files.pythonhosted.org/packages/bd/df/0867edd9ec26eb2e5eee7674a55f82c23ec27dd1d38d2d401f0e308eb920/wasmtime-41.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad7e866430313eb2ee07c85811e524344884489d00896f3b2246b65553fe322c", size = 7732024, upload-time = "2026-01-20T18:17:50.207Z" },
{ url = "https://files.pythonhosted.org/packages/bb/48/b748a2e70478feabc5c876d90e90a39f4aba35378f5ee822f607e8f29c69/wasmtime-41.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e0ea44584f60dcfa620af82d4fc2589248bcf64a93905b54ac3144242113b48a", size = 6800017, upload-time = "2026-01-20T18:17:51.7Z" },
{ url = "https://files.pythonhosted.org/packages/14/29/43656c3a464d437d62421de16f2de2db645647bab0a0153deea30bfdade4/wasmtime-41.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dabb20a2751f01b835095013426a76091bd0bdb36ca9bcfc49c910b78347438", size = 6840763, upload-time = "2026-01-20T18:17:53.125Z" },
{ url = "https://files.pythonhosted.org/packages/9f/09/4608b65fa35ce5fc1479e138293a1166b4ea817cfa9a79f019ab6d7013d8/wasmtime-41.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9627dfc5625b4947ea35c819561da358838fe76f65bda8ffe01ce34df8b32b1", size = 7754016, upload-time = "2026-01-20T18:17:55.346Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9d/236bb367270579e4f628fb7b04fe93541151df7953006f3766607fc667c9/wasmtime-41.0.0-py3-none-win_amd64.whl", hash = "sha256:4f29171d73b71f232b6fe86cba77526fee84139f1590071af5facba401b0c9eb", size = 6325764, upload-time = "2026-01-20T18:17:57.034Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/bba9c0368c377250ab24fd005a7a1e9076121778c1e83b1bcc092ab84f86/wasmtime-41.0.0-py3-none-win_arm64.whl", hash = "sha256:0c4bcaba055e78fc161f497b85f39f1d35d475f0341b1e0259fa0a4b49e223e8", size = 5392238, upload-time = "2026-01-20T18:17:59.052Z" },
]
[[package]] [[package]]
name = "websocket-client" name = "websocket-client"
version = "1.9.0" version = "1.9.0"