tasks and docs
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Agent Guide - Python Extensions
|
||||
nav_order: 4
|
||||
---
|
||||
|
||||
# Agent Guide - Python Extensions
|
||||
|
||||
This guide is for AI agents or developers using AI to build **traditional (Python) LNbits extensions**. It defines what to change, what not to change, and the expected structure.
|
||||
|
||||
## Hard Rules (Non-Negotiable)
|
||||
- Do **not** change core LNbits files.
|
||||
- Only edit files inside your extension folder.
|
||||
- Do **not** add new Python dependencies unless explicitly approved.
|
||||
|
||||
## Extension Folder Layout (Python)
|
||||
Your extension lives under:
|
||||
|
||||
```
|
||||
lnbits/extensions/<ext_id>/
|
||||
```
|
||||
|
||||
Typical files to edit:
|
||||
- `views.py` (HTML routes)
|
||||
- `views_api.py` (API routes)
|
||||
- `crud.py` / `models.py` (storage logic + models)
|
||||
- `migrations.py` (DB schema)
|
||||
- `templates/<ext_id>/` (HTML)
|
||||
- `static/` (JS/CSS/images)
|
||||
- `config.json`, `manifest.json`, `README.md`
|
||||
|
||||
## What You Can Do
|
||||
Python extensions can:
|
||||
- Define their own database schema via `migrations.py`
|
||||
- Run long-running background tasks via `*_start()` and `*_stop()` hooks
|
||||
- Access LNbits internal services directly in Python
|
||||
- Expose custom API routes under `/<ext_id>/api/v1/...`
|
||||
|
||||
## What You Must Not Do
|
||||
- Do not modify core services or routes.
|
||||
- Do not patch LNbits internals for your extension.
|
||||
- Avoid direct DB access outside your own schema.
|
||||
|
||||
## Background Tasks
|
||||
Implement background tasks by exposing:
|
||||
|
||||
```
|
||||
def <ext_id>_start():
|
||||
def <ext_id>_stop():
|
||||
```
|
||||
|
||||
Use `register_invoice_listener` or `wait_for_paid_invoices` if you need to react to payments.
|
||||
|
||||
## Testing Checklist
|
||||
- Extension loads without errors.
|
||||
- Migrations apply cleanly.
|
||||
- Routes are registered under `/<ext_id>/...`.
|
||||
- Background tasks start/stop cleanly.
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Agent Guide - WASM Extensions
|
||||
nav_order: 3
|
||||
---
|
||||
|
||||
# Agent Guide - WASM Extensions
|
||||
|
||||
This guide is written for AI agents or developers using AI to build LNbits WASM extensions. It describes what to change, what not to change, and the available capabilities/limits.
|
||||
|
||||
## Hard Rules (Non-Negotiable)
|
||||
- Do **not** change core LNbits files. Only edit files inside your extension folder.
|
||||
- Do **not** add new Python dependencies.
|
||||
- Do **not** rely on long-running WASM processes. WASM runs per-call with timeouts.
|
||||
|
||||
## Extension Folder Layout (WASM)
|
||||
Your extension lives under:
|
||||
|
||||
```
|
||||
lnbits/extensions/<ext_id>/
|
||||
```
|
||||
|
||||
You should only edit files under this folder, typically:
|
||||
- `config.json` (metadata, permissions, tags, public handlers)
|
||||
- `wasm/` (your `module.wasm` or `module.wat`)
|
||||
- `static/` (frontend assets)
|
||||
- `templates/` (HTML pages)
|
||||
- `manifest.json`, `README.md`, `description.md` (docs and metadata)
|
||||
|
||||
## Required Config Fields
|
||||
In `config.json`:
|
||||
- `id` / `name`
|
||||
- `extension_type: "wasm"`
|
||||
- `permissions` (required API permissions)
|
||||
- `public_wasm_functions` (handlers callable from public routes)
|
||||
- `public_kv_keys` (publicly readable KV keys)
|
||||
- `payment_tags` (list of tags the user may grant for watcher access)
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"id": "myext",
|
||||
"name": "MyExt",
|
||||
"extension_type": "wasm",
|
||||
"permissions": [
|
||||
{"id": "ext.db.read_write", "label": "DB access", "description": "..."},
|
||||
{"id": "api.POST:/api/v1/payments", "label": "Create invoices", "description": "..."},
|
||||
{"id": "ext.payments.watch", "label": "Watch payments", "description": "..."},
|
||||
{"id": "ext.tasks.schedule", "label": "Schedule tasks", "description": "..."},
|
||||
{"id": "ext.db.sql", "label": "SQL access", "description": "..."}
|
||||
],
|
||||
"public_wasm_functions": ["public_create_invoice", "on_tag_payment", "on_schedule"],
|
||||
"public_kv_keys": ["public_lists", "public_tasks"],
|
||||
"payment_tags": ["coinflip", "myext"]
|
||||
}
|
||||
```
|
||||
|
||||
## What the WASM Host Can Do
|
||||
WASM runs in a short-lived subprocess. It can:
|
||||
- Read/write extension KV (`/api/v1/kv/*`)
|
||||
- Read/write secret KV (`/api/v1/secret/*`)
|
||||
- Call internal LNbits endpoints (only if declared + granted)
|
||||
- Publish websockets (`ws_publish`)
|
||||
- Run backend tag watchers and scheduled handlers (server-side triggers)
|
||||
|
||||
## Permissions Model
|
||||
Your extension can only call or access what is declared and granted:
|
||||
- `api.METHOD:/path` for internal endpoints (core or other extensions)
|
||||
- `ext.db.read_write` for KV access
|
||||
- `ext.payments.watch` for payment watchers
|
||||
- `ext.tasks.schedule` for scheduled jobs
|
||||
- `ext.db.sql` for SQL interface
|
||||
|
||||
If the endpoint doesn’t exist, permissions won’t save.
|
||||
|
||||
## Tag Watchers (Backend)
|
||||
You can register tag watchers:
|
||||
|
||||
```
|
||||
POST /<ext_id>/api/v1/watch_tag
|
||||
{
|
||||
"tag": "coinflip",
|
||||
"wallet_id": "<wallet-id>",
|
||||
"handler": "on_tag_payment",
|
||||
"store_key": "tag:coinflip:last_payment"
|
||||
}
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Tag must be in `payment_tags` and granted by the user.
|
||||
- Watchers are persisted and restored on restart.
|
||||
|
||||
## Scheduled Tasks (Backend)
|
||||
You can schedule periodic handlers:
|
||||
|
||||
```
|
||||
POST /<ext_id>/api/v1/schedule
|
||||
{
|
||||
"interval_seconds": 30,
|
||||
"handler": "on_schedule",
|
||||
"store_key": "schedule:last_run"
|
||||
}
|
||||
```
|
||||
|
||||
Constraints:
|
||||
- Requires `ext.tasks.schedule` permission.
|
||||
- Minimum interval is 5 seconds.
|
||||
- Stored in extension KV and restored on restart.
|
||||
|
||||
## SQL Interface (Limited)
|
||||
You can run SQL within your extension schema:
|
||||
- `/api/v1/sql/query` (SELECT only)
|
||||
- `/api/v1/sql/exec` (limited DDL/DML)
|
||||
|
||||
Rules:
|
||||
- Single statement only
|
||||
- No `PRAGMA`, no `sqlite_master`
|
||||
- No cross-schema access
|
||||
|
||||
## Public Pages (No Keys)
|
||||
Public pages must not depend on `window.g` or wallet keys.
|
||||
They can call:
|
||||
- `/{ext_id}/api/v1/public/kv/{key}`
|
||||
- `/{ext_id}/api/v1/public/call/{handler}`
|
||||
|
||||
## What Not To Do
|
||||
- Do not write to core routes or override existing LNbits paths.
|
||||
- Do not add background threads; use watchers or scheduler instead.
|
||||
- Do not assume the WASM process persists.
|
||||
|
||||
## Testing Checklist
|
||||
- Permissions show correctly in the extensions UI.
|
||||
- Public handlers are in `public_wasm_functions`.
|
||||
- Public KV keys are explicitly listed.
|
||||
- Tag watchers only use allowed tags.
|
||||
- Scheduled handlers run and update KV as expected.
|
||||
+9
-1
@@ -39,7 +39,11 @@ from lnbits.core.tasks import (
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
)
|
||||
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
handle_wasm_tag_payment,
|
||||
register_wasm_ext_routes,
|
||||
wasm_scheduler,
|
||||
)
|
||||
from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.settings import settings
|
||||
@@ -502,6 +506,10 @@ def register_async_tasks() -> None:
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
# wasm tag watcher listener
|
||||
create_permanent_task(wait_for_paid_invoices("wasm_tags", handle_wasm_tag_payment))
|
||||
create_permanent_task(wasm_scheduler)
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
@@ -87,6 +87,7 @@ class ExtensionConfig(BaseModel):
|
||||
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:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -120,6 +121,7 @@ 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 ExtensionPermission(BaseModel):
|
||||
@@ -131,6 +133,7 @@ class ExtensionPermission(BaseModel):
|
||||
|
||||
class ExtensionPermissionsGrant(BaseModel):
|
||||
permissions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
@@ -166,6 +169,7 @@ class Extension(BaseModel):
|
||||
extension_type: str | None = None
|
||||
public_kv_keys: list[str] = []
|
||||
public_wasm_functions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
|
||||
@property
|
||||
def module_name(self) -> str:
|
||||
@@ -194,6 +198,7 @@ class Extension(BaseModel):
|
||||
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 [],
|
||||
)
|
||||
|
||||
|
||||
@@ -359,6 +364,7 @@ class ExtensionMeta(BaseModel):
|
||||
extension_type: str | None = "python"
|
||||
public_kv_keys: list[str] = []
|
||||
public_wasm_functions: list[str] = []
|
||||
payment_tags: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
paid_features: str | None = None
|
||||
@@ -489,6 +495,7 @@ class InstallableExtension(BaseModel):
|
||||
self.meta.public_wasm_functions = config_json.get(
|
||||
"public_wasm_functions", []
|
||||
)
|
||||
self.meta.payment_tags = config_json.get("payment_tags", [])
|
||||
|
||||
if (
|
||||
self.meta
|
||||
@@ -611,6 +618,7 @@ class InstallableExtension(BaseModel):
|
||||
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:
|
||||
@@ -663,6 +671,7 @@ class InstallableExtension(BaseModel):
|
||||
public_wasm_functions=config_json.get(
|
||||
"public_wasm_functions", []
|
||||
),
|
||||
payment_tags=config_json.get("payment_tags", []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ from lnbits.core.crud.extensions import (
|
||||
update_installed_extension,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
clear_schedules_for_extension,
|
||||
clear_tag_watches_for_extension,
|
||||
)
|
||||
from lnbits.db import Connection, Database, COCKROACH, POSTGRES
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -75,6 +79,8 @@ async def uninstall_extension(ext_id: str):
|
||||
extension = await get_installed_extension(ext_id)
|
||||
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()
|
||||
await delete_installed_extension(ext_id=ext_id)
|
||||
@@ -101,6 +107,10 @@ async def activate_extension(ext: Extension):
|
||||
async def deactivate_extension(ext_id: str):
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -313,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
payment.payment_hash,
|
||||
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):
|
||||
|
||||
@@ -8,6 +8,7 @@ import httpx
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi.requests import Request
|
||||
from starlette.routing import Match
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
@@ -34,6 +35,7 @@ from lnbits.core.models.extensions import (
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
)
|
||||
from lnbits.core.wasm import WASM_HOST_MANIFEST
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.core.services.extensions import (
|
||||
@@ -44,6 +46,10 @@ from lnbits.core.services.extensions import (
|
||||
install_extension,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.core.wasm.extension_host import (
|
||||
clear_schedules_for_user,
|
||||
clear_tag_watches_for_user,
|
||||
)
|
||||
from lnbits.db import Page
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
@@ -179,6 +185,7 @@ async def api_update_pay_to_enable(
|
||||
@extension_router.put("/{ext_id}/enable")
|
||||
async def api_enable_extension(
|
||||
ext_id: str,
|
||||
request: Request,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
||||
) -> SimpleStatus:
|
||||
@@ -190,10 +197,19 @@ async def api_enable_extension(
|
||||
|
||||
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)
|
||||
@@ -245,6 +261,25 @@ def _get_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
|
||||
@@ -265,6 +300,15 @@ async def _store_granted_permissions(
|
||||
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)
|
||||
@@ -318,13 +362,20 @@ async def api_disable_extension(
|
||||
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)
|
||||
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.")
|
||||
|
||||
|
||||
@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:
|
||||
@@ -353,6 +404,14 @@ async def api_update_extension_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]
|
||||
@@ -364,12 +423,54 @@ async def api_update_extension_permissions(
|
||||
|
||||
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)])
|
||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
@@ -681,6 +782,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
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 = [
|
||||
{
|
||||
@@ -703,12 +806,22 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
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": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
@@ -766,6 +879,126 @@ def _load_kv_schema_from_config(ext_id: str) -> dict:
|
||||
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 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:
|
||||
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(
|
||||
"/reviews/tags",
|
||||
dependencies=[Depends(check_account_exists)],
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from fastapi import APIRouter, WebSocket
|
||||
|
||||
from ..crud.wallets import get_wallet_for_key
|
||||
from ..models import KeyType
|
||||
from ..services import websocket_manager
|
||||
|
||||
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
|
||||
@@ -11,6 +13,28 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
|
||||
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}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
WASM_HOST_API_VERSION = "1.0"
|
||||
|
||||
WASM_HOST_MANIFEST = {
|
||||
"version": WASM_HOST_API_VERSION,
|
||||
"host_functions": [
|
||||
"db_get",
|
||||
"db_set",
|
||||
"db_secret_get",
|
||||
"db_secret_set",
|
||||
"http_request",
|
||||
"ws_publish",
|
||||
],
|
||||
"routes": [
|
||||
{"method": "GET", "path": "/{ext_id}/", "public": False},
|
||||
{"method": "GET", "path": "/{ext_id}/public/{key}", "public": True},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/{ext_id}/api/v1/kv/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/kv/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/{ext_id}/api/v1/public/kv/{key}",
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/secret/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/secret/{key}",
|
||||
"permission": "ext.db.read_write",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/public/call/{handler}",
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/watch",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/watch_tag",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/watch_tag",
|
||||
"permission": "ext.payments.watch",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/schedule",
|
||||
"permission": "ext.tasks.schedule",
|
||||
},
|
||||
{
|
||||
"method": "DELETE",
|
||||
"path": "/{ext_id}/api/v1/schedule",
|
||||
"permission": "ext.tasks.schedule",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/sql/query",
|
||||
"permission": "ext.db.sql",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/sql/exec",
|
||||
"permission": "ext.db.sql",
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/{ext_id}/api/v1/proxy",
|
||||
"permission": "api.METHOD:/path",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -14,6 +15,8 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extension
|
||||
from lnbits.core.models import User
|
||||
from lnbits.core.models.payments import Payment
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.crud.payments import get_standalone_payment, update_payment
|
||||
from lnbits.core.services import websocket_updater
|
||||
from loguru import logger
|
||||
@@ -26,6 +29,512 @@ from lnbits.tasks import register_invoice_listener, unregister_invoice_listener
|
||||
from .service import WasmExecutionError, wasm_call
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagWatch:
|
||||
ext_id: str
|
||||
user_id: str
|
||||
wallet_id: str
|
||||
tag: str
|
||||
handler: str
|
||||
store_key: str
|
||||
upgrade_hash: str | None
|
||||
|
||||
|
||||
_tag_watchers: dict[tuple[str, str], list[TagWatch]] = {}
|
||||
_TAG_WATCH_KV_KEY = "watch_tags"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduleTask:
|
||||
ext_id: str
|
||||
user_id: str
|
||||
handler: str
|
||||
interval_seconds: int
|
||||
store_key: str
|
||||
upgrade_hash: str | None
|
||||
next_run: float
|
||||
|
||||
|
||||
_scheduled_tasks: dict[str, list[ScheduleTask]] = {}
|
||||
_SCHEDULE_KV_KEY = "scheduled_tasks"
|
||||
|
||||
|
||||
def _register_tag_watch(watch: TagWatch) -> None:
|
||||
key = (watch.wallet_id, watch.tag)
|
||||
existing = _tag_watchers.get(key, [])
|
||||
for item in existing:
|
||||
if (
|
||||
item.ext_id == watch.ext_id
|
||||
and item.user_id == watch.user_id
|
||||
and item.handler == watch.handler
|
||||
and item.store_key == watch.store_key
|
||||
):
|
||||
return
|
||||
existing.append(watch)
|
||||
_tag_watchers[key] = existing
|
||||
|
||||
|
||||
async def _load_schedule_list(db: Database, ext_id: str) -> list[dict]:
|
||||
raw = await _kv_get(db, ext_id, _SCHEDULE_KV_KEY)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _save_schedule_list(db: Database, ext_id: str, items: list[dict]) -> None:
|
||||
await _kv_set(db, ext_id, _SCHEDULE_KV_KEY, json.dumps(items))
|
||||
|
||||
|
||||
def _register_schedule(task: ScheduleTask) -> None:
|
||||
existing = _scheduled_tasks.get(task.ext_id, [])
|
||||
for item in existing:
|
||||
if (
|
||||
item.user_id == task.user_id
|
||||
and item.handler == task.handler
|
||||
and item.interval_seconds == task.interval_seconds
|
||||
and item.store_key == task.store_key
|
||||
):
|
||||
return
|
||||
existing.append(task)
|
||||
_scheduled_tasks[task.ext_id] = existing
|
||||
|
||||
|
||||
async def _persist_schedule(ext_id: str, task: ScheduleTask) -> None:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_schedule_list(db, ext_id)
|
||||
for item in items:
|
||||
if (
|
||||
item.get("user_id") == task.user_id
|
||||
and item.get("handler") == task.handler
|
||||
and item.get("interval_seconds") == task.interval_seconds
|
||||
and item.get("store_key") == task.store_key
|
||||
):
|
||||
return
|
||||
items.append(
|
||||
{
|
||||
"user_id": task.user_id,
|
||||
"handler": task.handler,
|
||||
"interval_seconds": task.interval_seconds,
|
||||
"store_key": task.store_key,
|
||||
"upgrade_hash": task.upgrade_hash,
|
||||
}
|
||||
)
|
||||
await _save_schedule_list(db, ext_id, items)
|
||||
|
||||
|
||||
async def _remove_persisted_schedule_entries(
|
||||
ext_id: str,
|
||||
user_id: str | None = None,
|
||||
handler: str | None = None,
|
||||
store_key: str | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_schedule_list(db, ext_id)
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if not (
|
||||
(user_id is None or item.get("user_id") == user_id)
|
||||
and (handler is None or item.get("handler") == handler)
|
||||
and (store_key is None or item.get("store_key") == store_key)
|
||||
)
|
||||
]
|
||||
await _save_schedule_list(db, ext_id, items)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _remove_schedule_entries(
|
||||
ext_id: str,
|
||||
user_id: str | None = None,
|
||||
handler: str | None = None,
|
||||
store_key: str | None = None,
|
||||
) -> None:
|
||||
existing = _scheduled_tasks.get(ext_id, [])
|
||||
if not existing:
|
||||
return
|
||||
_scheduled_tasks[ext_id] = [
|
||||
item
|
||||
for item in existing
|
||||
if not (
|
||||
(user_id is None or item.user_id == user_id)
|
||||
and (handler is None or item.handler == handler)
|
||||
and (store_key is None or item.store_key == store_key)
|
||||
)
|
||||
]
|
||||
if not _scheduled_tasks[ext_id]:
|
||||
_scheduled_tasks.pop(ext_id, None)
|
||||
|
||||
|
||||
async def restore_schedules(ext_id: str, upgrade_hash: str | None) -> None:
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_schedule_list(db, ext_id)
|
||||
now = time.time()
|
||||
for item in items:
|
||||
user_id = item.get("user_id")
|
||||
handler = item.get("handler")
|
||||
interval_seconds = item.get("interval_seconds")
|
||||
store_key = item.get("store_key")
|
||||
if not user_id or not handler or not interval_seconds or not store_key:
|
||||
continue
|
||||
user_ext = await get_user_extension(user_id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
continue
|
||||
granted = user_ext.extra.granted_permissions if user_ext.extra else []
|
||||
if "ext.tasks.schedule" not in (granted or []):
|
||||
continue
|
||||
_register_schedule(
|
||||
ScheduleTask(
|
||||
ext_id=ext_id,
|
||||
user_id=user_id,
|
||||
handler=handler,
|
||||
interval_seconds=int(interval_seconds),
|
||||
store_key=store_key,
|
||||
upgrade_hash=upgrade_hash,
|
||||
next_run=now + int(interval_seconds),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _matches_watch(
|
||||
watch: TagWatch,
|
||||
ext_id: str,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
tag: str,
|
||||
handler: str | None,
|
||||
store_key: str | None,
|
||||
) -> bool:
|
||||
if watch.ext_id != ext_id:
|
||||
return False
|
||||
if watch.user_id != user_id:
|
||||
return False
|
||||
if watch.wallet_id != wallet_id:
|
||||
return False
|
||||
if watch.tag != tag:
|
||||
return False
|
||||
if handler is not None and watch.handler != handler:
|
||||
return False
|
||||
if store_key is not None and watch.store_key != store_key:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def _load_tag_watch_list(db: Database, ext_id: str) -> list[dict]:
|
||||
raw = await _kv_get(db, ext_id, _TAG_WATCH_KV_KEY)
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
return data if isinstance(data, list) else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def _save_tag_watch_list(db: Database, ext_id: str, items: list[dict]) -> None:
|
||||
await _kv_set(db, ext_id, _TAG_WATCH_KV_KEY, json.dumps(items))
|
||||
|
||||
|
||||
async def _persist_tag_watch(ext_id: str, watch: TagWatch) -> None:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_tag_watch_list(db, ext_id)
|
||||
for item in items:
|
||||
if (
|
||||
item.get("user_id") == watch.user_id
|
||||
and item.get("wallet_id") == watch.wallet_id
|
||||
and item.get("tag") == watch.tag
|
||||
and item.get("handler") == watch.handler
|
||||
and item.get("store_key") == watch.store_key
|
||||
):
|
||||
return
|
||||
items.append(
|
||||
{
|
||||
"user_id": watch.user_id,
|
||||
"wallet_id": watch.wallet_id,
|
||||
"tag": watch.tag,
|
||||
"handler": watch.handler,
|
||||
"store_key": watch.store_key,
|
||||
"upgrade_hash": watch.upgrade_hash,
|
||||
}
|
||||
)
|
||||
await _save_tag_watch_list(db, ext_id, items)
|
||||
|
||||
|
||||
async def _remove_persisted_tag_watch(ext_id: str, watch: TagWatch) -> None:
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_tag_watch_list(db, ext_id)
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if not (
|
||||
item.get("user_id") == watch.user_id
|
||||
and item.get("wallet_id") == watch.wallet_id
|
||||
and item.get("tag") == watch.tag
|
||||
and item.get("handler") == watch.handler
|
||||
and item.get("store_key") == watch.store_key
|
||||
)
|
||||
]
|
||||
await _save_tag_watch_list(db, ext_id, items)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def _remove_persisted_tag_watch_entries(
|
||||
ext_id: str,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
tag: str,
|
||||
handler: str | None,
|
||||
store_key: str | None,
|
||||
) -> None:
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_tag_watch_list(db, ext_id)
|
||||
items = [
|
||||
item
|
||||
for item in items
|
||||
if not (
|
||||
item.get("user_id") == user_id
|
||||
and item.get("wallet_id") == wallet_id
|
||||
and item.get("tag") == tag
|
||||
and (handler is None or item.get("handler") == handler)
|
||||
and (store_key is None or item.get("store_key") == store_key)
|
||||
)
|
||||
]
|
||||
await _save_tag_watch_list(db, ext_id, items)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def handle_wasm_tag_payment(payment: Payment) -> None:
|
||||
if not payment.tag or not payment.is_in:
|
||||
return
|
||||
key = (payment.wallet_id, payment.tag)
|
||||
watchers = list(_tag_watchers.get(key, []))
|
||||
if not watchers:
|
||||
return
|
||||
for watch in watchers:
|
||||
asyncio.create_task(_dispatch_tag_watch(payment, watch))
|
||||
|
||||
|
||||
async def _dispatch_tag_watch(payment: Payment, watch: TagWatch) -> None:
|
||||
try:
|
||||
user_ext = await get_user_extension(watch.user_id, watch.ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
|
||||
await _remove_persisted_tag_watch(watch.ext_id, watch)
|
||||
return
|
||||
granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else []
|
||||
if watch.tag not in (granted_tags or []):
|
||||
_remove_tag_watch(payment.wallet_id, payment.tag, watch)
|
||||
await _remove_persisted_tag_watch(watch.ext_id, watch)
|
||||
return
|
||||
|
||||
_check_quota(
|
||||
watch.user_id, watch.ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min
|
||||
)
|
||||
|
||||
db = Database(f"ext_{watch.ext_id}")
|
||||
payload_json = json.dumps(payment.dict(exclude={"preimage"}))
|
||||
await _kv_set(db, watch.ext_id, watch.store_key, payload_json)
|
||||
await websocket_updater(f"{watch.ext_id}:{watch.store_key}", payload_json)
|
||||
|
||||
watch_payload = {
|
||||
"payment_hash": payment.payment_hash,
|
||||
"store_key": watch.store_key,
|
||||
"tag": watch.tag,
|
||||
}
|
||||
await _kv_set(db, watch.ext_id, "watch_request", json.dumps(watch_payload))
|
||||
await _kv_set(db, watch.ext_id, "public_request", watch.tag)
|
||||
await wasm_call(
|
||||
watch.ext_id,
|
||||
watch.handler,
|
||||
[],
|
||||
upgrade_hash=watch.upgrade_hash,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _remove_tag_watch(wallet_id: str, tag: str, watch: TagWatch) -> None:
|
||||
key = (wallet_id, tag)
|
||||
existing = _tag_watchers.get(key, [])
|
||||
if not existing:
|
||||
return
|
||||
_tag_watchers[key] = [
|
||||
item
|
||||
for item in existing
|
||||
if not (
|
||||
item.ext_id == watch.ext_id
|
||||
and item.user_id == watch.user_id
|
||||
and item.handler == watch.handler
|
||||
and item.store_key == watch.store_key
|
||||
)
|
||||
]
|
||||
if not _tag_watchers[key]:
|
||||
_tag_watchers.pop(key, None)
|
||||
|
||||
|
||||
def _remove_tag_watch_entries(
|
||||
ext_id: str,
|
||||
user_id: str,
|
||||
wallet_id: str,
|
||||
tag: str,
|
||||
handler: str | None,
|
||||
store_key: str | None,
|
||||
) -> None:
|
||||
key = (wallet_id, tag)
|
||||
existing = _tag_watchers.get(key, [])
|
||||
if not existing:
|
||||
return
|
||||
_tag_watchers[key] = [
|
||||
item
|
||||
for item in existing
|
||||
if not _matches_watch(item, ext_id, user_id, wallet_id, tag, handler, store_key)
|
||||
]
|
||||
if not _tag_watchers[key]:
|
||||
_tag_watchers.pop(key, None)
|
||||
|
||||
|
||||
async def clear_tag_watches_for_user(ext_id: str, user_id: str) -> None:
|
||||
keys = list(_tag_watchers.keys())
|
||||
for wallet_id, tag in keys:
|
||||
existing = _tag_watchers.get((wallet_id, tag), [])
|
||||
remaining = [w for w in existing if not (w.ext_id == ext_id and w.user_id == user_id)]
|
||||
if remaining:
|
||||
_tag_watchers[(wallet_id, tag)] = remaining
|
||||
else:
|
||||
_tag_watchers.pop((wallet_id, tag), None)
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_tag_watch_list(db, ext_id)
|
||||
items = [item for item in items if item.get("user_id") != user_id]
|
||||
await _save_tag_watch_list(db, ext_id, items)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def clear_tag_watches_for_extension(ext_id: str) -> None:
|
||||
keys = list(_tag_watchers.keys())
|
||||
for wallet_id, tag in keys:
|
||||
existing = _tag_watchers.get((wallet_id, tag), [])
|
||||
remaining = [w for w in existing if w.ext_id != ext_id]
|
||||
if remaining:
|
||||
_tag_watchers[(wallet_id, tag)] = remaining
|
||||
else:
|
||||
_tag_watchers.pop((wallet_id, tag), None)
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
await _save_tag_watch_list(db, ext_id, [])
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def clear_schedules_for_user(ext_id: str, user_id: str) -> None:
|
||||
_remove_schedule_entries(ext_id, user_id=user_id)
|
||||
await _remove_persisted_schedule_entries(ext_id, user_id=user_id)
|
||||
|
||||
|
||||
async def clear_schedules_for_extension(ext_id: str) -> None:
|
||||
_remove_schedule_entries(ext_id)
|
||||
await _remove_persisted_schedule_entries(ext_id)
|
||||
|
||||
|
||||
async def restore_tag_watches(ext_id: str, upgrade_hash: str | None) -> None:
|
||||
try:
|
||||
db = Database(f"ext_{ext_id}")
|
||||
items = await _load_tag_watch_list(db, ext_id)
|
||||
for item in items:
|
||||
user_id = item.get("user_id")
|
||||
wallet_id = item.get("wallet_id")
|
||||
tag = item.get("tag")
|
||||
handler = item.get("handler")
|
||||
store_key = item.get("store_key")
|
||||
if not user_id or not wallet_id or not tag or not handler or not store_key:
|
||||
continue
|
||||
user_ext = await get_user_extension(user_id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
continue
|
||||
granted_tags = user_ext.extra.granted_payment_tags if user_ext.extra else []
|
||||
if tag not in (granted_tags or []):
|
||||
continue
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user_id:
|
||||
continue
|
||||
_register_tag_watch(
|
||||
TagWatch(
|
||||
ext_id=ext_id,
|
||||
user_id=user_id,
|
||||
wallet_id=wallet_id,
|
||||
tag=tag,
|
||||
handler=handler,
|
||||
store_key=store_key,
|
||||
upgrade_hash=upgrade_hash,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def wasm_scheduler() -> None:
|
||||
while settings.lnbits_running:
|
||||
now = time.time()
|
||||
for ext_id, tasks in list(_scheduled_tasks.items()):
|
||||
for task in list(tasks):
|
||||
if task.next_run > now:
|
||||
continue
|
||||
asyncio.create_task(_dispatch_schedule_task(task))
|
||||
task.next_run = now + max(1, task.interval_seconds)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
async def _dispatch_schedule_task(task: ScheduleTask) -> None:
|
||||
try:
|
||||
user_ext = await get_user_extension(task.user_id, task.ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
_remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key)
|
||||
await _remove_persisted_schedule_entries(
|
||||
task.ext_id, task.user_id, task.handler, task.store_key
|
||||
)
|
||||
return
|
||||
granted = user_ext.extra.granted_permissions if user_ext.extra else []
|
||||
if "ext.tasks.schedule" not in (granted or []):
|
||||
_remove_schedule_entries(task.ext_id, task.user_id, task.handler, task.store_key)
|
||||
await _remove_persisted_schedule_entries(
|
||||
task.ext_id, task.user_id, task.handler, task.store_key
|
||||
)
|
||||
return
|
||||
|
||||
_check_quota(
|
||||
task.user_id,
|
||||
task.ext_id,
|
||||
"db",
|
||||
settings.lnbits_wasm_max_db_ops_per_min,
|
||||
)
|
||||
|
||||
db = Database(f"ext_{task.ext_id}")
|
||||
payload = {"ts": time.time(), "interval_seconds": task.interval_seconds}
|
||||
await _kv_set(db, task.ext_id, task.store_key, json.dumps(payload))
|
||||
await websocket_updater(f"{task.ext_id}:{task.store_key}", json.dumps(payload))
|
||||
await wasm_call(
|
||||
task.ext_id,
|
||||
task.handler,
|
||||
[],
|
||||
upgrade_hash=task.upgrade_hash,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _renderer(ext_id: str):
|
||||
return template_renderer([f"{ext_id}/templates"])
|
||||
|
||||
@@ -129,6 +638,32 @@ def _schema_for_key(schema: dict, key: str) -> dict | None:
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def _validate_sql(ext_id: str, sql: str, read_only: bool) -> None:
|
||||
statement = sql.strip().strip(";").lower()
|
||||
if ";" in statement:
|
||||
raise HTTPException(400, "Only single-statement SQL is allowed")
|
||||
if read_only:
|
||||
if not statement.startswith("select "):
|
||||
raise HTTPException(400, "Only SELECT is allowed in read-only queries")
|
||||
else:
|
||||
allowed = (
|
||||
"create table",
|
||||
"alter table",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
"drop table",
|
||||
)
|
||||
if not any(statement.startswith(prefix) for prefix in allowed):
|
||||
raise HTTPException(400, "Statement not allowed")
|
||||
if "sqlite_master" in statement or "pragma" in statement:
|
||||
raise HTTPException(400, "Statement not allowed")
|
||||
for match in re.finditer(r"\b([a-zA-Z_][a-zA-Z0-9_]*)\.", statement):
|
||||
schema = match.group(1)
|
||||
if schema != ext_id:
|
||||
raise HTTPException(400, "Cross-schema access is not allowed")
|
||||
|
||||
|
||||
def _coerce_schema_value(schema_entry: dict, value):
|
||||
value_type = schema_entry.get("type", "string")
|
||||
if value_type == "int":
|
||||
@@ -241,6 +776,15 @@ async def _require_permission(user_id: str, ext_id: str, permission: str) -> Non
|
||||
raise HTTPException(403, f"Missing permission: {permission}")
|
||||
|
||||
|
||||
async def _require_payment_tag(user_id: str, ext_id: str, tag: str) -> None:
|
||||
user_ext = await get_user_extension(user_id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(403, "Extension not enabled.")
|
||||
granted = user_ext.extra.granted_payment_tags if user_ext.extra else []
|
||||
if tag not in (granted or []):
|
||||
raise HTTPException(403, f"Missing payment tag permission: {tag}")
|
||||
|
||||
|
||||
def _register_pages_routes(router: APIRouter, ext_id: str) -> None:
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def index(req: Request, user: User = Depends(check_user_exists)):
|
||||
@@ -266,7 +810,6 @@ def _register_pages_routes(router: APIRouter, ext_id: str) -> None:
|
||||
def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
||||
_register_kv_read_routes(router, ext_id, db, ext)
|
||||
_register_kv_write_routes(router, ext_id, db)
|
||||
_register_kv_increment_route(router, ext_id, db, ext)
|
||||
_register_secret_routes(router, ext_id, db)
|
||||
|
||||
|
||||
@@ -393,26 +936,6 @@ def _register_kv_write_routes(router: APIRouter, ext_id: str, db: Database) -> N
|
||||
return {"key": key, "value": value}
|
||||
|
||||
|
||||
def _register_kv_increment_route(
|
||||
router: APIRouter, ext_id: str, db: Database, ext
|
||||
) -> None:
|
||||
@router.post("/api/v1/kv/{key}/increment")
|
||||
async def api_kv_increment(key: str, user: User = Depends(check_user_exists)):
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
|
||||
if key != "counter":
|
||||
raise HTTPException(400, "Only 'counter' is supported in this example.")
|
||||
try:
|
||||
new_value = await wasm_call(
|
||||
ext_id, "increment_counter", [], upgrade_hash=ext.upgrade_hash
|
||||
)
|
||||
except WasmExecutionError as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
await _kv_set(db, ext_id, key, str(new_value))
|
||||
await websocket_updater(f"{ext_id}:{key}", str(new_value))
|
||||
return {"key": key, "value": new_value}
|
||||
|
||||
|
||||
def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
||||
@router.post("/api/v1/watch")
|
||||
async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)):
|
||||
@@ -423,12 +946,165 @@ def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) ->
|
||||
if not payment_hash:
|
||||
raise HTTPException(400, "Missing payment_hash")
|
||||
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
||||
if tag:
|
||||
await _require_payment_tag(user.id, ext_id, tag)
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
task = _start_payment_watch(
|
||||
ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash
|
||||
)
|
||||
return {"ok": True, "task_id": id(task)}
|
||||
|
||||
@router.post("/api/v1/watch_tag")
|
||||
async def api_watch_tag(payload: dict, user: User = Depends(check_user_exists)):
|
||||
tag = payload.get("tag")
|
||||
handler = payload.get("handler") or "on_tag_payment"
|
||||
store_key = payload.get("store_key")
|
||||
wallet_id = payload.get("wallet_id")
|
||||
if not tag:
|
||||
raise HTTPException(400, "Missing tag")
|
||||
if not wallet_id:
|
||||
raise HTTPException(400, "Missing wallet_id")
|
||||
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
await _require_payment_tag(user.id, ext_id, tag)
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(403, "Wallet not found or not owned by user")
|
||||
|
||||
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
|
||||
ext_id
|
||||
)
|
||||
if handler not in funcs:
|
||||
raise HTTPException(400, "Handler not allowed")
|
||||
|
||||
if not store_key:
|
||||
store_key = f"tag:{tag}:last_payment"
|
||||
|
||||
_register_tag_watch(
|
||||
TagWatch(
|
||||
ext_id=ext_id,
|
||||
user_id=user.id,
|
||||
wallet_id=wallet_id,
|
||||
tag=tag,
|
||||
handler=handler,
|
||||
store_key=store_key,
|
||||
upgrade_hash=ext.upgrade_hash,
|
||||
)
|
||||
)
|
||||
await _persist_tag_watch(
|
||||
ext_id,
|
||||
TagWatch(
|
||||
ext_id=ext_id,
|
||||
user_id=user.id,
|
||||
wallet_id=wallet_id,
|
||||
tag=tag,
|
||||
handler=handler,
|
||||
store_key=store_key,
|
||||
upgrade_hash=ext.upgrade_hash,
|
||||
),
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/v1/watch_tag")
|
||||
async def api_watch_tag_delete(
|
||||
payload: dict, user: User = Depends(check_user_exists)
|
||||
):
|
||||
tag = payload.get("tag")
|
||||
wallet_id = payload.get("wallet_id")
|
||||
handler = payload.get("handler")
|
||||
store_key = payload.get("store_key")
|
||||
if not tag:
|
||||
raise HTTPException(400, "Missing tag")
|
||||
if not wallet_id:
|
||||
raise HTTPException(400, "Missing wallet_id")
|
||||
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or wallet.user != user.id:
|
||||
raise HTTPException(403, "Wallet not found or not owned by user")
|
||||
_remove_tag_watch_entries(
|
||||
ext_id, user.id, wallet_id, tag, handler, store_key
|
||||
)
|
||||
await _remove_persisted_tag_watch_entries(
|
||||
ext_id, user.id, wallet_id, tag, handler, store_key
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/v1/schedule")
|
||||
async def api_schedule_task(payload: dict, user: User = Depends(check_user_exists)):
|
||||
interval_seconds = payload.get("interval_seconds")
|
||||
handler = payload.get("handler") or "on_schedule"
|
||||
store_key = payload.get("store_key") or "schedule:last_run"
|
||||
if not interval_seconds:
|
||||
raise HTTPException(400, "Missing interval_seconds")
|
||||
try:
|
||||
interval_seconds = int(interval_seconds)
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid interval_seconds")
|
||||
if interval_seconds < 5:
|
||||
raise HTTPException(400, "Minimum interval is 5 seconds")
|
||||
|
||||
await _require_permission(user.id, ext_id, "ext.tasks.schedule")
|
||||
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||
|
||||
funcs = getattr(ext, "public_wasm_functions", None) or _load_public_wasm_functions(
|
||||
ext_id
|
||||
)
|
||||
if handler not in funcs:
|
||||
raise HTTPException(400, "Handler not allowed")
|
||||
|
||||
task = ScheduleTask(
|
||||
ext_id=ext_id,
|
||||
user_id=user.id,
|
||||
handler=handler,
|
||||
interval_seconds=interval_seconds,
|
||||
store_key=store_key,
|
||||
upgrade_hash=ext.upgrade_hash,
|
||||
next_run=time.time() + interval_seconds,
|
||||
)
|
||||
_register_schedule(task)
|
||||
await _persist_schedule(ext_id, task)
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/v1/schedule")
|
||||
async def api_schedule_task_delete(
|
||||
payload: dict, user: User = Depends(check_user_exists)
|
||||
):
|
||||
handler = payload.get("handler")
|
||||
store_key = payload.get("store_key")
|
||||
await _require_permission(user.id, ext_id, "ext.tasks.schedule")
|
||||
_remove_schedule_entries(ext_id, user.id, handler, store_key)
|
||||
await _remove_persisted_schedule_entries(ext_id, user.id, handler, store_key)
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/api/v1/sql/query")
|
||||
async def api_sql_query(
|
||||
payload: dict, user: User = Depends(check_user_exists)
|
||||
) -> dict:
|
||||
sql = payload.get("sql")
|
||||
params = payload.get("params") or {}
|
||||
if not sql:
|
||||
raise HTTPException(400, "Missing sql")
|
||||
await _require_permission(user.id, ext_id, "ext.db.sql")
|
||||
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
|
||||
_validate_sql(ext_id, sql, read_only=True)
|
||||
rows = await db.fetchall(sql, params) # noqa: S608
|
||||
return {"rows": rows}
|
||||
|
||||
@router.post("/api/v1/sql/exec")
|
||||
async def api_sql_exec(
|
||||
payload: dict, user: User = Depends(check_user_exists)
|
||||
) -> dict:
|
||||
sql = payload.get("sql")
|
||||
params = payload.get("params") or {}
|
||||
if not sql:
|
||||
raise HTTPException(400, "Missing sql")
|
||||
await _require_permission(user.id, ext_id, "ext.db.sql")
|
||||
_check_quota(user.id, ext_id, "db", settings.lnbits_wasm_max_db_ops_per_min)
|
||||
_validate_sql(ext_id, sql, read_only=False)
|
||||
await db.execute(sql, params) # noqa: S608
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _start_payment_watch(
|
||||
ext_id: str,
|
||||
@@ -575,6 +1251,8 @@ def register_wasm_ext_routes(app, ext) -> None:
|
||||
|
||||
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
|
||||
app.include_router(router, prefix=prefix)
|
||||
asyncio.create_task(restore_tag_watches(ext_id, ext.upgrade_hash))
|
||||
asyncio.create_task(restore_schedules(ext_id, ext.upgrade_hash))
|
||||
|
||||
|
||||
_quota_events: dict[tuple[str, str, str], list[float]] = {}
|
||||
|
||||
@@ -31,7 +31,10 @@ window.PageExtensions = {
|
||||
permissionsDialog: {
|
||||
show: false,
|
||||
extension: null,
|
||||
checked: []
|
||||
checked: [],
|
||||
missing: [],
|
||||
tags: [],
|
||||
tagOptions: []
|
||||
},
|
||||
reviewsDialog: {
|
||||
show: false,
|
||||
@@ -103,6 +106,12 @@ window.PageExtensions = {
|
||||
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: {
|
||||
@@ -274,12 +283,17 @@ window.PageExtensions = {
|
||||
permissions ||
|
||||
extension._grantedPermissions ||
|
||||
(extension.permissions ? extension.permissions.map(p => p.id) : [])
|
||||
const tags =
|
||||
extension._grantedPaymentTags ||
|
||||
(extension.paymentTags ? extension.paymentTags.slice() : [])
|
||||
LNbits.api
|
||||
.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${extension.id}/enable`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
granted.length ? {permissions: granted} : null
|
||||
granted.length || tags.length
|
||||
? {permissions: granted, payment_tags: tags}
|
||||
: null
|
||||
)
|
||||
.then(response => {
|
||||
this.g.user.extensions = this.g.user.extensions.concat([extension.id])
|
||||
@@ -326,15 +340,25 @@ window.PageExtensions = {
|
||||
},
|
||||
openPermissionsDialog(extension) {
|
||||
this.permissionsDialog.extension = extension
|
||||
this.permissionsDialog.checked = []
|
||||
this.permissionsDialog.checked = extension._grantedPermissions
|
||||
? extension._grantedPermissions.slice()
|
||||
: []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = 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 = []
|
||||
},
|
||||
openPermissionsForExtension(extension) {
|
||||
async openPermissionsForExtension(extension) {
|
||||
if (!extension.permissions || !extension.permissions.length) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
@@ -344,22 +368,53 @@ window.PageExtensions = {
|
||||
}
|
||||
this.permissionsDialog.extension = extension
|
||||
this.permissionsDialog.checked = []
|
||||
this.permissionsDialog.missing = []
|
||||
this.permissionsDialog.tags = []
|
||||
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._grantedPaymentTags = tags
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${ext.id}/permissions`,
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{permissions: granted}
|
||||
{permissions: granted, payment_tags: tags}
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
@@ -911,6 +966,9 @@ window.PageExtensions = {
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -954,6 +954,31 @@
|
||||
</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 &&
|
||||
@@ -974,6 +999,22 @@
|
||||
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
|
||||
@@ -984,7 +1025,7 @@
|
||||
></q-btn>
|
||||
<q-btn
|
||||
color="primary"
|
||||
:disable="!permissionsAllChecked"
|
||||
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
|
||||
label="Save"
|
||||
@click="confirmPermissionsDialog"
|
||||
></q-btn>
|
||||
|
||||
Reference in New Issue
Block a user