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_for_paid_invoices,
|
||||||
wait_notification_messages,
|
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.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
|
||||||
@@ -502,6 +506,10 @@ def register_async_tasks() -> None:
|
|||||||
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(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(run_by_the_minute_tasks)
|
||||||
create_permanent_task(purge_audit_data)
|
create_permanent_task(purge_audit_data)
|
||||||
create_permanent_task(collect_exchange_rates_data)
|
create_permanent_task(collect_exchange_rates_data)
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ class ExtensionConfig(BaseModel):
|
|||||||
extension_type: str | None = "python"
|
extension_type: str | None = "python"
|
||||||
public_kv_keys: list[str] = []
|
public_kv_keys: list[str] = []
|
||||||
public_wasm_functions: 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)
|
||||||
@@ -120,6 +121,7 @@ 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_permissions: list[str] | None = None
|
||||||
|
granted_payment_tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExtensionPermission(BaseModel):
|
class ExtensionPermission(BaseModel):
|
||||||
@@ -131,6 +133,7 @@ class ExtensionPermission(BaseModel):
|
|||||||
|
|
||||||
class ExtensionPermissionsGrant(BaseModel):
|
class ExtensionPermissionsGrant(BaseModel):
|
||||||
permissions: list[str] = []
|
permissions: list[str] = []
|
||||||
|
payment_tags: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class UserExtension(BaseModel):
|
class UserExtension(BaseModel):
|
||||||
@@ -166,6 +169,7 @@ class Extension(BaseModel):
|
|||||||
extension_type: str | None = None
|
extension_type: str | None = None
|
||||||
public_kv_keys: list[str] = []
|
public_kv_keys: list[str] = []
|
||||||
public_wasm_functions: list[str] = []
|
public_wasm_functions: list[str] = []
|
||||||
|
payment_tags: list[str] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def module_name(self) -> str:
|
def module_name(self) -> str:
|
||||||
@@ -194,6 +198,7 @@ class Extension(BaseModel):
|
|||||||
public_wasm_functions=ext_info.meta.public_wasm_functions
|
public_wasm_functions=ext_info.meta.public_wasm_functions
|
||||||
if ext_info.meta
|
if ext_info.meta
|
||||||
else [],
|
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"
|
extension_type: str | None = "python"
|
||||||
public_kv_keys: list[str] = []
|
public_kv_keys: list[str] = []
|
||||||
public_wasm_functions: 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
|
||||||
@@ -489,6 +495,7 @@ class InstallableExtension(BaseModel):
|
|||||||
self.meta.public_wasm_functions = config_json.get(
|
self.meta.public_wasm_functions = config_json.get(
|
||||||
"public_wasm_functions", []
|
"public_wasm_functions", []
|
||||||
)
|
)
|
||||||
|
self.meta.payment_tags = config_json.get("payment_tags", [])
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self.meta
|
self.meta
|
||||||
@@ -611,6 +618,7 @@ class InstallableExtension(BaseModel):
|
|||||||
extension_type=config.extension_type,
|
extension_type=config.extension_type,
|
||||||
public_kv_keys=config.public_kv_keys,
|
public_kv_keys=config.public_kv_keys,
|
||||||
public_wasm_functions=config.public_wasm_functions,
|
public_wasm_functions=config.public_wasm_functions,
|
||||||
|
payment_tags=config.payment_tags,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -663,6 +671,7 @@ class InstallableExtension(BaseModel):
|
|||||||
public_wasm_functions=config_json.get(
|
public_wasm_functions=config_json.get(
|
||||||
"public_wasm_functions", []
|
"public_wasm_functions", []
|
||||||
),
|
),
|
||||||
|
payment_tags=config_json.get("payment_tags", []),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from lnbits.core.crud.extensions import (
|
|||||||
update_installed_extension,
|
update_installed_extension,
|
||||||
)
|
)
|
||||||
from lnbits.core.helpers import migrate_extension_database
|
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.db import Connection, Database, COCKROACH, POSTGRES
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
@@ -75,6 +79,8 @@ 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":
|
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)
|
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)
|
||||||
@@ -101,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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import httpx
|
|||||||
from bolt11 import decode as bolt11_decode
|
from bolt11 import decode as bolt11_decode
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||||
from fastapi.requests import Request
|
from fastapi.requests import Request
|
||||||
|
from starlette.routing import Match
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extensions
|
from lnbits.core.crud.extensions import get_user_extensions
|
||||||
@@ -34,6 +35,7 @@ from lnbits.core.models.extensions import (
|
|||||||
UserExtension,
|
UserExtension,
|
||||||
UserExtensionInfo,
|
UserExtensionInfo,
|
||||||
)
|
)
|
||||||
|
from lnbits.core.wasm import WASM_HOST_MANIFEST
|
||||||
from lnbits.core.models.users import Account, AccountId
|
from lnbits.core.models.users import Account, AccountId
|
||||||
from lnbits.core.services import check_transaction_status, create_invoice
|
from lnbits.core.services import check_transaction_status, create_invoice
|
||||||
from lnbits.core.services.extensions import (
|
from lnbits.core.services.extensions import (
|
||||||
@@ -44,6 +46,10 @@ from lnbits.core.services.extensions import (
|
|||||||
install_extension,
|
install_extension,
|
||||||
uninstall_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.db import Page
|
||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
check_account_exists,
|
check_account_exists,
|
||||||
@@ -179,6 +185,7 @@ 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,
|
ext_id: str,
|
||||||
|
request: Request,
|
||||||
account_id: AccountId = Depends(check_account_id_exists),
|
account_id: AccountId = Depends(check_account_id_exists),
|
||||||
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
@@ -190,10 +197,19 @@ async def api_enable_extension(
|
|||||||
|
|
||||||
required_permissions = _get_required_permissions(ext_id, ext)
|
required_permissions = _get_required_permissions(ext_id, ext)
|
||||||
granted_permissions = _get_granted_permissions(grant, user_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)
|
_ensure_permissions(required_permissions, granted_permissions)
|
||||||
|
|
||||||
if grant and grant.permissions:
|
if grant and grant.permissions:
|
||||||
await _store_granted_permissions(user_ext, granted_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:
|
if account_id.is_admin_id or not ext.requires_payment:
|
||||||
await _activate_user_extension(user_ext)
|
await _activate_user_extension(user_ext)
|
||||||
@@ -245,6 +261,25 @@ def _get_granted_permissions(
|
|||||||
return []
|
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:
|
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
|
||||||
if not required:
|
if not required:
|
||||||
return
|
return
|
||||||
@@ -265,6 +300,15 @@ async def _store_granted_permissions(
|
|||||||
await update_user_extension(user_ext)
|
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:
|
async def _activate_user_extension(user_ext: UserExtension) -> None:
|
||||||
user_ext.active = True
|
user_ext.active = True
|
||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
@@ -318,13 +362,20 @@ async def api_disable_extension(
|
|||||||
user_ext.active = False
|
user_ext.active = False
|
||||||
if user_ext.extra and user_ext.extra.granted_permissions:
|
if user_ext.extra and user_ext.extra.granted_permissions:
|
||||||
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")
|
@extension_router.put("/{ext_id}/permissions")
|
||||||
async def api_update_extension_permissions(
|
async def api_update_extension_permissions(
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
|
request: Request,
|
||||||
account_id: AccountId = Depends(check_account_id_exists),
|
account_id: AccountId = Depends(check_account_id_exists),
|
||||||
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
grant: ExtensionPermissionsGrant | None = Body(default=None),
|
||||||
) -> SimpleStatus:
|
) -> SimpleStatus:
|
||||||
@@ -353,6 +404,14 @@ async def api_update_extension_permissions(
|
|||||||
[p.id for p in permissions_source] if permissions_source else []
|
[p.id for p in permissions_source] if permissions_source else []
|
||||||
)
|
)
|
||||||
granted_permissions = grant.permissions if grant and grant.permissions 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:
|
if required_permissions:
|
||||||
missing = [p for p in required_permissions if p not in granted_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 = user_ext.extra or UserExtensionInfo()
|
||||||
user_ext_info.granted_permissions = granted_permissions
|
user_ext_info.granted_permissions = granted_permissions
|
||||||
|
user_ext_info.granted_payment_tags = granted_tags
|
||||||
user_ext.extra = user_ext_info
|
user_ext.extra = user_ext_info
|
||||||
await update_user_extension(user_ext)
|
await update_user_extension(user_ext)
|
||||||
|
|
||||||
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
|
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:
|
||||||
@@ -681,6 +782,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
|||||||
e.icon = installed_ext.icon
|
e.icon = installed_ext.icon
|
||||||
if e.meta and not e.meta.permissions:
|
if e.meta and not e.meta.permissions:
|
||||||
e.meta.permissions = _load_permissions_from_config(e.id)
|
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 = [
|
||||||
{
|
{
|
||||||
@@ -703,12 +806,22 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
|||||||
if ext.meta and ext.meta.permissions
|
if ext.meta and ext.meta.permissions
|
||||||
else [dict(p) for p in _load_permissions_from_config(ext.id)]
|
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),
|
"kvSchema": _load_kv_schema_from_config(ext.id),
|
||||||
"grantedPermissions": (
|
"grantedPermissions": (
|
||||||
user_ext.extra.granted_permissions
|
user_ext.extra.granted_permissions
|
||||||
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
|
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
|
||||||
else []
|
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
|
||||||
@@ -766,6 +879,126 @@ def _load_kv_schema_from_config(ext_id: str) -> dict:
|
|||||||
return {}
|
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(
|
@extension_router.get(
|
||||||
"/reviews/tags",
|
"/reviews/tags",
|
||||||
dependencies=[Depends(check_account_exists)],
|
dependencies=[Depends(check_account_exists)],
|
||||||
|
|||||||
@@ -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,28 @@ 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:
|
||||||
|
|||||||
@@ -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 re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -14,6 +15,8 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from lnbits.core.crud.extensions import get_user_extension
|
from lnbits.core.crud.extensions import get_user_extension
|
||||||
from lnbits.core.models import User
|
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.crud.payments import get_standalone_payment, update_payment
|
||||||
from lnbits.core.services import websocket_updater
|
from lnbits.core.services import websocket_updater
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -26,6 +29,512 @@ from lnbits.tasks import register_invoice_listener, unregister_invoice_listener
|
|||||||
from .service import WasmExecutionError, wasm_call
|
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):
|
def _renderer(ext_id: str):
|
||||||
return template_renderer([f"{ext_id}/templates"])
|
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
|
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):
|
def _coerce_schema_value(schema_entry: dict, value):
|
||||||
value_type = schema_entry.get("type", "string")
|
value_type = schema_entry.get("type", "string")
|
||||||
if value_type == "int":
|
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}")
|
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:
|
def _register_pages_routes(router: APIRouter, ext_id: str) -> None:
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
async def index(req: Request, user: User = Depends(check_user_exists)):
|
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:
|
def _register_kv_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
||||||
_register_kv_read_routes(router, ext_id, db, ext)
|
_register_kv_read_routes(router, ext_id, db, ext)
|
||||||
_register_kv_write_routes(router, ext_id, db)
|
_register_kv_write_routes(router, ext_id, db)
|
||||||
_register_kv_increment_route(router, ext_id, db, ext)
|
|
||||||
_register_secret_routes(router, ext_id, db)
|
_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}
|
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:
|
def _register_watch_routes(router: APIRouter, ext_id: str, db: Database, ext) -> None:
|
||||||
@router.post("/api/v1/watch")
|
@router.post("/api/v1/watch")
|
||||||
async def api_watch_payment(payload: dict, user: User = Depends(check_user_exists)):
|
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:
|
if not payment_hash:
|
||||||
raise HTTPException(400, "Missing payment_hash")
|
raise HTTPException(400, "Missing payment_hash")
|
||||||
await _require_permission(user.id, ext_id, "ext.payments.watch")
|
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")
|
await _require_permission(user.id, ext_id, "ext.db.read_write")
|
||||||
task = _start_payment_watch(
|
task = _start_payment_watch(
|
||||||
ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash
|
ext_id, db, payment_hash, handler, tag, store_key, ext.upgrade_hash
|
||||||
)
|
)
|
||||||
return {"ok": True, "task_id": id(task)}
|
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(
|
def _start_payment_watch(
|
||||||
ext_id: str,
|
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 ""
|
prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash else ""
|
||||||
app.include_router(router, prefix=prefix)
|
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]] = {}
|
_quota_events: dict[tuple[str, str, str], list[float]] = {}
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ window.PageExtensions = {
|
|||||||
permissionsDialog: {
|
permissionsDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
extension: null,
|
extension: null,
|
||||||
checked: []
|
checked: [],
|
||||||
|
missing: [],
|
||||||
|
tags: [],
|
||||||
|
tagOptions: []
|
||||||
},
|
},
|
||||||
reviewsDialog: {
|
reviewsDialog: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -103,6 +106,12 @@ window.PageExtensions = {
|
|||||||
if (!ext || !Array.isArray(ext.permissions)) return true
|
if (!ext || !Array.isArray(ext.permissions)) return true
|
||||||
const required = ext.permissions.map(p => p.id)
|
const required = ext.permissions.map(p => p.id)
|
||||||
return required.every(p => this.permissionsDialog.checked.includes(p))
|
return required.every(p => this.permissionsDialog.checked.includes(p))
|
||||||
|
},
|
||||||
|
permissionsHasMissingEndpoints() {
|
||||||
|
return (
|
||||||
|
this.permissionsDialog.missing &&
|
||||||
|
this.permissionsDialog.missing.length > 0
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -274,12 +283,17 @@ window.PageExtensions = {
|
|||||||
permissions ||
|
permissions ||
|
||||||
extension._grantedPermissions ||
|
extension._grantedPermissions ||
|
||||||
(extension.permissions ? extension.permissions.map(p => p.id) : [])
|
(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 ? {permissions: granted} : null
|
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])
|
||||||
@@ -326,15 +340,25 @@ window.PageExtensions = {
|
|||||||
},
|
},
|
||||||
openPermissionsDialog(extension) {
|
openPermissionsDialog(extension) {
|
||||||
this.permissionsDialog.extension = 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
|
this.permissionsDialog.show = true
|
||||||
},
|
},
|
||||||
cancelPermissionsDialog() {
|
cancelPermissionsDialog() {
|
||||||
this.permissionsDialog.show = false
|
this.permissionsDialog.show = false
|
||||||
this.permissionsDialog.extension = null
|
this.permissionsDialog.extension = null
|
||||||
this.permissionsDialog.checked = []
|
this.permissionsDialog.checked = []
|
||||||
|
this.permissionsDialog.missing = []
|
||||||
|
this.permissionsDialog.tags = []
|
||||||
|
this.permissionsDialog.tagOptions = []
|
||||||
},
|
},
|
||||||
openPermissionsForExtension(extension) {
|
async openPermissionsForExtension(extension) {
|
||||||
if (!extension.permissions || !extension.permissions.length) {
|
if (!extension.permissions || !extension.permissions.length) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -344,22 +368,53 @@ window.PageExtensions = {
|
|||||||
}
|
}
|
||||||
this.permissionsDialog.extension = extension
|
this.permissionsDialog.extension = extension
|
||||||
this.permissionsDialog.checked = []
|
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
|
this.permissionsDialog.show = true
|
||||||
},
|
},
|
||||||
async confirmPermissionsDialog() {
|
async confirmPermissionsDialog() {
|
||||||
const ext = this.permissionsDialog.extension
|
const ext = this.permissionsDialog.extension
|
||||||
const granted = this.permissionsDialog.checked.slice()
|
const granted = this.permissionsDialog.checked.slice()
|
||||||
|
const tags = this.permissionsDialog.tags.slice()
|
||||||
this.permissionsDialog.show = false
|
this.permissionsDialog.show = false
|
||||||
this.permissionsDialog.extension = null
|
this.permissionsDialog.extension = null
|
||||||
this.permissionsDialog.checked = []
|
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
|
if (!ext) return
|
||||||
ext._grantedPermissions = granted
|
ext._grantedPermissions = granted
|
||||||
|
ext._grantedPaymentTags = tags
|
||||||
try {
|
try {
|
||||||
await LNbits.api.request(
|
await LNbits.api.request(
|
||||||
'PUT',
|
'PUT',
|
||||||
`/api/v1/extension/${ext.id}/permissions`,
|
`/api/v1/extension/${ext.id}/permissions`,
|
||||||
this.g.user.wallets[0].adminkey,
|
this.g.user.wallets[0].adminkey,
|
||||||
{permissions: granted}
|
{permissions: granted, payment_tags: tags}
|
||||||
)
|
)
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
@@ -911,6 +966,9 @@ window.PageExtensions = {
|
|||||||
if (ext.grantedPermissions && ext.grantedPermissions.length) {
|
if (ext.grantedPermissions && ext.grantedPermissions.length) {
|
||||||
ext._grantedPermissions = ext.grantedPermissions
|
ext._grantedPermissions = ext.grantedPermissions
|
||||||
}
|
}
|
||||||
|
if (ext.grantedPaymentTags && ext.grantedPaymentTags.length) {
|
||||||
|
ext._grantedPaymentTags = ext.grantedPaymentTags
|
||||||
|
}
|
||||||
const schema = ext.kvSchema || ext.kv_schema || {}
|
const schema = ext.kvSchema || ext.kv_schema || {}
|
||||||
ext.kvSchemaKeys = Object.keys(schema)
|
ext.kvSchemaKeys = Object.keys(schema)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -954,6 +954,31 @@
|
|||||||
</q-item-section>
|
</q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</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
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
permissionsDialog.extension &&
|
permissionsDialog.extension &&
|
||||||
@@ -974,6 +999,22 @@
|
|||||||
class="q-mr-xs q-mt-xs"
|
class="q-mr-xs q-mt-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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-section>
|
||||||
<q-card-actions align="right">
|
<q-card-actions align="right">
|
||||||
<q-btn
|
<q-btn
|
||||||
@@ -984,7 +1025,7 @@
|
|||||||
></q-btn>
|
></q-btn>
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
:disable="!permissionsAllChecked"
|
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
|
||||||
label="Save"
|
label="Save"
|
||||||
@click="confirmPermissionsDialog"
|
@click="confirmPermissionsDialog"
|
||||||
></q-btn>
|
></q-btn>
|
||||||
|
|||||||
Reference in New Issue
Block a user