Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53c1662eda | ||
|
|
f98b78f436 | ||
|
|
92570c80ed | ||
|
|
120cf735c8 | ||
|
|
4f072a6247 | ||
|
|
15d26093a5 | ||
|
|
62acfd2ea4 | ||
|
|
81c2d89e93 | ||
|
|
e23c945025 | ||
|
|
b5558d0867 | ||
|
|
40fc9d44f6 | ||
|
|
cd068f029b | ||
|
|
22eb50543a | ||
|
|
757271bb95 | ||
|
|
be8094fb9b | ||
|
|
a8629a0550 | ||
|
|
695aad0ddc | ||
|
|
0af4351380 | ||
|
|
9ec0a1232c | ||
|
|
76a8e5acfc | ||
|
|
ca55b07467 | ||
|
|
a2e0b39a28 | ||
|
|
57fc2e54f3 | ||
|
|
101620f682 | ||
|
|
3b3ad4c7f8 | ||
|
|
6ad5cca4f4 | ||
|
|
1c9a416994 | ||
|
|
bfe1da5fc8 | ||
|
|
6702c1606d | ||
|
|
a1ca1fe578 | ||
|
|
18c11c0ef7 | ||
|
|
1a79722514 | ||
|
|
d6097a68a0 | ||
|
|
502d799b78 | ||
|
|
38dfd1178e | ||
|
|
16c705e4b6 | ||
|
|
d32291fea6 | ||
|
|
77df764637 | ||
|
|
b3421bad51 | ||
|
|
3efb7a7e6b | ||
|
|
f839eaef6d | ||
|
|
7aee755d4d | ||
|
|
d1336e11df | ||
|
|
4e64c1180f | ||
|
|
e024868b3b | ||
|
|
6d71526b88 | ||
|
|
1f3d141f49 | ||
|
|
c33d1e8c55 | ||
|
|
3eadf489fb | ||
|
|
762863b285 | ||
|
|
dcb740a48d | ||
|
|
e6662e041e | ||
|
|
a3dafe6644 | ||
|
|
160a6d2365 | ||
|
|
b6347f69e2 | ||
|
|
3fdf22a917 | ||
|
|
dd8e3c3350 | ||
|
|
31ba0952a8 | ||
|
|
65ac43c85e | ||
|
|
472679f9ed | ||
|
|
a099d9f37a | ||
|
|
8457c3f298 | ||
|
|
e83fc8cb86 | ||
|
|
710464c05e | ||
|
|
9914c6975f | ||
|
|
8114beaeec | ||
|
|
bc6a1adefd | ||
|
|
1b044e2d8d | ||
|
|
ea449bdae5 | ||
|
|
8dce327841 | ||
|
|
f5caaf1e6b | ||
|
|
fc33e73d12 | ||
|
|
dc1370993a | ||
|
|
aed51cafe8 | ||
|
|
4f1d8bbfe3 | ||
|
|
5f5140c603 | ||
|
|
ab31dd7f02 | ||
|
|
b2be906fb2 | ||
|
|
9a533ae71d | ||
|
|
fd72a8ac78 | ||
|
|
631e6d0cb0 | ||
|
|
96bd9a373d | ||
|
|
32f2070f17 | ||
|
|
d0ed95fe42 | ||
|
|
d591dc65a1 | ||
|
|
372c96cf06 | ||
|
|
d33b24f131 | ||
|
|
f563c232ad | ||
|
|
850e1c72f6 | ||
|
|
b9bcddc0b8 | ||
|
|
0c7317a701 | ||
|
|
b167647b74 | ||
|
|
cebc9a5cc0 | ||
|
|
5d265c61cc | ||
|
|
902060ab5b | ||
|
|
4d936b7686 | ||
|
|
0696f9cd1f | ||
|
|
2cca56aa21 |
@@ -7,6 +7,10 @@ on:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -14,6 +18,10 @@ on:
|
||||
description: 'The tag name for the release'
|
||||
required: true
|
||||
type: string
|
||||
upload_url:
|
||||
description: 'The upload URL for the release'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux-package:
|
||||
@@ -105,6 +113,11 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Upload Linux Release Asset
|
||||
uses: actions/upload-release-asset@v1
|
||||
with:
|
||||
upload_url: ${{ inputs.upload_url }}
|
||||
asset_path: ${{ env.APPIMAGE_NAME }}
|
||||
asset_name: ${{ env.APPIMAGE_NAME }}
|
||||
asset_content_type: application/octet-stream
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "${{ inputs.tag_name }}" "${{ env.APPIMAGE_NAME }}" --clobber
|
||||
|
||||
@@ -64,6 +64,13 @@ jobs:
|
||||
with:
|
||||
make: openapi
|
||||
|
||||
test-wasm-e2e:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/make.yml
|
||||
with:
|
||||
make: test-wasm-e2e
|
||||
playwright-browser: chromium
|
||||
|
||||
regtest:
|
||||
needs: [ lint ]
|
||||
uses: ./.github/workflows/regtest.yml
|
||||
@@ -95,5 +102,5 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
bundle:
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, regtest, jmeter ]
|
||||
needs: [ lint, test-api, test-wallets, test-unit, migration, openapi, test-wasm-e2e, regtest, jmeter ]
|
||||
uses: ./.github/workflows/bundle.yml
|
||||
|
||||
@@ -15,6 +15,10 @@ on:
|
||||
description: "python version"
|
||||
type: string
|
||||
default: "3.12"
|
||||
playwright-browser:
|
||||
description: "Playwright browser to install before running make"
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
make:
|
||||
@@ -31,4 +35,7 @@ jobs:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ matrix.node-version }}
|
||||
npm: ${{ inputs.npm }}
|
||||
- name: Install Playwright browser
|
||||
if: ${{ inputs.playwright-browser != '' }}
|
||||
run: uv run playwright install --with-deps ${{ inputs.playwright-browser }}
|
||||
- run: make ${{ inputs.make }}
|
||||
|
||||
@@ -12,6 +12,8 @@ jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github pre-release
|
||||
@@ -20,6 +22,14 @@ jobs:
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --prerelease --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
@@ -64,3 +74,4 @@ jobs:
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -13,6 +13,8 @@ jobs:
|
||||
|
||||
release:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
upload_url: ${{ steps.get_upload_url.outputs.upload_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Create github release
|
||||
@@ -21,6 +23,14 @@ jobs:
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "$tag" --generate-notes --draft
|
||||
- id: get_upload_url
|
||||
name: Get upload url of Github release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
run: |
|
||||
upload_url=$(gh release view "$tag" --json uploadUrl -q ".uploadUrl")
|
||||
echo "upload_url=$upload_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'lnbits/lnbits'
|
||||
@@ -75,3 +85,4 @@ jobs:
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -16,3 +16,5 @@
|
||||
flake.lock
|
||||
|
||||
.venv
|
||||
|
||||
tests/fixtures/lnbits-wasm-test-extension/static/html-like.js
|
||||
|
||||
@@ -62,6 +62,12 @@ test-api:
|
||||
DEBUG=true \
|
||||
uv run pytest tests/api
|
||||
|
||||
test-wasm-e2e:
|
||||
LNBITS_BACKEND_WALLET_CLASS="FakeWallet" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBUG=true \
|
||||
uv run pytest tests/wasm_ext --browser chromium
|
||||
|
||||
test-regtest:
|
||||
LNBITS_DATA_FOLDER="./tests/data" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
|
||||
+34
-52
@@ -23,30 +23,20 @@ from lnbits.core.crud import (
|
||||
get_installed_extensions,
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.extensions import create_installed_extension
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.extensions import deactivate_extension, get_valid_extensions
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
dispatch_payment_notification,
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
check_pending_payments,
|
||||
fundingsource_invoice_producer,
|
||||
)
|
||||
from lnbits.core.services.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
from lnbits.core.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
notify_server_status,
|
||||
process_next_audit_entry,
|
||||
refresh_extension_cache,
|
||||
purge_audit_data,
|
||||
run_by_the_minute_tasks,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
|
||||
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
|
||||
@@ -57,6 +47,11 @@ from lnbits.exceptions import register_exception_handlers
|
||||
from lnbits.helpers import version_parse
|
||||
from lnbits.llms_txt import create_llms_txt_route
|
||||
from lnbits.settings import settings
|
||||
from lnbits.tasks import (
|
||||
cancel_all_tasks,
|
||||
create_permanent_task,
|
||||
register_invoice_listener,
|
||||
)
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.logger import (
|
||||
configure_logger,
|
||||
@@ -79,7 +74,7 @@ from .middleware import (
|
||||
add_profiler_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .task_manager import task_manager
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
@@ -143,7 +138,7 @@ async def shutdown():
|
||||
settings.lnbits_running = False
|
||||
|
||||
# shutdown event
|
||||
task_manager.cancel_all_tasks()
|
||||
cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -495,47 +490,34 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
task_manager.init()
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
|
||||
# listen to all incoming payments and dispatch payment notifications
|
||||
# note: should be the first in task list for a bit quicker notifications
|
||||
task_manager.register_invoice_listener(dispatch_payment_notification, "core")
|
||||
create_permanent_task(
|
||||
run_interval(
|
||||
settings.lnbits_funding_source_pending_interval_seconds,
|
||||
check_pending_payments,
|
||||
)
|
||||
)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
|
||||
# periodic tasks
|
||||
task_manager.create_permanent_task(cache.invalidate_cache, interval=10)
|
||||
task_manager.create_permanent_task(delete_expired_audit_entries, interval=60 * 60)
|
||||
task_manager.create_permanent_task(
|
||||
check_pending_payments,
|
||||
interval=settings.lnbits_funding_source_pending_interval_seconds,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
collect_exchange_rates_data,
|
||||
interval=max(60, settings.lnbits_exchange_history_refresh_interval_seconds),
|
||||
)
|
||||
task_manager.create_permanent_task(check_balance_delta_changed, interval=60)
|
||||
task_manager.create_permanent_task(
|
||||
check_server_balance_against_node,
|
||||
interval=60 * settings.lnbits_watchdog_interval_minutes,
|
||||
)
|
||||
task_manager.create_permanent_task(
|
||||
notify_server_status,
|
||||
interval=60 * 60 * settings.lnbits_notification_server_status_hours,
|
||||
)
|
||||
task_manager.create_permanent_task(refresh_extension_cache, interval=60)
|
||||
|
||||
# permanent tasks run in a loop, will be restarted if they fail
|
||||
task_manager.create_permanent_task(fundingsource_invoice_producer)
|
||||
task_manager.create_permanent_task(process_next_notification)
|
||||
task_manager.create_permanent_task(process_next_audit_entry)
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
|
||||
async def dispatch_extension_invoice_paid(payment) -> None:
|
||||
await dispatch_wasm_invoice_paid(payment)
|
||||
|
||||
core_app_extra.dispatch_extension_invoice_paid = dispatch_extension_invoice_paid
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
task_manager.create_permanent_task(
|
||||
server_log_task, name="server_websocket_logger"
|
||||
)
|
||||
create_permanent_task(server_log_task)
|
||||
|
||||
@@ -18,7 +18,6 @@ from .extensions import (
|
||||
get_user_extensions,
|
||||
update_installed_extension,
|
||||
update_installed_extension_state,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_user_extension,
|
||||
)
|
||||
from .payments import (
|
||||
@@ -157,7 +156,6 @@ __all__ = [
|
||||
"update_admin_settings",
|
||||
"update_installed_extension",
|
||||
"update_installed_extension_state",
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
"update_migration_version",
|
||||
"update_payment",
|
||||
"update_payment_checking_id",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
UserExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
from lnbits.db import Connection, Database
|
||||
|
||||
@@ -16,11 +11,6 @@ async def create_installed_extension(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension(
|
||||
@@ -28,11 +18,6 @@ async def update_installed_extension(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("installed_extensions", ext)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext.id,
|
||||
limits=ext.wasm_runtime_limits,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_state(
|
||||
@@ -46,33 +31,6 @@ async def update_installed_extension_state(
|
||||
)
|
||||
|
||||
|
||||
async def update_installed_extension_wasm_runtime_limits(
|
||||
*, ext_id: str, limits: dict, conn: Connection | None = None
|
||||
) -> None:
|
||||
if not await _has_installed_extension_wasm_runtime_limits_column(conn=conn):
|
||||
return
|
||||
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE installed_extensions
|
||||
SET wasm_runtime_limits = :limits
|
||||
WHERE id = :id
|
||||
""",
|
||||
{"id": ext_id, "limits": json.dumps(limits)},
|
||||
)
|
||||
|
||||
|
||||
async def _has_installed_extension_wasm_runtime_limits_column(
|
||||
conn: Connection | None = None,
|
||||
) -> bool:
|
||||
row: dict | None = await (conn or db).fetchone(
|
||||
"SELECT version FROM dbversions WHERE db = 'core'"
|
||||
)
|
||||
if not row:
|
||||
return False
|
||||
return int(row["version"] or 0) >= 48
|
||||
|
||||
|
||||
async def delete_installed_extension(
|
||||
*, ext_id: str, conn: Connection | None = None
|
||||
) -> None:
|
||||
@@ -186,154 +144,3 @@ async def get_user_active_extensions_ids(
|
||||
UserExtension,
|
||||
)
|
||||
return [ext.extension for ext in exts]
|
||||
|
||||
|
||||
async def create_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).insert("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def update_wasm_invocation(
|
||||
invocation: WasmInvocation,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).update("wasm_invocations", invocation)
|
||||
|
||||
|
||||
async def get_wasm_invocation(
|
||||
invocation_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocation | None:
|
||||
return await (conn or db).fetchone(
|
||||
"SELECT * FROM wasm_invocations WHERE id = :id",
|
||||
{"id": invocation_id},
|
||||
model=WasmInvocation,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocations(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
where: list[str] = []
|
||||
values: dict = {
|
||||
"limit": max(1, min(limit, 500)),
|
||||
"offset": max(offset, 0),
|
||||
}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if status:
|
||||
where.append("status = :status")
|
||||
values["status"] = status
|
||||
|
||||
query = "SELECT * FROM wasm_invocations"
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
query += " ORDER BY started_at DESC LIMIT :limit OFFSET :offset"
|
||||
|
||||
return await (conn or db).fetchall(query, values, model=WasmInvocation)
|
||||
|
||||
|
||||
async def get_running_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
return await get_wasm_invocations(status="running", conn=conn)
|
||||
|
||||
|
||||
async def get_wasm_invocation_stats(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
since: datetime | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> WasmInvocationStats:
|
||||
where: list[str] = []
|
||||
values: dict = {}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if since:
|
||||
where.append("started_at >= :since")
|
||||
values["since"] = since
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COALESCE(SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), 0)
|
||||
AS running,
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)
|
||||
AS completed,
|
||||
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0)
|
||||
AS failed,
|
||||
COALESCE(SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END), 0)
|
||||
AS stopped,
|
||||
COALESCE(SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END), 0)
|
||||
AS timeout,
|
||||
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
|
||||
COALESCE(MAX(duration_ms), 0) AS max_duration_ms,
|
||||
COALESCE(SUM(host_call_count), 0) AS host_call_count,
|
||||
COALESCE(SUM(http_call_count), 0) AS http_call_count,
|
||||
COALESCE(SUM(storage_call_count), 0) AS storage_call_count,
|
||||
COALESCE(SUM(wallet_call_count), 0) AS wallet_call_count
|
||||
FROM wasm_invocations
|
||||
"""
|
||||
if where:
|
||||
query += f" WHERE {' AND '.join(where)}"
|
||||
|
||||
row: dict | None = await (conn or db).fetchone(query, values)
|
||||
if not row:
|
||||
return WasmInvocationStats()
|
||||
|
||||
return WasmInvocationStats(
|
||||
total=int(row["total"] or 0),
|
||||
running=int(row["running"] or 0),
|
||||
completed=int(row["completed"] or 0),
|
||||
failed=int(row["failed"] or 0),
|
||||
stopped=int(row["stopped"] or 0),
|
||||
timeout=int(row["timeout"] or 0),
|
||||
avg_duration_ms=float(row["avg_duration_ms"] or 0),
|
||||
max_duration_ms=int(row["max_duration_ms"] or 0),
|
||||
host_call_count=int(row["host_call_count"] or 0),
|
||||
http_call_count=int(row["http_call_count"] or 0),
|
||||
storage_call_count=int(row["storage_call_count"] or 0),
|
||||
wallet_call_count=int(row["wallet_call_count"] or 0),
|
||||
)
|
||||
|
||||
|
||||
async def delete_old_wasm_invocations(
|
||||
retention_days: int,
|
||||
conn: Connection | None = None,
|
||||
) -> int:
|
||||
if retention_days <= 0:
|
||||
return 0
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
|
||||
result = await (conn or db).execute(
|
||||
"""
|
||||
DELETE FROM wasm_invocations
|
||||
WHERE status != 'running' AND started_at < :cutoff
|
||||
""",
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def mark_stale_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
await (conn or db).execute(
|
||||
"""
|
||||
UPDATE wasm_invocations
|
||||
SET status = 'abandoned',
|
||||
finished_at = :finished_at,
|
||||
stop_reason = 'Server restarted before invocation finished.'
|
||||
WHERE status = 'running'
|
||||
""",
|
||||
{"finished_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
@@ -8,18 +8,11 @@ from lnbits.db import dict_to_model
|
||||
from lnbits.settings import (
|
||||
AdminSettings,
|
||||
EditableSettings,
|
||||
FundingSourcesSettings,
|
||||
SettingsField,
|
||||
SuperSettings,
|
||||
settings,
|
||||
)
|
||||
|
||||
RESET_PRESERVED_SETTINGS = (
|
||||
"lnbits_webpush_pubkey",
|
||||
"lnbits_webpush_privkey",
|
||||
*FundingSourcesSettings.__fields__,
|
||||
)
|
||||
|
||||
|
||||
async def get_super_settings() -> SuperSettings | None:
|
||||
data = await get_settings_by_tag("core")
|
||||
@@ -76,14 +69,16 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
core_settings = await get_settings_by_tag("core") or {}
|
||||
super_user = await get_settings_field("super_user")
|
||||
await delete_admin_settings()
|
||||
if super_user:
|
||||
await set_settings_field("super_user", super_user.value)
|
||||
for field in RESET_PRESERVED_SETTINGS:
|
||||
if field in core_settings:
|
||||
await set_settings_field(field, core_settings[field])
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
|
||||
@@ -27,13 +27,7 @@ async def migrate_extension_database(
|
||||
if is_wasm_extension_id(ext.id):
|
||||
await migrate_wasm_extension_database(ext, current_version)
|
||||
return
|
||||
else:
|
||||
await migrate_py_extension_database(ext, current_version)
|
||||
|
||||
|
||||
async def migrate_py_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
try:
|
||||
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
|
||||
ext_db = importlib.import_module(ext.module_name).db
|
||||
|
||||
@@ -824,62 +824,3 @@ async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions ADD COLUMN permissions TEXT DEFAULT '[]'"
|
||||
)
|
||||
|
||||
|
||||
async def m047_create_wasm_invocations_table(db: Connection):
|
||||
"""
|
||||
Tracks WASM extension invocations for runtime monitoring and controls.
|
||||
"""
|
||||
await db.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS wasm_invocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
extension_id TEXT NOT NULL,
|
||||
export_name TEXT NOT NULL,
|
||||
trigger_type TEXT NOT NULL DEFAULT 'unknown',
|
||||
status TEXT NOT NULL DEFAULT 'running',
|
||||
started_at TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
finished_at TIMESTAMP,
|
||||
duration_ms INT,
|
||||
user_id TEXT,
|
||||
wallet_id TEXT,
|
||||
request_id TEXT,
|
||||
method TEXT,
|
||||
path TEXT,
|
||||
event_type TEXT,
|
||||
payment_hash TEXT,
|
||||
checking_id TEXT,
|
||||
memory_peak_bytes INT,
|
||||
request_bytes INT,
|
||||
response_bytes INT,
|
||||
host_call_count INT NOT NULL DEFAULT 0,
|
||||
http_call_count INT NOT NULL DEFAULT 0,
|
||||
storage_call_count INT NOT NULL DEFAULT 0,
|
||||
wallet_call_count INT NOT NULL DEFAULT 0,
|
||||
error_type TEXT,
|
||||
error_message TEXT,
|
||||
stop_reason TEXT,
|
||||
"context" TEXT NOT NULL DEFAULT '{{}}'
|
||||
);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_extension_started
|
||||
ON wasm_invocations (extension_id, started_at);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_status
|
||||
ON wasm_invocations (status);
|
||||
""")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_invocations_started
|
||||
ON wasm_invocations (started_at);
|
||||
""")
|
||||
|
||||
|
||||
async def m048_add_wasm_runtime_limits_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds per-extension WASM runtime limit overrides.
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE installed_extensions "
|
||||
"ADD COLUMN wasm_runtime_limits TEXT DEFAULT '{}'"
|
||||
)
|
||||
|
||||
@@ -6,14 +6,14 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, StrictStr
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -22,7 +22,6 @@ from lnbits.helpers import (
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
@@ -80,13 +79,11 @@ class GitHubRepo(BaseModel):
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: StrictStr
|
||||
description: StrictStr | None = None
|
||||
id: str
|
||||
label: str | None = None
|
||||
description: str | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
@@ -182,8 +179,6 @@ class Extension(BaseModel):
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
if self.is_wasm:
|
||||
return False
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
@@ -194,66 +189,17 @@ class Extension(BaseModel):
|
||||
is_wasm=ext_info.is_wasm,
|
||||
name=ext_info.name,
|
||||
short_description=ext_info.short_description,
|
||||
tile=_extension_tile(ext_info),
|
||||
tile=(
|
||||
wasm_extension_icon_url(ext_info.id)
|
||||
if ext_info.is_wasm
|
||||
else ext_info.icon
|
||||
),
|
||||
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
|
||||
)
|
||||
|
||||
|
||||
class WasmInvocation(BaseModel):
|
||||
id: str
|
||||
extension_id: str
|
||||
export_name: str
|
||||
trigger_type: str = "unknown"
|
||||
status: str = "running"
|
||||
started_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
finished_at: datetime | None = None
|
||||
duration_ms: int | None = None
|
||||
user_id: str | None = None
|
||||
wallet_id: str | None = None
|
||||
request_id: str | None = None
|
||||
method: str | None = None
|
||||
path: str | None = None
|
||||
event_type: str | None = None
|
||||
payment_hash: str | None = None
|
||||
checking_id: str | None = None
|
||||
memory_peak_bytes: int | None = None
|
||||
request_bytes: int | None = None
|
||||
response_bytes: int | None = None
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
error_type: str | None = None
|
||||
error_message: str | None = None
|
||||
stop_reason: str | None = None
|
||||
context: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmInvocationStats(BaseModel):
|
||||
total: int = 0
|
||||
running: int = 0
|
||||
completed: int = 0
|
||||
failed: int = 0
|
||||
stopped: int = 0
|
||||
timeout: int = 0
|
||||
avg_duration_ms: float = 0
|
||||
max_duration_ms: int = 0
|
||||
host_call_count: int = 0
|
||||
http_call_count: int = 0
|
||||
storage_call_count: int = 0
|
||||
wallet_call_count: int = 0
|
||||
|
||||
|
||||
class WasmRuntimeLimitsUpdate(BaseModel):
|
||||
limits: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmRuntimeLimitsInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
active: bool | None = False
|
||||
wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
effective_wasm_runtime_limits: dict[str, int] = Field(default_factory=dict)
|
||||
def wasm_extension_icon_url(ext_id: str) -> str:
|
||||
return f"/ext-assets/{ext_id}/assets/icon.png"
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
@@ -431,7 +377,6 @@ class InstallableExtension(BaseModel):
|
||||
stars: int = 0
|
||||
meta: ExtensionMeta | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
wasm_runtime_limits: dict = Field(default_factory=dict, no_database=True)
|
||||
|
||||
@property
|
||||
def hash(self) -> str:
|
||||
@@ -534,7 +479,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
config_name = _archive_config_name(archive.namelist())
|
||||
config_name = self._archive_config_name(archive.namelist())
|
||||
if not config_name:
|
||||
return {}
|
||||
with archive.open(config_name) as config_file:
|
||||
@@ -544,6 +489,14 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _archive_config_name(names: list[str]) -> str | None:
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if len(path.parts) == 2 and path.name == "config.json":
|
||||
return name
|
||||
return None
|
||||
|
||||
def extract_archive(self):
|
||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||
Path(settings.lnbits_extensions_upgrade_path).mkdir(parents=True, exist_ok=True)
|
||||
@@ -755,10 +708,7 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
if cache_value.older_than(10 * 60) or post_refresh_cache:
|
||||
# refresh cache in background if older than 10 minutes or requested
|
||||
task_manager.create_task(
|
||||
cls._refresh_installable_extensions_cache(),
|
||||
"refresh_installable_extensions_cache",
|
||||
)
|
||||
create_task(cls._refresh_installable_extensions_cache())
|
||||
|
||||
extension_list = cache_value.value # type: ignore
|
||||
return extension_list
|
||||
@@ -969,21 +919,3 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
|
||||
_, _, *rest = path.split("/")
|
||||
tail = "/".join(rest)
|
||||
return f"https://github.com/{source_repo}/raw/main/{tail}"
|
||||
|
||||
|
||||
def wasm_extension_icon_url(ext_id: str) -> str:
|
||||
return f"/ext-assets/{ext_id}/assets/icon.png"
|
||||
|
||||
|
||||
def _extension_tile(ext_info: InstallableExtension) -> str | None:
|
||||
if ext_info.is_wasm:
|
||||
return wasm_extension_icon_url(ext_info.id)
|
||||
return ext_info.icon
|
||||
|
||||
|
||||
def _archive_config_name(names: list[str]) -> str | None:
|
||||
for name in names:
|
||||
path = PurePosixPath(name)
|
||||
if len(path.parts) == 2 and path.name == "config.json":
|
||||
return name
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -32,16 +31,8 @@ class WasmExtensionRegistry:
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
def register(self, extension: Any) -> None:
|
||||
self.require_available(extension)
|
||||
self._extensions[extension.id] = extension
|
||||
|
||||
def require_available(self, extension: Any) -> None:
|
||||
existing = self._extensions.get(extension.id)
|
||||
if existing and not _same_wasm_extension_registration(existing, extension):
|
||||
raise ValueError(
|
||||
f"WASM extension id '{extension.id}' is already registered."
|
||||
)
|
||||
|
||||
def get(self, ext_id: str) -> Any | None:
|
||||
return self._extensions.get(ext_id)
|
||||
|
||||
@@ -49,13 +40,6 @@ class WasmExtensionRegistry:
|
||||
return list(self._extensions.values())
|
||||
|
||||
|
||||
def _same_wasm_extension_registration(left: Any, right: Any) -> bool:
|
||||
try:
|
||||
return Path(left.root_path).resolve() == Path(right.root_path).resolve()
|
||||
except (AttributeError, TypeError):
|
||||
return left is right
|
||||
|
||||
|
||||
class ConversionData(BaseModel):
|
||||
from_: str = "sat"
|
||||
amount: float
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import asyncio
|
||||
import importlib
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from threading import RLock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -20,168 +13,22 @@ from lnbits.core.crud import (
|
||||
update_installed_extension_state,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
create_wasm_invocation,
|
||||
delete_old_wasm_invocations,
|
||||
get_installed_extensions,
|
||||
get_wasm_invocation,
|
||||
mark_stale_wasm_invocations,
|
||||
update_installed_extension,
|
||||
update_installed_extension_wasm_runtime_limits,
|
||||
update_wasm_invocation,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocation_stats as get_wasm_invocation_stats_crud,
|
||||
)
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_wasm_invocations as get_wasm_invocations_crud,
|
||||
)
|
||||
from lnbits.core.helpers import migrate_extension_database
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import Connection
|
||||
from lnbits.settings import WasmRuntimeLimits, settings
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models.extensions import (
|
||||
Extension,
|
||||
ExtensionMeta,
|
||||
ExtensionPermission,
|
||||
InstallableExtension,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
)
|
||||
|
||||
_WASM_INVOCATION_CLEANUP_INTERVAL = timedelta(hours=1)
|
||||
WASM_RUNTIME_LIMIT_FIELDS = tuple(WasmRuntimeLimits.__fields__.keys())
|
||||
|
||||
|
||||
@dataclass
|
||||
class WasmInvocationHandle:
|
||||
invocation: WasmInvocation
|
||||
engine: Any | None = None
|
||||
store: Any | None = None
|
||||
runtime_limits: dict[str, int] | None = None
|
||||
stop_requested: bool = False
|
||||
stop_reason: str | None = None
|
||||
|
||||
|
||||
_wasm_invocation_lock = RLock()
|
||||
_wasm_invocation_ready_lock = asyncio.Lock()
|
||||
_wasm_invocation_handles: dict[str, WasmInvocationHandle] = {}
|
||||
_wasm_invocations_marked_stale = False
|
||||
_wasm_invocations_last_cleanup_at: datetime | None = None
|
||||
|
||||
|
||||
def wasm_runtime_limit_defaults() -> dict[str, int]:
|
||||
return {field: int(getattr(settings, field)) for field in WASM_RUNTIME_LIMIT_FIELDS}
|
||||
|
||||
|
||||
def validate_wasm_runtime_limit_overrides(
|
||||
limits: Mapping[str, Any] | None,
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> dict[str, int]:
|
||||
if not limits:
|
||||
return {}
|
||||
|
||||
validated: dict[str, int] = {}
|
||||
for field, raw_value in limits.items():
|
||||
if field not in WASM_RUNTIME_LIMIT_FIELDS:
|
||||
if strict:
|
||||
raise ValueError(f"Unknown WASM runtime limit field '{field}'.")
|
||||
continue
|
||||
|
||||
value = _validate_wasm_runtime_limit_value(field, raw_value, strict=strict)
|
||||
if value is None:
|
||||
continue
|
||||
validated[field] = value
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
def _validate_wasm_runtime_limit_value(
|
||||
field: str,
|
||||
raw_value: Any,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> int | None:
|
||||
if raw_value is None or raw_value == "":
|
||||
return None
|
||||
if isinstance(raw_value, bool):
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if raw_value == "":
|
||||
return None
|
||||
if not raw_value.isdecimal():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
if isinstance(raw_value, float) and not raw_value.is_integer():
|
||||
return _invalid_wasm_runtime_limit(field, strict, "must be an integer")
|
||||
|
||||
try:
|
||||
value = int(raw_value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return _invalid_wasm_runtime_limit(
|
||||
field,
|
||||
strict,
|
||||
"must be an integer",
|
||||
exc=exc,
|
||||
)
|
||||
if value < 0:
|
||||
return _invalid_wasm_runtime_limit(field, strict, "cannot be negative")
|
||||
return value
|
||||
|
||||
|
||||
def _invalid_wasm_runtime_limit(
|
||||
field: str,
|
||||
strict: bool,
|
||||
message: str,
|
||||
*,
|
||||
exc: Exception | None = None,
|
||||
) -> int | None:
|
||||
if not strict:
|
||||
return None
|
||||
error = ValueError(f"WASM runtime limit '{field}' {message}.")
|
||||
if exc:
|
||||
raise error from exc
|
||||
raise error
|
||||
|
||||
|
||||
def resolve_wasm_runtime_limits(
|
||||
installed_extension: InstallableExtension | None = None,
|
||||
) -> dict[str, int]:
|
||||
limits = wasm_runtime_limit_defaults()
|
||||
if installed_extension:
|
||||
limits.update(
|
||||
validate_wasm_runtime_limit_overrides(
|
||||
installed_extension.wasm_runtime_limits,
|
||||
strict=False,
|
||||
)
|
||||
)
|
||||
return limits
|
||||
|
||||
|
||||
async def get_wasm_runtime_limits_for_extension(ext_id: str) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
return resolve_wasm_runtime_limits(installed_extension)
|
||||
|
||||
|
||||
async def update_wasm_extension_runtime_limits(
|
||||
ext_id: str,
|
||||
limits: Mapping[str, Any] | None,
|
||||
) -> dict[str, int]:
|
||||
installed_extension = await get_installed_extension(ext_id)
|
||||
if not installed_extension:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
if not installed_extension.is_wasm:
|
||||
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
|
||||
|
||||
validated_limits = validate_wasm_runtime_limit_overrides(limits)
|
||||
await update_installed_extension_wasm_runtime_limits(
|
||||
ext_id=ext_id,
|
||||
limits=validated_limits,
|
||||
)
|
||||
return validated_limits
|
||||
|
||||
|
||||
async def install_extension(
|
||||
ext_info: InstallableExtension,
|
||||
@@ -200,8 +47,6 @@ async def install_extension(
|
||||
installed_ext = await get_installed_extension(ext_info.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
ext_info.meta.payments = installed_ext.meta.payments
|
||||
if installed_ext:
|
||||
ext_info.wasm_runtime_limits = installed_ext.wasm_runtime_limits
|
||||
|
||||
await check_extensions_limit(installed_ext)
|
||||
|
||||
@@ -226,7 +71,7 @@ async def install_extension(
|
||||
await update_installed_extension(ext_info)
|
||||
|
||||
extension = Extension.from_installable_ext(ext_info)
|
||||
if extension.is_upgrade_extension:
|
||||
if extension.is_upgrade_extension and not extension.is_wasm:
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
@@ -245,394 +90,6 @@ async def check_extensions_limit(installed_ext: InstallableExtension | None = No
|
||||
raise ValueError("Max amount of extensions have been installed")
|
||||
|
||||
|
||||
async def ensure_wasm_invocation_monitoring_ready() -> None:
|
||||
global _wasm_invocations_last_cleanup_at, _wasm_invocations_marked_stale
|
||||
|
||||
async with _wasm_invocation_ready_lock:
|
||||
now = _now()
|
||||
if not _wasm_invocations_marked_stale:
|
||||
await mark_stale_wasm_invocations()
|
||||
_wasm_invocations_marked_stale = True
|
||||
|
||||
if (
|
||||
_wasm_invocations_last_cleanup_at is None
|
||||
or now - _wasm_invocations_last_cleanup_at
|
||||
>= _WASM_INVOCATION_CLEANUP_INTERVAL
|
||||
):
|
||||
_wasm_invocations_last_cleanup_at = now
|
||||
await delete_old_wasm_invocations(
|
||||
settings.lnbits_wasm_invocation_retention_days
|
||||
)
|
||||
|
||||
|
||||
async def start_wasm_invocation(
|
||||
*,
|
||||
extension_id: str,
|
||||
export_name: str,
|
||||
trigger_type: str = "unknown",
|
||||
user_id: str | None = None,
|
||||
wallet_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
event_type: str | None = None,
|
||||
payment_hash: str | None = None,
|
||||
checking_id: str | None = None,
|
||||
request_bytes: int | None = None,
|
||||
context: dict | None = None,
|
||||
runtime_limits: dict[str, int] | None = None,
|
||||
) -> WasmInvocation:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
_check_wasm_invocation_concurrency(
|
||||
extension_id=extension_id,
|
||||
user_id=user_id,
|
||||
limits=runtime_limits,
|
||||
)
|
||||
|
||||
invocation = WasmInvocation(
|
||||
id=uuid4().hex,
|
||||
extension_id=extension_id,
|
||||
export_name=export_name,
|
||||
trigger_type=trigger_type,
|
||||
user_id=user_id,
|
||||
wallet_id=wallet_id,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
path=path,
|
||||
event_type=event_type,
|
||||
payment_hash=payment_hash,
|
||||
checking_id=checking_id,
|
||||
request_bytes=request_bytes,
|
||||
context=_safe_wasm_invocation_context(context or {}),
|
||||
)
|
||||
await create_wasm_invocation(invocation)
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
_wasm_invocation_handles[invocation.id] = WasmInvocationHandle(
|
||||
invocation,
|
||||
runtime_limits=runtime_limits,
|
||||
)
|
||||
|
||||
return invocation
|
||||
|
||||
|
||||
def attach_wasm_invocation_runtime(
|
||||
invocation_id: str,
|
||||
*,
|
||||
engine: Any,
|
||||
store: Any,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
handle.engine = engine
|
||||
handle.store = store
|
||||
if handle.stop_requested:
|
||||
_interrupt_wasm_invocation(handle)
|
||||
|
||||
|
||||
def record_wasm_invocation_host_call(
|
||||
invocation_id: str | None,
|
||||
method_id: str,
|
||||
) -> None:
|
||||
if not invocation_id:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if not handle:
|
||||
return
|
||||
|
||||
invocation = handle.invocation
|
||||
invocation.host_call_count += 1
|
||||
category = _wasm_host_call_category(method_id)
|
||||
if category == "http":
|
||||
invocation.http_call_count += 1
|
||||
elif category == "storage":
|
||||
invocation.storage_call_count += 1
|
||||
elif category == "wallet":
|
||||
invocation.wallet_call_count += 1
|
||||
|
||||
_check_wasm_host_call_limit(invocation, category, handle.runtime_limits)
|
||||
|
||||
|
||||
async def stop_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
reason: str = "Stopped by admin.",
|
||||
) -> bool:
|
||||
interrupted = False
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
if handle:
|
||||
handle.stop_requested = True
|
||||
handle.stop_reason = reason
|
||||
handle.invocation.stop_reason = reason
|
||||
interrupted = _interrupt_wasm_invocation(handle)
|
||||
|
||||
invocation = await get_wasm_invocation(invocation_id)
|
||||
if invocation and invocation.status == "running":
|
||||
invocation.stop_reason = reason
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
return interrupted
|
||||
|
||||
|
||||
async def stop_wasm_extension_invocations(
|
||||
extension_id: str,
|
||||
*,
|
||||
reason: str = "Extension deactivated.",
|
||||
) -> int:
|
||||
with _wasm_invocation_lock:
|
||||
invocation_ids = [
|
||||
invocation_id
|
||||
for invocation_id, handle in _wasm_invocation_handles.items()
|
||||
if handle.invocation.extension_id == extension_id
|
||||
]
|
||||
|
||||
for invocation_id in invocation_ids:
|
||||
await stop_wasm_invocation(invocation_id, reason=reason)
|
||||
|
||||
return len(invocation_ids)
|
||||
|
||||
|
||||
def wasm_invocation_stop_requested(invocation_id: str) -> bool:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return bool(handle and handle.stop_requested)
|
||||
|
||||
|
||||
def get_wasm_invocation_stop_reason(invocation_id: str) -> str | None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.get(invocation_id)
|
||||
return handle.stop_reason if handle else None
|
||||
|
||||
|
||||
async def finish_wasm_invocation(
|
||||
invocation_id: str,
|
||||
*,
|
||||
status: str,
|
||||
response_bytes: int | None = None,
|
||||
memory_peak_bytes: int | None = None,
|
||||
error_type: str | None = None,
|
||||
error_message: str | None = None,
|
||||
stop_reason: str | None = None,
|
||||
) -> None:
|
||||
with _wasm_invocation_lock:
|
||||
handle = _wasm_invocation_handles.pop(invocation_id, None)
|
||||
|
||||
invocation = (
|
||||
handle.invocation if handle else await get_wasm_invocation(invocation_id)
|
||||
)
|
||||
if not invocation:
|
||||
return
|
||||
|
||||
reason = stop_reason or (handle.stop_reason if handle else None)
|
||||
if handle and handle.stop_requested and status == "failed":
|
||||
status = "stopped"
|
||||
reason = reason or "Stopped by admin."
|
||||
|
||||
finished_at = _now()
|
||||
invocation.status = status
|
||||
invocation.finished_at = finished_at
|
||||
invocation.duration_ms = max(
|
||||
0, int((finished_at - invocation.started_at).total_seconds() * 1000)
|
||||
)
|
||||
invocation.response_bytes = response_bytes
|
||||
invocation.memory_peak_bytes = memory_peak_bytes
|
||||
invocation.error_type = error_type
|
||||
invocation.error_message = _safe_wasm_error_message(error_message)
|
||||
invocation.stop_reason = reason
|
||||
|
||||
await update_wasm_invocation(invocation)
|
||||
|
||||
|
||||
def get_current_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
with _wasm_invocation_lock:
|
||||
invocations = []
|
||||
for handle in _wasm_invocation_handles.values():
|
||||
if extension_id and handle.invocation.extension_id != extension_id:
|
||||
continue
|
||||
invocation = handle.invocation.copy(deep=True)
|
||||
if handle.stop_requested and invocation.status == "running":
|
||||
invocation.status = "stopping"
|
||||
invocation.stop_reason = handle.stop_reason
|
||||
invocations.append(invocation)
|
||||
|
||||
return sorted(
|
||||
invocations, key=lambda invocation: invocation.started_at, reverse=True
|
||||
)
|
||||
|
||||
|
||||
def _check_wasm_invocation_concurrency(
|
||||
*,
|
||||
extension_id: str,
|
||||
user_id: str | None,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
with _wasm_invocation_lock:
|
||||
handles = list(_wasm_invocation_handles.values())
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations"],
|
||||
len(handles) + 1,
|
||||
):
|
||||
raise ValueError("WASM runtime has too many active invocations.")
|
||||
|
||||
extension_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.extension_id == extension_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_extension"],
|
||||
extension_invocations + 1,
|
||||
):
|
||||
raise ValueError(
|
||||
f"WASM extension '{extension_id}' has too many active invocations."
|
||||
)
|
||||
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
user_invocations = sum(
|
||||
1 for handle in handles if handle.invocation.user_id == user_id
|
||||
)
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_concurrent_invocations_per_user"],
|
||||
user_invocations + 1,
|
||||
):
|
||||
raise ValueError("WASM user has too many active invocations.")
|
||||
|
||||
|
||||
def _check_wasm_host_call_limit(
|
||||
invocation: WasmInvocation,
|
||||
category: str,
|
||||
limits: dict[str, int] | None,
|
||||
) -> None:
|
||||
if not limits:
|
||||
return
|
||||
|
||||
if _wasm_limit_exceeded(
|
||||
limits["wasm_runtime_max_host_calls"],
|
||||
invocation.host_call_count,
|
||||
):
|
||||
raise ValueError("WASM host call limit exceeded.")
|
||||
|
||||
category_limits = {
|
||||
"http": (
|
||||
limits["wasm_runtime_max_http_calls"],
|
||||
invocation.http_call_count,
|
||||
),
|
||||
"storage": (
|
||||
limits["wasm_runtime_max_storage_calls"],
|
||||
invocation.storage_call_count,
|
||||
),
|
||||
"wallet": (
|
||||
limits["wasm_runtime_max_wallet_calls"],
|
||||
invocation.wallet_call_count,
|
||||
),
|
||||
}
|
||||
category_limit = category_limits.get(category)
|
||||
if category_limit and _wasm_limit_exceeded(*category_limit):
|
||||
raise ValueError(f"WASM {category} host call limit exceeded.")
|
||||
|
||||
|
||||
def _wasm_limit_exceeded(limit: int, value: int) -> bool:
|
||||
return limit > 0 and value > limit
|
||||
|
||||
|
||||
async def get_wasm_invocation_history(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[WasmInvocation]:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
return await get_wasm_invocations_crud(
|
||||
extension_id=extension_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
async def get_wasm_invocation_summary(
|
||||
*,
|
||||
extension_id: str | None = None,
|
||||
hours: int = 24,
|
||||
) -> WasmInvocationStats:
|
||||
await ensure_wasm_invocation_monitoring_ready()
|
||||
since = _now() - timedelta(hours=max(1, min(hours, 24 * 30)))
|
||||
return await get_wasm_invocation_stats_crud(
|
||||
extension_id=extension_id,
|
||||
since=since,
|
||||
)
|
||||
|
||||
|
||||
def _interrupt_wasm_invocation(handle: WasmInvocationHandle) -> bool:
|
||||
if not handle.store or not handle.engine:
|
||||
return False
|
||||
try:
|
||||
handle.store.set_epoch_deadline(1)
|
||||
handle.engine.increment_epoch()
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Failed to interrupt WASM invocation '{handle.invocation.id}': {exc}"
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_host_call_category(method_id: str) -> str:
|
||||
if method_id.startswith("http.") or method_id.startswith("extension.api."):
|
||||
return "http"
|
||||
if method_id.startswith("storage."):
|
||||
return "storage"
|
||||
if method_id.startswith("wallet."):
|
||||
return "wallet"
|
||||
return "host"
|
||||
|
||||
|
||||
def _safe_wasm_invocation_context(context: dict) -> dict:
|
||||
safe_context: dict = {}
|
||||
for key, value in context.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if value is None or isinstance(value, (bool, int, float)):
|
||||
safe_context[key[:64]] = value
|
||||
elif isinstance(value, str):
|
||||
safe_context[key[:64]] = value[:256]
|
||||
return safe_context
|
||||
|
||||
|
||||
def _safe_wasm_error_message(message: str | None) -> str | None:
|
||||
if not message:
|
||||
return None
|
||||
|
||||
safe_message = message[:500]
|
||||
redactions = [
|
||||
(
|
||||
r"(?i)(api[-_ ]?key|token|authorization|password|secret|preimage)"
|
||||
r"\s*[:=]\s*[^\s,;]+",
|
||||
r"\1=[redacted]",
|
||||
),
|
||||
(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [redacted]"),
|
||||
(r"\b[a-fA-F0-9]{64}\b", "[redacted-hex]"),
|
||||
]
|
||||
for pattern, replacement in redactions:
|
||||
safe_message = re.sub(pattern, replacement, safe_message)
|
||||
return safe_message
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def uninstall_extension(ext_id: str):
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
@@ -656,8 +113,6 @@ async def activate_extension(ext: Extension):
|
||||
|
||||
|
||||
async def deactivate_extension(ext_id: str):
|
||||
if is_wasm_extension_id(ext_id):
|
||||
await stop_wasm_extension_invocations(ext_id, reason="Extension deactivated.")
|
||||
settings.deactivate_extension_paths(ext_id)
|
||||
await update_installed_extension_state(ext_id=ext_id, active=False)
|
||||
await stop_extension_background_work(ext_id)
|
||||
|
||||
@@ -20,7 +20,6 @@ from lnbits.fiat.base import (
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
@@ -61,7 +60,11 @@ async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
|
||||
return fiat_status
|
||||
|
||||
|
||||
@@ -66,8 +66,6 @@ async def check_server_balance_against_node():
|
||||
|
||||
|
||||
async def check_balance_delta_changed():
|
||||
if settings.notification_balance_delta_threshold_sats <= 0:
|
||||
return
|
||||
status = await get_balance_delta()
|
||||
if settings.latest_balance_delta_sats is None:
|
||||
settings.latest_balance_delta_sats = status.delta_sats
|
||||
|
||||
@@ -18,7 +18,6 @@ from lnbits.core.crud import (
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
@@ -238,16 +237,6 @@ async def send_email(
|
||||
return False
|
||||
|
||||
|
||||
async def dispatch_payment_notification(payment: Payment) -> None:
|
||||
"""
|
||||
This worker dispatches the payment notifications.
|
||||
"""
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
await core_app_extra.dispatch_extension_invoice_paid(payment)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
Dispatches the webhook to the webhook url.
|
||||
|
||||
@@ -19,7 +19,6 @@ from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.crypto import fake_privkey, random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis, satoshis_amount_as_fiat
|
||||
from lnbits.wallets import fake_wallet, get_funding_source
|
||||
@@ -517,7 +516,9 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
from lnbits.tasks import internal_invoice_queue_put
|
||||
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
@@ -788,8 +789,10 @@ async def _pay_internal_invoice(
|
||||
) # notify the receiver
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
task_manager.internal_invoice_queue.put_nowait(internal_payment)
|
||||
await internal_invoice_queue.put(internal_payment.checking_id)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -826,15 +829,16 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
task = task_manager.create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
|
||||
f"fundingsource_pay_invoice_{checking_id}",
|
||||
from lnbits.tasks import create_task
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
)
|
||||
|
||||
# make sure a hold invoice or deferred payment is not blocking the server
|
||||
wait_time = max(1, settings.lnbits_funding_source_pay_invoice_wait_seconds)
|
||||
try:
|
||||
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
|
||||
payment_response = await asyncio.wait_for(task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(
|
||||
@@ -1104,18 +1108,3 @@ async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment
|
||||
payment = await update_payment(payment)
|
||||
|
||||
return payment
|
||||
|
||||
|
||||
async def fundingsource_invoice_producer() -> None:
|
||||
"""
|
||||
will collect all invoices that come directly from the backend wallet.
|
||||
|
||||
Called registered in the app startup sequence and run by taskmanager.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
payment = await update_invoice_from_paid_invoices_stream(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
task_manager.invoice_queue.put_nowait(payment)
|
||||
|
||||
+125
-46
@@ -2,43 +2,78 @@ import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import create_audit_entry
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud.payments import get_payments_status_count
|
||||
from lnbits.core.crud.users import get_accounts
|
||||
from lnbits.core.crud.wallets import get_wallets_count
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.models.audit import AuditEntry
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.models.notifications import NotificationType
|
||||
from lnbits.core.services.funding_source import get_balance_delta
|
||||
from lnbits.core.services.funding_source import (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.notifications import (
|
||||
enqueue_admin_notification,
|
||||
process_next_notification,
|
||||
send_payment_notification,
|
||||
)
|
||||
from lnbits.db import Filters
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||
from lnbits.utils.exchange_rates import btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
async def process_next_audit_entry() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
data = await audit_queue.get()
|
||||
await create_audit_entry(data)
|
||||
async def run_by_the_minute_tasks() -> None:
|
||||
minute_counter = 0
|
||||
while settings.lnbits_running:
|
||||
status_minutes = settings.lnbits_notification_server_status_hours * 60
|
||||
|
||||
if settings.notification_balance_delta_threshold_sats > 0:
|
||||
try:
|
||||
# runs by default every minute, the delta should not change that often
|
||||
await check_balance_delta_changed()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % settings.lnbits_watchdog_interval_minutes == 0:
|
||||
try:
|
||||
await check_server_balance_against_node()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % status_minutes == 0:
|
||||
try:
|
||||
await _notify_server_status()
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
if minute_counter % 60 == 0:
|
||||
try:
|
||||
# initialize the list of all extensions
|
||||
await InstallableExtension.get_installable_extensions(
|
||||
post_refresh_cache=True
|
||||
)
|
||||
except Exception as ex:
|
||||
logger.error(ex)
|
||||
|
||||
minute_counter += 1
|
||||
await asyncio.sleep(60)
|
||||
|
||||
|
||||
async def refresh_extension_cache() -> None:
|
||||
# only refreshes every 10 minutes
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
|
||||
|
||||
async def notify_server_status() -> None:
|
||||
async def _notify_server_status() -> None:
|
||||
accounts = await get_accounts(filters=Filters(limit=0))
|
||||
wallets_count = await get_wallets_count()
|
||||
payments = await get_payments_status_count()
|
||||
|
||||
status = await get_balance_delta()
|
||||
values = {
|
||||
"up_time": settings.lnbits_server_up_time,
|
||||
@@ -55,38 +90,82 @@ async def notify_server_status() -> None:
|
||||
enqueue_admin_notification(NotificationType.server_status, values)
|
||||
|
||||
|
||||
async def wait_for_paid_invoices(invoice_paid_queue: asyncio.Queue) -> None:
|
||||
"""
|
||||
This worker dispatches events to all extensions and dispatches webhooks.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
payment = await invoice_paid_queue.get()
|
||||
logger.trace("received invoice paid event")
|
||||
# payment notification
|
||||
wallet = await get_wallet(payment.wallet_id)
|
||||
if wallet:
|
||||
await send_payment_notification(wallet, payment)
|
||||
await core_app_extra.dispatch_extension_invoice_paid(payment)
|
||||
|
||||
|
||||
async def wait_for_audit_data() -> None:
|
||||
"""
|
||||
Waits for audit entries to be pushed to the queue.
|
||||
Then it inserts the entries into the DB.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
data = await audit_queue.get()
|
||||
try:
|
||||
await create_audit_entry(data)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def wait_notification_messages() -> None:
|
||||
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await process_next_notification()
|
||||
except Exception as ex:
|
||||
logger.warning("Payment notification error", ex)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
async def purge_audit_data() -> None:
|
||||
"""
|
||||
Remove audit entries which have passed their retention period.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await delete_expired_audit_entries()
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
|
||||
# clean every hour
|
||||
await asyncio.sleep(60 * 60)
|
||||
|
||||
|
||||
async def collect_exchange_rates_data() -> None:
|
||||
"""
|
||||
Collect exchange rates data. Used for monitoring only.
|
||||
"""
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
try:
|
||||
if (
|
||||
settings.lnbits_price_aggregator_enabled
|
||||
and settings.lnbits_price_aggregator_url
|
||||
):
|
||||
price = await btc_price_from_aggregator(currency)
|
||||
if price:
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
price,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(
|
||||
{"Aggregator": price}, max_history_size
|
||||
)
|
||||
while settings.lnbits_running:
|
||||
currency = settings.lnbits_default_accounting_currency or "USD"
|
||||
max_history_size = settings.lnbits_exchange_history_size
|
||||
sleep_time = settings.lnbits_exchange_history_refresh_interval_seconds
|
||||
|
||||
if sleep_time > 0:
|
||||
try:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
lnbits_rate,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
else:
|
||||
rates = await btc_rates(currency)
|
||||
if rates:
|
||||
rates_values = [r[1] for r in rates]
|
||||
lnbits_rate = sum(rates_values) / len(rates_values)
|
||||
rates.append(("LNbits", lnbits_rate))
|
||||
cache.set(
|
||||
f"btc-price-{currency}",
|
||||
lnbits_rate,
|
||||
expiry=settings.lnbits_exchange_rate_cache_seconds,
|
||||
)
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
sleep_time = 60
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
@@ -20,7 +20,7 @@ from lnbits.core.services.settings import dict_to_settings
|
||||
from lnbits.decorators import check_admin, check_super_user
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import AdminSettings, Settings, UpdateSettings, settings
|
||||
from lnbits.task_manager import PublicTask, task_manager
|
||||
from lnbits.tasks import invoice_listeners
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||
@@ -44,10 +44,11 @@ async def api_auditor():
|
||||
name="Monitor",
|
||||
description="show the current listeners and other monitoring data",
|
||||
dependencies=[Depends(check_admin)],
|
||||
response_model=list[PublicTask],
|
||||
)
|
||||
async def api_monitor() -> list[PublicTask]:
|
||||
return task_manager.get_public_tasks()
|
||||
async def api_monitor():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
}
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
|
||||
@@ -29,10 +29,6 @@ from lnbits.core.models.extensions import (
|
||||
ReleasePaymentInfo,
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
WasmRuntimeLimitsInfo,
|
||||
WasmRuntimeLimitsUpdate,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
@@ -40,17 +36,10 @@ from lnbits.core.services import check_transaction_status, create_invoice
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
deactivate_extension,
|
||||
get_current_wasm_invocations,
|
||||
get_valid_extension,
|
||||
get_valid_extensions,
|
||||
get_wasm_invocation_history,
|
||||
get_wasm_invocation_summary,
|
||||
install_extension,
|
||||
resolve_wasm_runtime_limits,
|
||||
stop_wasm_invocation,
|
||||
uninstall_extension,
|
||||
update_wasm_extension_runtime_limits,
|
||||
validate_wasm_runtime_limit_overrides,
|
||||
)
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_extension_permissions
|
||||
from lnbits.db import Page
|
||||
@@ -143,106 +132,6 @@ async def api_install_extension(data: CreateExtension):
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations/current",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_current_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
) -> list[WasmInvocation]:
|
||||
return get_current_wasm_invocations(extension_id=extension_id)
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_invocations(
|
||||
extension_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[WasmInvocation]:
|
||||
return await get_wasm_invocation_history(
|
||||
extension_id=extension_id,
|
||||
status=status,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/invocations/stats",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_invocation_stats(
|
||||
extension_id: str | None = None,
|
||||
hours: int = 24,
|
||||
) -> WasmInvocationStats:
|
||||
return await get_wasm_invocation_summary(extension_id=extension_id, hours=hours)
|
||||
|
||||
|
||||
@extension_router.post(
|
||||
"/wasm/invocations/{invocation_id}/stop",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_stop_wasm_invocation(invocation_id: str) -> SimpleStatus:
|
||||
await stop_wasm_invocation(invocation_id, reason="Stopped by admin.")
|
||||
return SimpleStatus(success=True, message="WASM invocation stop requested.")
|
||||
|
||||
|
||||
@extension_router.get(
|
||||
"/wasm/runtime-limits/extensions",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_get_wasm_runtime_limit_extensions() -> list[WasmRuntimeLimitsInfo]:
|
||||
installed_extensions = await get_installed_extensions()
|
||||
return [
|
||||
WasmRuntimeLimitsInfo(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
active=extension.active,
|
||||
wasm_runtime_limits=validate_wasm_runtime_limit_overrides(
|
||||
extension.wasm_runtime_limits,
|
||||
strict=False,
|
||||
),
|
||||
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
|
||||
)
|
||||
for extension in installed_extensions
|
||||
if extension.is_wasm
|
||||
]
|
||||
|
||||
|
||||
@extension_router.put(
|
||||
"/wasm/runtime-limits/{ext_id}",
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_update_wasm_runtime_limits(
|
||||
ext_id: str,
|
||||
data: WasmRuntimeLimitsUpdate,
|
||||
) -> WasmRuntimeLimitsInfo:
|
||||
try:
|
||||
wasm_runtime_limits = await update_wasm_extension_runtime_limits(
|
||||
ext_id, data.limits
|
||||
)
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise ValueError(f"Extension '{ext_id}' is not installed.")
|
||||
extension.wasm_runtime_limits = wasm_runtime_limits
|
||||
return WasmRuntimeLimitsInfo(
|
||||
id=extension.id,
|
||||
name=extension.name,
|
||||
active=extension.active,
|
||||
wasm_runtime_limits=wasm_runtime_limits,
|
||||
effective_wasm_runtime_limits=resolve_wasm_runtime_limits(extension),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/details")
|
||||
async def api_extension_details(
|
||||
ext_id: str,
|
||||
|
||||
@@ -189,9 +189,6 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
@generic_router.get("/audit", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/node", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm/limits", dependencies=admin_ui_checks)
|
||||
@generic_router.get("/admin/extensions/wasm/{ext_id}", dependencies=admin_ui_checks)
|
||||
@generic_router.get(
|
||||
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
@@ -199,9 +196,7 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request,
|
||||
ext_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
from ..client.extensions import send_extension_api_request
|
||||
from ..storage.crud import (
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
@@ -50,11 +49,6 @@ from .registry import extension_api_method
|
||||
logger = logging.getLogger("lnbits.extensions")
|
||||
|
||||
|
||||
def _looks_like_lnurl_pay_target(payment_request: str) -> bool:
|
||||
normalized = payment_request.strip().lower()
|
||||
return normalized.startswith(("lnurl", "lightning:lnurl")) or "@" in normalized
|
||||
|
||||
|
||||
class ExtensionHostAPI:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -65,9 +59,6 @@ class ExtensionHostAPI:
|
||||
access_token: str | None = None,
|
||||
context: str = "user",
|
||||
owner_id: str | None = None,
|
||||
wallet_id: str | None = None,
|
||||
invocation_id: str | None = None,
|
||||
runtime_limits: dict[str, int] | None = None,
|
||||
) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.permissions, self.permission_policies = self._permission_data(permissions)
|
||||
@@ -75,12 +66,9 @@ class ExtensionHostAPI:
|
||||
self.access_token = access_token
|
||||
self.context = context
|
||||
self.owner_id = sha256s(user_id) if user_id else owner_id
|
||||
self.wallet_id = wallet_id
|
||||
self.invocation_id = invocation_id
|
||||
self.runtime_limits = runtime_limits or {}
|
||||
from .utils import ExtensionAPIUtils
|
||||
|
||||
self.utils = ExtensionAPIUtils(self.extension_id, self.permissions)
|
||||
self.utils = ExtensionAPIUtils(self)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get",
|
||||
@@ -374,48 +362,29 @@ class ExtensionHostAPI:
|
||||
async def wallet_pay_invoice(
|
||||
self, request: PayInvoiceRequest
|
||||
) -> PayInvoiceResponse:
|
||||
from lnurl import LnurlResponseException
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.services.lnurl import get_pr_from_lnurl
|
||||
from lnbits.core.services.payments import pay_invoice
|
||||
from lnbits.exceptions import PaymentError
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None:
|
||||
raise PermissionError("Paying invoices from this wallet is not allowed.")
|
||||
if self.user_id:
|
||||
if wallet.user != self.user_id:
|
||||
raise PermissionError(
|
||||
"Paying invoices from this wallet is not allowed."
|
||||
)
|
||||
elif not (self.context == "event" and request.wallet_id == self.wallet_id):
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Paying an invoice requires an authenticated user context."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Paying invoices from this wallet is not allowed.")
|
||||
|
||||
try:
|
||||
payment_request = request.payment_request
|
||||
if _looks_like_lnurl_pay_target(payment_request):
|
||||
if request.max_sat is None:
|
||||
return PayInvoiceResponse(
|
||||
ok=False,
|
||||
error="max_sat is required for LNURL payments.",
|
||||
)
|
||||
payment_request = await get_pr_from_lnurl(
|
||||
payment_request,
|
||||
request.max_sat * 1000,
|
||||
request.description or None,
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=request.wallet_id,
|
||||
payment_request=payment_request,
|
||||
payment_request=request.payment_request,
|
||||
max_sat=request.max_sat,
|
||||
extra={"tag": self.extension_id, **request.extra},
|
||||
description=request.description,
|
||||
tag=self.extension_id,
|
||||
)
|
||||
except (PaymentError, ValueError, LnurlResponseException) as exc:
|
||||
except (PaymentError, ValueError) as exc:
|
||||
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||
|
||||
return PayInvoiceResponse(
|
||||
@@ -443,15 +412,7 @@ class ExtensionHostAPI:
|
||||
from ..client.http import send_extension_http_request
|
||||
|
||||
policies = self.permission_policies.get("http.request") or []
|
||||
return await send_extension_http_request(
|
||||
self.extension_id,
|
||||
policies,
|
||||
request,
|
||||
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
|
||||
max_response_bytes=self.runtime_limits.get(
|
||||
"wasm_runtime_max_http_response_bytes"
|
||||
),
|
||||
)
|
||||
return await send_extension_http_request(self.extension_id, policies, request)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="extension.api.request",
|
||||
@@ -464,6 +425,7 @@ class ExtensionHostAPI:
|
||||
require_auth=True,
|
||||
)
|
||||
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
|
||||
from ..client.extensions import send_extension_api_request
|
||||
|
||||
policies = self.permission_policies.get("extension.api.request") or []
|
||||
return await send_extension_api_request(
|
||||
@@ -472,10 +434,6 @@ class ExtensionHostAPI:
|
||||
self.user_id,
|
||||
self.access_token,
|
||||
request,
|
||||
timeout_ms=self.runtime_limits.get("wasm_runtime_http_timeout_ms"),
|
||||
max_response_bytes=self.runtime_limits.get(
|
||||
"wasm_runtime_max_http_response_bytes"
|
||||
),
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
|
||||
@@ -3,18 +3,6 @@ from typing import Any
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
|
||||
from lnbits.core.wasm_ext.api.registry import extension_api_permission_ids
|
||||
from lnbits.core.wasm_ext.client.http import _request_origin
|
||||
from lnbits.core.wasm_ext.wasm.config import (
|
||||
WasmExtensionConfig,
|
||||
parse_wasm_extension_config,
|
||||
)
|
||||
|
||||
_POLICY_AWARE_PERMISSION_IDS = {
|
||||
"ext.storage.read_public",
|
||||
"extension.api.request",
|
||||
"http.request",
|
||||
"wallet.create_invoice_public",
|
||||
}
|
||||
|
||||
|
||||
def validate_extension_permissions(
|
||||
@@ -32,7 +20,7 @@ def validate_extension_permissions(
|
||||
unknown_ids.append(permission.id)
|
||||
if strict:
|
||||
continue
|
||||
normalized_permissions.append(permission.copy())
|
||||
normalized_permissions.append(permission.copy(update={"label": None}))
|
||||
|
||||
if unknown_ids and strict:
|
||||
raise ValueError(
|
||||
@@ -46,17 +34,14 @@ def validate_extension_permissions(
|
||||
def validate_wasm_extension_permissions(
|
||||
ext_info: InstallableExtension,
|
||||
granted_permissions: list[ExtensionPermission] | None,
|
||||
extension_config: dict[str, Any] | WasmExtensionConfig,
|
||||
extension_config: dict[str, Any],
|
||||
) -> list[ExtensionPermission]:
|
||||
if isinstance(extension_config, WasmExtensionConfig):
|
||||
config = extension_config
|
||||
elif extension_config.get("extension_type") != "wasm":
|
||||
if extension_config.get("extension_type") != "wasm":
|
||||
return []
|
||||
else:
|
||||
config = parse_wasm_extension_config(ext_info.id, extension_config)
|
||||
|
||||
requested_permissions = validate_extension_permissions(
|
||||
ext_info.id, config.permissions
|
||||
ext_info.id,
|
||||
ExtensionPermission.list_from_config(extension_config),
|
||||
)
|
||||
if not requested_permissions:
|
||||
return []
|
||||
@@ -64,187 +49,11 @@ def validate_wasm_extension_permissions(
|
||||
if granted_permissions is None:
|
||||
raise ValueError(f"Extension '{ext_info.id}' requires permission approval.")
|
||||
|
||||
granted_permissions = validate_extension_permissions(
|
||||
ext_info.id,
|
||||
granted_permissions,
|
||||
)
|
||||
requested_by_id = _permission_index(ext_info.id, requested_permissions, "requested")
|
||||
granted_by_id = _permission_index(ext_info.id, granted_permissions, "granted")
|
||||
|
||||
extra_granted_ids = sorted(set(granted_by_id) - set(requested_by_id))
|
||||
if extra_granted_ids:
|
||||
requested_ids = {permission.id for permission in requested_permissions}
|
||||
granted_ids = {permission.id for permission in granted_permissions}
|
||||
if requested_ids != granted_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted unrequested permissions: "
|
||||
+ ", ".join(extra_granted_ids)
|
||||
f"Extension '{ext_info.id}' was not granted all requested permissions."
|
||||
)
|
||||
|
||||
effective_permissions: list[ExtensionPermission] = []
|
||||
for permission_id, granted_permission in granted_by_id.items():
|
||||
requested_permission = requested_by_id[permission_id]
|
||||
if not _permission_grant_is_subset(requested_permission, granted_permission):
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted broader policies for "
|
||||
f"permission '{permission_id}'."
|
||||
)
|
||||
effective_permissions.append(
|
||||
requested_permission.copy(update={"policies": granted_permission.policies})
|
||||
)
|
||||
|
||||
return effective_permissions
|
||||
|
||||
|
||||
def _permission_index(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
source: str,
|
||||
) -> dict[str, ExtensionPermission]:
|
||||
indexed: dict[str, ExtensionPermission] = {}
|
||||
duplicate_ids: list[str] = []
|
||||
|
||||
for permission in permissions:
|
||||
if permission.id in indexed:
|
||||
duplicate_ids.append(permission.id)
|
||||
continue
|
||||
indexed[permission.id] = permission
|
||||
|
||||
if duplicate_ids:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' has duplicate {source} permissions: "
|
||||
+ ", ".join(sorted(set(duplicate_ids)))
|
||||
)
|
||||
return indexed
|
||||
|
||||
|
||||
def _permission_grant_is_subset(
|
||||
requested: ExtensionPermission,
|
||||
granted: ExtensionPermission,
|
||||
) -> bool:
|
||||
if requested.id != granted.id:
|
||||
return False
|
||||
if requested.id not in _POLICY_AWARE_PERMISSION_IDS:
|
||||
return True
|
||||
if requested.id == "http.request":
|
||||
return _http_request_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "extension.api.request":
|
||||
return _extension_api_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "ext.storage.read_public":
|
||||
return _public_storage_grant_is_subset(requested.policies, granted.policies)
|
||||
if requested.id == "wallet.create_invoice_public":
|
||||
return _public_invoice_grant_is_subset(requested.policies, granted.policies)
|
||||
return False
|
||||
|
||||
|
||||
def _policy_list(policies: list[Any] | None) -> list[Any]:
|
||||
return policies if isinstance(policies, list) else []
|
||||
|
||||
|
||||
def _http_request_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
return _http_origins(granted_policies).issubset(_http_origins(requested_policies))
|
||||
|
||||
|
||||
def _http_origins(policies: list[Any] | None) -> set[str]:
|
||||
origins: set[str] = set()
|
||||
for policy in _policy_list(policies):
|
||||
host = policy.get("host") if isinstance(policy, dict) else policy
|
||||
if not isinstance(host, str) or not host:
|
||||
continue
|
||||
try:
|
||||
origins.add(_request_origin(host))
|
||||
except PermissionError:
|
||||
continue
|
||||
return origins
|
||||
|
||||
|
||||
def _extension_api_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
requested_targets = _extension_api_targets(requested_policies)
|
||||
granted_targets = _extension_api_targets(granted_policies)
|
||||
for extension_id, granted_access in granted_targets.items():
|
||||
requested_access = requested_targets.get(extension_id)
|
||||
if requested_access is None or not granted_access.issubset(requested_access):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extension_api_targets(policies: list[Any] | None) -> dict[str, set[str]]:
|
||||
targets: dict[str, set[str]] = {}
|
||||
for policy in _policy_list(policies):
|
||||
extension_id: str | None = None
|
||||
access: list[Any] = []
|
||||
if isinstance(policy, str):
|
||||
extension_id = policy
|
||||
access = ["read"]
|
||||
elif isinstance(policy, dict):
|
||||
raw_extension_id = policy.get("id")
|
||||
raw_access = policy.get("access")
|
||||
if isinstance(raw_extension_id, str) and isinstance(raw_access, list):
|
||||
extension_id = raw_extension_id
|
||||
access = raw_access
|
||||
if not extension_id or extension_id in targets:
|
||||
continue
|
||||
clean_access = {
|
||||
item
|
||||
for item in access
|
||||
if isinstance(item, str) and item in {"read", "write"}
|
||||
}
|
||||
if clean_access:
|
||||
targets[extension_id] = clean_access
|
||||
return targets
|
||||
|
||||
|
||||
def _public_storage_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
requested_tables = _public_storage_tables(requested_policies)
|
||||
granted_tables = _public_storage_tables(granted_policies)
|
||||
for table_name, granted_fields in granted_tables.items():
|
||||
requested_fields = requested_tables.get(table_name)
|
||||
if requested_fields is None or not granted_fields.issubset(requested_fields):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _public_storage_tables(policies: list[Any] | None) -> dict[str, set[str]]:
|
||||
tables: dict[str, set[str]] = {}
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table_name = policy.get("table_name")
|
||||
public_fields = policy.get("public_fields")
|
||||
if (
|
||||
not isinstance(table_name, str)
|
||||
or table_name in tables
|
||||
or not isinstance(public_fields, list)
|
||||
):
|
||||
continue
|
||||
fields = {field for field in public_fields if isinstance(field, str) and field}
|
||||
if fields:
|
||||
tables[table_name] = fields
|
||||
return tables
|
||||
|
||||
|
||||
def _public_invoice_grant_is_subset(
|
||||
requested_policies: list[Any] | None,
|
||||
granted_policies: list[Any] | None,
|
||||
) -> bool:
|
||||
return _public_invoice_sources(granted_policies).issubset(
|
||||
_public_invoice_sources(requested_policies)
|
||||
)
|
||||
|
||||
|
||||
def _public_invoice_sources(policies: list[Any] | None) -> set[tuple[str, str]]:
|
||||
sources: set[tuple[str, str]] = set()
|
||||
for policy in _policy_list(policies):
|
||||
if not isinstance(policy, dict):
|
||||
continue
|
||||
table = policy.get("table")
|
||||
wallet_field = policy.get("wallet_field")
|
||||
if isinstance(table, str) and table and isinstance(wallet_field, str):
|
||||
sources.add((table, wallet_field))
|
||||
return sources
|
||||
return requested_permissions
|
||||
|
||||
@@ -31,9 +31,6 @@ class ExtensionAPIHost:
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
method = self._require_method(host_name)
|
||||
from lnbits.core.services.extensions import record_wasm_invocation_host_call
|
||||
|
||||
record_wasm_invocation_host_call(self.api.invocation_id, method.method_id)
|
||||
request = self._request_model(method, payload)
|
||||
handler = _resolve_attr_path(self.api, method.python_name)
|
||||
response = handler(request)
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.settings import settings
|
||||
from lnbits.utils.crypto import random_secret_and_hash, verify_preimage
|
||||
from lnbits.utils.exchange_rates import (
|
||||
allowed_currencies,
|
||||
fiat_amount_as_satoshis,
|
||||
get_fiat_rate_and_price_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .models import (
|
||||
Bolt11Request,
|
||||
@@ -41,28 +30,21 @@ from .models import (
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
|
||||
class ExtensionAPIUtils:
|
||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
|
||||
permission_set = set(permissions)
|
||||
self.currencies = ExtensionCurrencyUtils(extension_id, permission_set)
|
||||
self.server = ExtensionServerUtils(extension_id, permission_set)
|
||||
self.lightning = ExtensionLightningUtils(extension_id, permission_set)
|
||||
def __init__(self, api: ExtensionHostAPI) -> None:
|
||||
self.api = api
|
||||
self.currencies = ExtensionCurrencyUtils(api)
|
||||
self.server = ExtensionServerUtils(api)
|
||||
self.lightning = ExtensionLightningUtils(api)
|
||||
|
||||
|
||||
class _ExtensionAPIUtilsGroup:
|
||||
def __init__(self, extension_id: str, permissions: Iterable[str]) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.permissions = set(permissions)
|
||||
|
||||
def require_permission(self, permission: str | None) -> None:
|
||||
if permission and permission not in self.permissions:
|
||||
raise PermissionError(
|
||||
f"Extension '{self.extension_id}' is missing permission '{permission}'."
|
||||
)
|
||||
|
||||
def has_authenticated_context(self) -> bool:
|
||||
return False
|
||||
def __init__(self, api: ExtensionHostAPI) -> None:
|
||||
self.api = api
|
||||
|
||||
|
||||
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
@@ -78,6 +60,7 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
|
||||
return CurrencyListResponse(currencies=allowed_currencies())
|
||||
|
||||
@@ -93,6 +76,7 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
|
||||
from lnbits.utils.exchange_rates import get_fiat_rate_and_price_satoshis
|
||||
|
||||
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
|
||||
return CurrencyRateResponse(rate=rate, price=price)
|
||||
@@ -109,6 +93,10 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
|
||||
from lnbits.utils.exchange_rates import (
|
||||
fiat_amount_as_satoshis,
|
||||
satoshis_amount_as_fiat,
|
||||
)
|
||||
|
||||
from_currency = request.from_currency
|
||||
if from_currency == "sats":
|
||||
@@ -147,6 +135,7 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
|
||||
from lnbits.utils.exchange_rates import fiat_amount_as_satoshis
|
||||
|
||||
return FiatToSatsResponse(
|
||||
amount_sat=await fiat_amount_as_satoshis(
|
||||
@@ -167,6 +156,7 @@ class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
|
||||
from lnbits.utils.exchange_rates import satoshis_amount_as_fiat
|
||||
|
||||
return SatsToFiatResponse(
|
||||
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
|
||||
@@ -186,6 +176,7 @@ class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
|
||||
require_auth=False,
|
||||
)
|
||||
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
|
||||
from lnbits.settings import settings
|
||||
|
||||
return ServerHealthResponse(
|
||||
server_time=int(time.time()),
|
||||
@@ -307,6 +298,7 @@ class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||
async def verify_preimage(
|
||||
self, request: VerifyPreimageRequest
|
||||
) -> VerifyPreimageResponse:
|
||||
from lnbits.utils.crypto import verify_preimage
|
||||
|
||||
return VerifyPreimageResponse(
|
||||
valid=verify_preimage(request.preimage, request.payment_hash)
|
||||
@@ -326,6 +318,7 @@ class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||
async def random_secret_and_hash(
|
||||
self, request: RandomSecretAndHashRequest
|
||||
) -> RandomSecretAndHashResponse:
|
||||
from lnbits.utils.crypto import random_secret_and_hash
|
||||
|
||||
secret, payment_hash = random_secret_and_hash(request.length)
|
||||
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
|
||||
@@ -340,6 +333,7 @@ def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGro
|
||||
|
||||
|
||||
def _decode_bolt11(payment_request: str) -> Any:
|
||||
from lnbits import bolt11
|
||||
|
||||
return bolt11.decode(payment_request)
|
||||
|
||||
|
||||
@@ -35,9 +35,6 @@ async def send_extension_api_request(
|
||||
user_id: str | None,
|
||||
access_token: str | None,
|
||||
request: ExtensionApiRequest,
|
||||
*,
|
||||
timeout_ms: int | None = None,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> HttpResponse:
|
||||
if not user_id:
|
||||
raise PermissionError("Extension API requests require authentication.")
|
||||
@@ -58,7 +55,7 @@ async def send_extension_api_request(
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
|
||||
timeout=EXTENSION_API_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
@@ -67,10 +64,7 @@ async def send_extension_api_request(
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
@@ -183,34 +177,17 @@ def _extension_api_path(path: str) -> str:
|
||||
return urlunsplit(("", "", parts.path, parts.query, ""))
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> bytes:
|
||||
limit = (
|
||||
EXTENSION_API_MAX_RESPONSE_BYTES
|
||||
if max_response_bytes is None
|
||||
else max_response_bytes
|
||||
)
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
if size > EXTENSION_API_MAX_RESPONSE_BYTES:
|
||||
raise ValueError("Extension API response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
|
||||
if timeout_ms is None:
|
||||
return default
|
||||
if timeout_ms <= 0:
|
||||
return None
|
||||
return timeout_ms / 1000
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
|
||||
@@ -32,9 +32,6 @@ async def send_extension_http_request(
|
||||
extension_id: str,
|
||||
policies: list[Any],
|
||||
request: HttpRequest,
|
||||
*,
|
||||
timeout_ms: int | None = None,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> HttpResponse:
|
||||
allowed_origins = _allowed_origins(policies)
|
||||
origin = _request_origin(request.url)
|
||||
@@ -52,7 +49,7 @@ async def send_extension_http_request(
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
|
||||
timeout=HTTP_REQUEST_TIMEOUT_SECONDS,
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
@@ -61,10 +58,7 @@ async def send_extension_http_request(
|
||||
headers=headers,
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
response_body = await _read_limited_response(response)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
@@ -170,32 +164,17 @@ def _request_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return clean
|
||||
|
||||
|
||||
async def _read_limited_response(
|
||||
response: httpx.Response,
|
||||
*,
|
||||
max_response_bytes: int | None = None,
|
||||
) -> bytes:
|
||||
limit = (
|
||||
HTTP_MAX_RESPONSE_BYTES if max_response_bytes is None else max_response_bytes
|
||||
)
|
||||
async def _read_limited_response(response: httpx.Response) -> bytes:
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
if size > HTTP_MAX_RESPONSE_BYTES:
|
||||
raise ValueError("HTTP response is too large.")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _timeout_seconds(timeout_ms: int | None, default: float) -> float | None:
|
||||
if timeout_ms is None:
|
||||
return default
|
||||
if timeout_ms <= 0:
|
||||
return None
|
||||
return timeout_ms / 1000
|
||||
|
||||
|
||||
def _response_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
key: value
|
||||
|
||||
@@ -2,46 +2,32 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.core.services.extensions import get_wasm_runtime_limits_for_extension
|
||||
from lnbits.decorators import check_access_token, check_account_exists
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.config import WasmAPIRouteConfig
|
||||
from ..wasm.invoke import invoke_wasm_extension_export
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
|
||||
class WasmRequestBodyTooLargeError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmRoutePayload:
|
||||
data: dict[str, Any]
|
||||
request_bytes: int | None
|
||||
|
||||
|
||||
def register_wasm_extension_api_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
for route_config in extension.config.api_routes:
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
_add_wasm_extension_api_route(app, extension, route_config)
|
||||
|
||||
|
||||
def _add_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
route_config: dict[str, Any],
|
||||
) -> None:
|
||||
method = _wasm_extension_api_method(extension, route_config.method)
|
||||
route_path = _wasm_extension_api_path(extension, route_config.path)
|
||||
export_name = _wasm_extension_api_export(extension, route_config.export)
|
||||
path_params = route_config.path_params
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
route_path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
export_name = _wasm_extension_api_export(extension, route_config.get("export"))
|
||||
path_params = route_config.get("path_params") or {}
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
|
||||
if _has_route(app, route_path, method):
|
||||
return
|
||||
@@ -52,27 +38,14 @@ def _add_wasm_extension_api_route(
|
||||
access_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
limits = await get_wasm_runtime_limits_for_extension(extension.id)
|
||||
payload = await _read_api_payload(
|
||||
request,
|
||||
path_params,
|
||||
max_body_bytes=limits["wasm_runtime_max_request_bytes"],
|
||||
)
|
||||
payload = await _read_api_payload(request, path_params)
|
||||
return await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
payload.data,
|
||||
payload,
|
||||
user=account,
|
||||
access_token=access_token,
|
||||
trigger_type="http",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
request_id=request.headers.get("x-request-id"),
|
||||
request_bytes=payload.request_bytes,
|
||||
context_data={"origin": _request_origin(request)},
|
||||
)
|
||||
except WasmRequestBodyTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except PermissionError as exc:
|
||||
@@ -106,70 +79,22 @@ def _add_wasm_extension_api_route(
|
||||
async def _read_api_payload(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> WasmRoutePayload:
|
||||
) -> dict[str, Any]:
|
||||
payload = _read_api_path_params(request, path_params)
|
||||
payload.update(_read_api_query_params(request))
|
||||
request_bytes: int | None = None
|
||||
if request.method in {"POST", "PUT", "PATCH"}:
|
||||
body, request_bytes = await _read_json_object_with_size(
|
||||
request,
|
||||
max_body_bytes=max_body_bytes,
|
||||
)
|
||||
payload.update(body)
|
||||
return WasmRoutePayload(payload, request_bytes)
|
||||
payload.update(await _read_json_object(request))
|
||||
return payload
|
||||
|
||||
|
||||
async def _read_json_object(
|
||||
request: Request,
|
||||
*,
|
||||
max_body_bytes: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body, _ = await _read_json_object_with_size(
|
||||
request,
|
||||
max_body_bytes=(
|
||||
settings.wasm_runtime_max_request_bytes
|
||||
if max_body_bytes is None
|
||||
else max_body_bytes
|
||||
),
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
async def _read_json_object_with_size(
|
||||
request: Request,
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
body = await _read_limited_body(request, max_body_bytes=max_body_bytes)
|
||||
async def _read_json_object(request: Request) -> dict[str, Any]:
|
||||
body = await request.body()
|
||||
if not body:
|
||||
return {}, 0
|
||||
return {}
|
||||
value = json.loads(body)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("WASM extension API payload must be a JSON object.")
|
||||
return value, len(body)
|
||||
|
||||
|
||||
async def _read_limited_body(request: Request, *, max_body_bytes: int) -> bytes:
|
||||
content_length = _request_content_length(request)
|
||||
if _wasm_request_too_large(content_length, max_body_bytes):
|
||||
raise WasmRequestBodyTooLargeError(
|
||||
f"WASM extension request is too large: {content_length} bytes."
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in request.stream():
|
||||
if not chunk:
|
||||
continue
|
||||
size += len(chunk)
|
||||
if _wasm_request_too_large(size, max_body_bytes):
|
||||
raise WasmRequestBodyTooLargeError(
|
||||
f"WASM extension request is too large: {size} bytes."
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
return value
|
||||
|
||||
|
||||
def _read_api_path_params(
|
||||
@@ -187,32 +112,14 @@ def _read_api_query_params(request: Request) -> dict[str, Any]:
|
||||
return {_snake_to_camel(key): value for key, value in request.query_params.items()}
|
||||
|
||||
|
||||
def _request_content_length(request: Request) -> int | None:
|
||||
content_length = request.headers.get("content-length")
|
||||
if content_length and content_length.isdigit():
|
||||
return int(content_length)
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_request_too_large(size: int | None, max_body_bytes: int) -> bool:
|
||||
return size is not None and max_body_bytes > 0 and size > max_body_bytes
|
||||
|
||||
|
||||
def _request_origin(request: Request) -> str | None:
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return None
|
||||
return origin[:256]
|
||||
|
||||
|
||||
def _wasm_extension_api_export(extension: WasmExtension, export_name: Any) -> str:
|
||||
if not isinstance(export_name, str) or not export_name:
|
||||
raise ValueError(f"Invalid API export for WASM extension '{extension.id}'.")
|
||||
|
||||
for export in extension.exports:
|
||||
if export.name != export_name:
|
||||
if export.get("name") != export_name:
|
||||
continue
|
||||
if export.visibility in {"public", "authenticated"}:
|
||||
if export.get("visibility") in {"public", "authenticated"}:
|
||||
return export_name
|
||||
raise PermissionError(f"WASM export '{export_name}' is not callable over HTTP.")
|
||||
raise KeyError(f"WASM extension '{extension.id}' has no export '{export_name}'.")
|
||||
|
||||
@@ -15,7 +15,6 @@ from .ui import register_wasm_extension_ui_routes
|
||||
|
||||
def register_wasm_extension(app: FastAPI, ext_id: str) -> WasmExtension:
|
||||
loaded = load_wasm_extension(ext_id)
|
||||
core_app_extra.wasm_extension_registry.require_available(loaded)
|
||||
|
||||
warm_wasm_extension(loaded)
|
||||
mount_wasm_extension_static(app, loaded)
|
||||
|
||||
@@ -17,7 +17,6 @@ from lnbits.decorators import (
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_has_route,
|
||||
_path_template_pattern,
|
||||
_read_json_object,
|
||||
@@ -38,11 +37,13 @@ from .security import (
|
||||
def register_wasm_extension_ui_routes(app: FastAPI, extension: WasmExtension) -> None:
|
||||
_add_wasm_extension_frame_config_route(app, extension)
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.ui_routes):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
|
||||
entrypoint = _wasm_extension_entrypoint(extension, route_config.entrypoint)
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||
entrypoint = _wasm_extension_entrypoint(
|
||||
extension, route_config.get("entrypoint")
|
||||
)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
@@ -67,8 +68,6 @@ def _add_wasm_extension_frame_config_route(
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
body = await _read_json_object(request)
|
||||
except WasmRequestBodyTooLargeError as exc:
|
||||
raise HTTPException(status_code=413, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@@ -189,13 +188,13 @@ def _wasm_extension_bridge_api_routes(
|
||||
public: bool,
|
||||
) -> list[dict[str, str]]:
|
||||
routes: list[dict[str, str]] = []
|
||||
for route_config in extension.config.api_routes:
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
for route_config in extension.config.get("api_routes") or []:
|
||||
auth = _wasm_extension_route_auth(extension, route_config.get("auth"))
|
||||
if public and auth != "public":
|
||||
continue
|
||||
method = _wasm_extension_api_method(extension, route_config.method)
|
||||
path = _wasm_extension_api_path(extension, route_config.path)
|
||||
_wasm_extension_api_export(extension, route_config.export)
|
||||
method = _wasm_extension_api_method(extension, route_config.get("method"))
|
||||
path = _wasm_extension_api_path(extension, route_config.get("path"))
|
||||
_wasm_extension_api_export(extension, route_config.get("export"))
|
||||
routes.append(
|
||||
{
|
||||
"method": method,
|
||||
@@ -217,16 +216,16 @@ def _match_wasm_extension_ui_route(
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
for route_index, route_config in enumerate(extension.config.ui_routes):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.path)
|
||||
for route_index, route_config in enumerate(extension.config.get("ui_routes") or []):
|
||||
route_path = _wasm_extension_ui_route_path(extension, route_config.get("path"))
|
||||
route_params = _path_template_params(route_path, path)
|
||||
if route_params is None:
|
||||
continue
|
||||
|
||||
return {
|
||||
"frame_path": f"/ext-frame/{extension.id}/{route_index}",
|
||||
"auth": _wasm_extension_route_auth(extension, route_config.auth),
|
||||
"path_params": route_config.path_params,
|
||||
"auth": _wasm_extension_route_auth(extension, route_config.get("auth")),
|
||||
"path_params": route_config.get("path_params") or {},
|
||||
"route_params": route_params,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,6 @@ from __future__ import annotations
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from wasmtime import Config, Engine
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .loader import WasmExtension
|
||||
|
||||
|
||||
@@ -14,37 +10,27 @@ def warm_wasm_extension(extension: WasmExtension) -> None:
|
||||
_wasm_component(extension)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _wasm_engine(max_wasm_stack_bytes: int | None = None) -> Any:
|
||||
@lru_cache(maxsize=1)
|
||||
def _wasm_engine() -> Any:
|
||||
try:
|
||||
from wasmtime import Config, Engine
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WASM extension runtime is not installed. Install the 'wasmtime' "
|
||||
"Python package to run WASM extensions."
|
||||
) from exc
|
||||
|
||||
config = Config()
|
||||
config.wasm_component_model = True
|
||||
config.epoch_interruption = True
|
||||
config.consume_fuel = True
|
||||
stack_limit = (
|
||||
settings.wasm_runtime_max_wasm_stack_bytes
|
||||
if max_wasm_stack_bytes is None
|
||||
else max_wasm_stack_bytes
|
||||
)
|
||||
if stack_limit > 0:
|
||||
config.max_wasm_stack = stack_limit
|
||||
return Engine(config)
|
||||
|
||||
|
||||
def _wasm_component(
|
||||
extension: WasmExtension,
|
||||
limits: dict[str, int] | None = None,
|
||||
) -> Any:
|
||||
def _wasm_component(extension: WasmExtension) -> Any:
|
||||
stat = extension.module_path.stat()
|
||||
max_wasm_stack_bytes = (
|
||||
limits["wasm_runtime_max_wasm_stack_bytes"]
|
||||
if limits
|
||||
else settings.wasm_runtime_max_wasm_stack_bytes
|
||||
)
|
||||
return _cached_wasm_component(
|
||||
str(extension.module_path),
|
||||
stat.st_mtime_ns,
|
||||
stat.st_size,
|
||||
max_wasm_stack_bytes,
|
||||
)
|
||||
|
||||
|
||||
@@ -53,10 +39,7 @@ def _cached_wasm_component(
|
||||
module_path: str,
|
||||
mtime_ns: int,
|
||||
size: int,
|
||||
max_wasm_stack_bytes: int,
|
||||
) -> Any:
|
||||
from wasmtime import component
|
||||
|
||||
return component.Component.from_file(
|
||||
_wasm_engine(max_wasm_stack_bytes), module_path
|
||||
)
|
||||
return component.Component.from_file(_wasm_engine(), module_path)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
StrictBool,
|
||||
StrictStr,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
|
||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
|
||||
|
||||
class _StrictWasmModel(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class WasmExtensionExport(_StrictWasmModel):
|
||||
name: StrictStr
|
||||
visibility: Literal["authenticated", "event", "public"]
|
||||
|
||||
|
||||
class WasmRuntimeConfig(_StrictWasmModel):
|
||||
module: StrictStr
|
||||
wit: StrictStr | None = None
|
||||
world: StrictStr = ""
|
||||
exports: list[WasmExtensionExport] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WasmUIConfig(_StrictWasmModel):
|
||||
entrypoint: StrictStr | None = None
|
||||
sandbox: StrictBool | None = None
|
||||
|
||||
|
||||
class WasmSDKConfig(_StrictWasmModel):
|
||||
frontend_js: StrictStr | None = None
|
||||
|
||||
|
||||
class WasmUIRouteConfig(_StrictWasmModel):
|
||||
path: StrictStr
|
||||
entrypoint: StrictStr
|
||||
auth: Literal["public", "user"]
|
||||
path_params: dict[str, StrictStr] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmAPIRouteConfig(_StrictWasmModel):
|
||||
method: Literal["DELETE", "GET", "PATCH", "POST", "PUT"]
|
||||
path: StrictStr
|
||||
export: StrictStr
|
||||
auth: Literal["public", "user"]
|
||||
path_params: dict[str, StrictStr] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WasmEventsConfig(_StrictWasmModel):
|
||||
on_invoice_paid: StrictStr | None = Field(None, alias="onInvoicePaid")
|
||||
|
||||
|
||||
class WasmExtensionConfig(_StrictWasmModel):
|
||||
id: StrictStr
|
||||
name: StrictStr
|
||||
short_description: StrictStr
|
||||
tile: StrictStr | None = None
|
||||
version: StrictStr
|
||||
min_lnbits_version: StrictStr | None = None
|
||||
max_lnbits_version: StrictStr | None = None
|
||||
extension_type: Literal["wasm"]
|
||||
wasm: WasmRuntimeConfig
|
||||
events: WasmEventsConfig = Field(
|
||||
default_factory=lambda: WasmEventsConfig.parse_obj({})
|
||||
)
|
||||
ui: WasmUIConfig | None = None
|
||||
sdk: WasmSDKConfig | None = None
|
||||
ui_routes: list[WasmUIRouteConfig] = Field(default_factory=list)
|
||||
api_routes: list[WasmAPIRouteConfig] = Field(default_factory=list)
|
||||
permissions: list[ExtensionPermission] = Field(default_factory=list)
|
||||
|
||||
|
||||
def parse_wasm_extension_config(
|
||||
ext_id: str,
|
||||
config: dict[str, Any],
|
||||
) -> WasmExtensionConfig:
|
||||
validate_wasm_extension_config_id(ext_id, config)
|
||||
try:
|
||||
return WasmExtensionConfig.parse_obj(config)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid WASM extension config for '{ext_id}': {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_wasm_extension_config_id(
|
||||
ext_id: str,
|
||||
config: dict[str, Any] | WasmExtensionConfig,
|
||||
) -> str:
|
||||
if not _EXTENSION_ID_RE.fullmatch(ext_id):
|
||||
raise ValueError(f"Invalid WASM extension id '{ext_id}'.")
|
||||
|
||||
config_id = (
|
||||
config.id if isinstance(config, WasmExtensionConfig) else config.get("id")
|
||||
)
|
||||
if not isinstance(config_id, str) or not config_id:
|
||||
raise ValueError(f"WASM extension '{ext_id}' config must define id.")
|
||||
if config_id != ext_id:
|
||||
raise ValueError(
|
||||
f"WASM extension id mismatch: installed as '{ext_id}' "
|
||||
f"but config declares '{config_id}'."
|
||||
)
|
||||
return config_id
|
||||
@@ -1,13 +1,9 @@
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_installed_extension
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
|
||||
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
|
||||
|
||||
|
||||
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
||||
@@ -31,18 +27,14 @@ async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
||||
return
|
||||
|
||||
try:
|
||||
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
|
||||
from lnbits.core.wasm_ext.wasm.invoke import invoke_wasm_extension_export
|
||||
|
||||
await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
_wasm_invoice_paid_payload(payment),
|
||||
context="event",
|
||||
owner_id=owner_id,
|
||||
trigger_type="event",
|
||||
event_type="invoice_paid",
|
||||
wallet_id=payment.wallet_id,
|
||||
payment_hash=payment.payment_hash,
|
||||
checking_id=payment.checking_id,
|
||||
owner_id=await _wasm_invoice_paid_owner_id(extension, payment),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
@@ -62,10 +54,12 @@ def _payment_extension_id(payment: Any) -> str | None:
|
||||
|
||||
async def _wasm_invoice_paid_owner_id(extension: Any, payment: Any) -> str | None:
|
||||
source_id = _payment_source_id(payment)
|
||||
source_tables = await _wasm_public_invoice_source_tables(extension.id)
|
||||
source_tables = _wasm_public_invoice_source_tables(extension.config)
|
||||
if not source_id or not source_tables:
|
||||
return None
|
||||
|
||||
from lnbits.core.wasm_ext.storage.crud import storage_get_row_owner_id
|
||||
|
||||
for source_table in source_tables:
|
||||
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
|
||||
if owner_id:
|
||||
@@ -79,31 +73,14 @@ def _payment_source_id(payment: Any) -> str | None:
|
||||
return source_id if isinstance(source_id, str) and source_id else None
|
||||
|
||||
|
||||
async def _wasm_public_invoice_source_tables(extension_id: str) -> list[str]:
|
||||
installed_extension = await get_installed_extension(extension_id)
|
||||
if not installed_extension:
|
||||
return []
|
||||
return _wasm_public_invoice_source_tables_from_permissions(
|
||||
installed_extension.permissions
|
||||
)
|
||||
|
||||
|
||||
def _wasm_public_invoice_source_tables_from_permissions(
|
||||
permissions: Iterable[Any],
|
||||
) -> list[str]:
|
||||
def _wasm_public_invoice_source_tables(config: dict[str, Any]) -> list[str]:
|
||||
permissions = config.get("permissions") or []
|
||||
for permission in permissions:
|
||||
permission_id = (
|
||||
permission.get("id")
|
||||
if isinstance(permission, dict)
|
||||
else getattr(permission, "id", None)
|
||||
)
|
||||
if permission_id != "wallet.create_invoice_public":
|
||||
if not isinstance(permission, dict):
|
||||
continue
|
||||
policies = (
|
||||
permission.get("policies")
|
||||
if isinstance(permission, dict)
|
||||
else getattr(permission, "policies", None)
|
||||
)
|
||||
if permission.get("id") != "wallet.create_invoice_public":
|
||||
continue
|
||||
policies = permission.get("policies")
|
||||
if not isinstance(policies, list):
|
||||
return []
|
||||
return [
|
||||
@@ -116,14 +93,16 @@ def _wasm_public_invoice_source_tables_from_permissions(
|
||||
return []
|
||||
|
||||
|
||||
def _wasm_invoice_paid_export(config: Any) -> str | None:
|
||||
return config.events.on_invoice_paid
|
||||
def _wasm_invoice_paid_export(config: dict[str, Any]) -> str | None:
|
||||
events = config.get("events") or {}
|
||||
export_name = events.get("onInvoicePaid")
|
||||
return export_name if isinstance(export_name, str) and export_name else None
|
||||
|
||||
|
||||
def _is_wasm_event_export(extension: Any, export_name: str) -> bool:
|
||||
for export in extension.exports:
|
||||
if export.name == export_name:
|
||||
return export.visibility == "event"
|
||||
if export.get("name") == export_name:
|
||||
return export.get("visibility") == "event"
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from wasmtime import component
|
||||
|
||||
from ..api.models import EmptyRequest
|
||||
from ..api.registry import list_extension_api_methods
|
||||
from ..api.runtime import ExtensionAPIHost
|
||||
@@ -75,6 +73,7 @@ def _component_payload_to_dict(value: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
|
||||
from wasmtime import component
|
||||
|
||||
record = component.Record()
|
||||
for key, item in value.items():
|
||||
|
||||
@@ -5,11 +5,8 @@ import json
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from wasmtime import Store, WasiConfig, component
|
||||
|
||||
from lnbits.core.crud.extensions import get_installed_extension
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..api.host import ExtensionHostAPI
|
||||
from ..api.runtime import ExtensionAPIHost
|
||||
@@ -17,9 +14,6 @@ from .component import _wasm_component, _wasm_engine
|
||||
from .host import add_extension_host_imports
|
||||
from .loader import WasmExtension
|
||||
|
||||
_WASM_EPOCH_DEADLINE_TICKS = 1_000_000_000
|
||||
_WASM_UNLIMITED_FUEL = 2**63 - 1
|
||||
|
||||
|
||||
async def invoke_wasm_extension_export(
|
||||
ext_id: str,
|
||||
@@ -30,52 +24,9 @@ async def invoke_wasm_extension_export(
|
||||
access_token: str | None = None,
|
||||
context: str = "user",
|
||||
owner_id: str | None = None,
|
||||
trigger_type: str = "unknown",
|
||||
request_id: str | None = None,
|
||||
method: str | None = None,
|
||||
path: str | None = None,
|
||||
event_type: str | None = None,
|
||||
wallet_id: str | None = None,
|
||||
payment_hash: str | None = None,
|
||||
checking_id: str | None = None,
|
||||
request_bytes: int | None = None,
|
||||
context_data: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from lnbits.core.services.extensions import (
|
||||
finish_wasm_invocation,
|
||||
get_wasm_invocation_stop_reason,
|
||||
resolve_wasm_runtime_limits,
|
||||
start_wasm_invocation,
|
||||
stop_wasm_invocation,
|
||||
wasm_invocation_stop_requested,
|
||||
)
|
||||
|
||||
extension = _get_registered_extension(ext_id)
|
||||
installed_extension = await _active_installed_extension(extension)
|
||||
permissions = installed_extension.permissions
|
||||
limits = resolve_wasm_runtime_limits(installed_extension)
|
||||
payload = payload or {}
|
||||
payload_size = _json_size(payload)
|
||||
effective_request_bytes = (
|
||||
request_bytes if request_bytes is not None else payload_size
|
||||
)
|
||||
_check_wasm_request_size(effective_request_bytes, limits)
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id=extension.id,
|
||||
export_name=export_name,
|
||||
trigger_type=trigger_type,
|
||||
user_id=_user_id(user) or owner_id,
|
||||
wallet_id=wallet_id,
|
||||
request_id=request_id,
|
||||
method=method,
|
||||
path=path,
|
||||
event_type=event_type,
|
||||
payment_hash=payment_hash,
|
||||
checking_id=checking_id,
|
||||
request_bytes=effective_request_bytes,
|
||||
context={"host_context": context, **(context_data or {})},
|
||||
runtime_limits=limits,
|
||||
)
|
||||
permissions = await _extension_permissions(extension)
|
||||
api = ExtensionHostAPI(
|
||||
extension.id,
|
||||
permissions,
|
||||
@@ -83,91 +34,17 @@ async def invoke_wasm_extension_export(
|
||||
access_token=access_token,
|
||||
context=context,
|
||||
owner_id=owner_id,
|
||||
wallet_id=wallet_id,
|
||||
invocation_id=invocation.id,
|
||||
runtime_limits=limits,
|
||||
)
|
||||
event_loop = asyncio.get_running_loop()
|
||||
thread_task = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
_invoke_wasm_extension_export_sync,
|
||||
extension,
|
||||
export_name,
|
||||
payload,
|
||||
api,
|
||||
event_loop,
|
||||
invocation.id,
|
||||
limits,
|
||||
)
|
||||
|
||||
return await asyncio.to_thread(
|
||||
_invoke_wasm_extension_export_sync,
|
||||
extension,
|
||||
export_name,
|
||||
payload or {},
|
||||
api,
|
||||
event_loop,
|
||||
)
|
||||
max_execution_ms = limits["wasm_runtime_max_execution_ms"]
|
||||
timed_out = False
|
||||
finished = False
|
||||
|
||||
try:
|
||||
try:
|
||||
if max_execution_ms > 0:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(thread_task),
|
||||
timeout=max_execution_ms / 1000,
|
||||
)
|
||||
else:
|
||||
result = await thread_task
|
||||
except asyncio.TimeoutError as exc:
|
||||
timed_out = True
|
||||
stop_reason = "WASM execution time limit exceeded."
|
||||
await stop_wasm_invocation(invocation.id, reason=stop_reason)
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(thread_task),
|
||||
timeout=2,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
await finish_wasm_invocation(
|
||||
invocation.id,
|
||||
status="timeout",
|
||||
error_type="TimeoutError",
|
||||
error_message=stop_reason,
|
||||
stop_reason=stop_reason,
|
||||
)
|
||||
finished = True
|
||||
raise TimeoutError(stop_reason) from exc
|
||||
|
||||
status = (
|
||||
"timeout"
|
||||
if timed_out
|
||||
else (
|
||||
"stopped"
|
||||
if wasm_invocation_stop_requested(invocation.id)
|
||||
else "completed"
|
||||
)
|
||||
)
|
||||
await finish_wasm_invocation(
|
||||
invocation.id,
|
||||
status=status,
|
||||
response_bytes=_json_size(result),
|
||||
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
|
||||
)
|
||||
finished = True
|
||||
return result
|
||||
except Exception as exc:
|
||||
if not finished:
|
||||
await finish_wasm_invocation(
|
||||
invocation.id,
|
||||
status=(
|
||||
"timeout"
|
||||
if timed_out
|
||||
else (
|
||||
"stopped"
|
||||
if wasm_invocation_stop_requested(invocation.id)
|
||||
else "failed"
|
||||
)
|
||||
),
|
||||
error_type=exc.__class__.__name__,
|
||||
error_message=str(exc),
|
||||
stop_reason=get_wasm_invocation_stop_reason(invocation.id),
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def _invoke_wasm_extension_export_sync(
|
||||
@@ -176,24 +53,24 @@ def _invoke_wasm_extension_export_sync(
|
||||
payload: Mapping[str, Any],
|
||||
api: ExtensionHostAPI,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
invocation_id: str,
|
||||
limits: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
from lnbits.core.services.extensions import attach_wasm_invocation_runtime
|
||||
try:
|
||||
from wasmtime import Store, WasiConfig, component
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WASM extension runtime is not installed. Install the 'wasmtime' "
|
||||
"Python package to run WASM extensions."
|
||||
) from exc
|
||||
|
||||
engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"])
|
||||
engine = _wasm_engine()
|
||||
store = Store(engine)
|
||||
_set_store_limits(store, limits)
|
||||
_set_store_fuel(store, limits)
|
||||
store.set_epoch_deadline(_WASM_EPOCH_DEADLINE_TICKS)
|
||||
attach_wasm_invocation_runtime(invocation_id, engine=engine, store=store)
|
||||
store.set_wasi(WasiConfig())
|
||||
|
||||
linker = component.Linker(engine)
|
||||
linker.add_wasip2()
|
||||
add_extension_host_imports(linker, ExtensionAPIHost(api), event_loop)
|
||||
|
||||
wasm_component = _wasm_component(extension, limits)
|
||||
wasm_component = _wasm_component(extension)
|
||||
instance = linker.instantiate(store, wasm_component)
|
||||
function = instance.get_func(store, export_name)
|
||||
if not function:
|
||||
@@ -203,17 +80,21 @@ def _invoke_wasm_extension_export_sync(
|
||||
|
||||
result = function(store, json.dumps(payload))
|
||||
function.post_return(store)
|
||||
return _parse_wasm_export_result(result, limits)
|
||||
return _parse_wasm_export_result(extension, result)
|
||||
|
||||
|
||||
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> dict[str, Any]:
|
||||
def _parse_wasm_export_result(extension: WasmExtension, value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
if not isinstance(value, str):
|
||||
return {"ok": True, "data": value}
|
||||
|
||||
max_response_bytes = limits["wasm_runtime_max_response_bytes"]
|
||||
if max_response_bytes > 0:
|
||||
max_response_bytes = (
|
||||
(extension.config.get("wasm") or {})
|
||||
.get("resource_limits", {})
|
||||
.get("max_response_bytes")
|
||||
)
|
||||
if isinstance(max_response_bytes, int):
|
||||
response_size = len(value.encode())
|
||||
if response_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
@@ -233,48 +114,12 @@ def _get_registered_extension(ext_id: str) -> WasmExtension:
|
||||
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
|
||||
|
||||
|
||||
async def _active_installed_extension(extension: WasmExtension) -> Any:
|
||||
async def _extension_permissions(extension: WasmExtension) -> list[Any]:
|
||||
installed_extension = await get_installed_extension(extension.id)
|
||||
if (
|
||||
not installed_extension
|
||||
or settings.lnbits_extensions_deactivate_all
|
||||
or not installed_extension.active
|
||||
):
|
||||
raise PermissionError(f"WASM extension '{extension.id}' is deactivated.")
|
||||
return installed_extension
|
||||
if not installed_extension:
|
||||
return []
|
||||
return installed_extension.permissions
|
||||
|
||||
|
||||
def _user_id(user: Any | None) -> str | None:
|
||||
return getattr(user, "id", None) if user else None
|
||||
|
||||
|
||||
def _set_store_limits(store: Any, limits: dict[str, int]) -> None:
|
||||
store.set_limits(
|
||||
memory_size=_wasm_limit(limits["wasm_runtime_max_memory_bytes"]),
|
||||
table_elements=_wasm_limit(limits["wasm_runtime_max_table_elements"]),
|
||||
instances=_wasm_limit(limits["wasm_runtime_max_instances"]),
|
||||
tables=_wasm_limit(limits["wasm_runtime_max_tables"]),
|
||||
memories=_wasm_limit(limits["wasm_runtime_max_memories"]),
|
||||
)
|
||||
|
||||
|
||||
def _set_store_fuel(store: Any, limits: dict[str, int]) -> None:
|
||||
store.set_fuel(
|
||||
limits["wasm_runtime_max_fuel"]
|
||||
if limits["wasm_runtime_max_fuel"] > 0
|
||||
else _WASM_UNLIMITED_FUEL
|
||||
)
|
||||
|
||||
|
||||
def _check_wasm_request_size(request_bytes: int, limits: dict[str, int]) -> None:
|
||||
max_request_bytes = limits["wasm_runtime_max_request_bytes"]
|
||||
if max_request_bytes > 0 and request_bytes > max_request_bytes:
|
||||
raise ValueError(f"WASM extension request is too large: {request_bytes} bytes.")
|
||||
|
||||
|
||||
def _wasm_limit(value: int) -> int:
|
||||
return value if value > 0 else -1
|
||||
|
||||
|
||||
def _json_size(value: Any) -> int:
|
||||
return len(json.dumps(value, default=str).encode())
|
||||
|
||||
@@ -5,11 +5,6 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from lnbits.core.wasm_ext.wasm.config import (
|
||||
WasmExtensionConfig,
|
||||
WasmExtensionExport,
|
||||
parse_wasm_extension_config,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
@@ -22,13 +17,13 @@ class WasmExtension:
|
||||
module_path: Path
|
||||
wit_path: Path | None
|
||||
world: str
|
||||
exports: list[WasmExtensionExport]
|
||||
config: WasmExtensionConfig
|
||||
host_api: str
|
||||
exports: list[dict[str, Any]]
|
||||
config: dict[str, Any]
|
||||
|
||||
|
||||
def is_wasm_extension_id(ext_id: str) -> bool:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
@@ -37,38 +32,36 @@ def is_wasm_extension_dir(ext_dir: Path) -> bool:
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | None:
|
||||
def load_wasm_extension_config(ext_id: str) -> dict[str, Any] | None:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
if not config or config.get("extension_type") != "wasm":
|
||||
return None
|
||||
return parse_wasm_extension_config(ext_id, config)
|
||||
return _load_json(ext_dir / "config.json")
|
||||
|
||||
|
||||
def load_wasm_extension(ext_id: str) -> WasmExtension:
|
||||
ext_dir = Path(settings.lnbits_extensions_path, "extensions", ext_id)
|
||||
raw_config = _load_json(ext_dir / "config.json")
|
||||
if not raw_config:
|
||||
config = load_wasm_extension_config(ext_id)
|
||||
if not config:
|
||||
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
|
||||
if raw_config.get("extension_type") != "wasm":
|
||||
if config.get("extension_type") != "wasm":
|
||||
raise ValueError(f"Extension '{ext_id}' is not a WASM extension.")
|
||||
config = parse_wasm_extension_config(ext_id, raw_config)
|
||||
|
||||
module_path = _extension_path(ext_dir, config.wasm.module)
|
||||
wit_path = _optional_extension_path(ext_dir, config.wasm.wit)
|
||||
wasm_config = config.get("wasm") or {}
|
||||
module_path = _extension_path(ext_dir, wasm_config.get("module"))
|
||||
wit_path = _optional_extension_path(ext_dir, wasm_config.get("wit"))
|
||||
_check_wasm_module(module_path)
|
||||
if wit_path and not wit_path.is_file():
|
||||
raise FileNotFoundError(f"WIT file not found: {wit_path}")
|
||||
|
||||
return WasmExtension(
|
||||
id=config.id,
|
||||
name=config.name,
|
||||
version=config.version,
|
||||
id=config.get("id") or ext_id,
|
||||
name=config.get("name") or ext_id,
|
||||
version=config.get("version") or "0.0",
|
||||
root_path=ext_dir,
|
||||
module_path=module_path,
|
||||
wit_path=wit_path,
|
||||
world=config.wasm.world,
|
||||
exports=config.wasm.exports,
|
||||
world=wasm_config.get("world") or "",
|
||||
host_api=wasm_config.get("host_api") or "lnbits.core.wasm_ext.ExtensionHostAPI",
|
||||
exports=wasm_config.get("exports") or [],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
+1
-36
@@ -60,7 +60,6 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
lnbits_user_default_extensions: list[str] = Field(default=[])
|
||||
lnbits_extensions_deactivate_all: bool = Field(default=False)
|
||||
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
|
||||
lnbits_wasm_invocation_retention_days: int = Field(default=7, ge=0)
|
||||
lnbits_extensions_reviews_url: str = Field(
|
||||
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
|
||||
description="""
|
||||
@@ -82,33 +81,6 @@ class ExtensionsSettings(LNbitsSettings):
|
||||
return Path(settings.lnbits_data_folder, "extensions_builder")
|
||||
|
||||
|
||||
class WasmRuntimeLimits(LNbitsSettings):
|
||||
# 0 disables the limit. Installed WASM extensions may override these defaults.
|
||||
wasm_runtime_max_memory_bytes: int = Field(default=64 * 1024 * 1024, ge=0)
|
||||
wasm_runtime_max_execution_ms: int = Field(default=5_000, ge=0)
|
||||
wasm_runtime_max_fuel: int = Field(default=100_000_000, ge=0)
|
||||
wasm_runtime_max_response_bytes: int = Field(default=1024 * 1024, ge=0)
|
||||
wasm_runtime_max_request_bytes: int = Field(default=1024 * 1024, ge=0)
|
||||
wasm_runtime_max_wasm_stack_bytes: int = Field(default=1024 * 1024, ge=0)
|
||||
|
||||
wasm_runtime_max_table_elements: int = Field(default=10_000, ge=0)
|
||||
wasm_runtime_max_instances: int = Field(default=8, ge=0)
|
||||
wasm_runtime_max_tables: int = Field(default=10, ge=0)
|
||||
wasm_runtime_max_memories: int = Field(default=1, ge=0)
|
||||
|
||||
wasm_runtime_max_concurrent_invocations: int = Field(default=16, ge=0)
|
||||
wasm_runtime_max_concurrent_invocations_per_extension: int = Field(default=4, ge=0)
|
||||
wasm_runtime_max_concurrent_invocations_per_user: int = Field(default=4, ge=0)
|
||||
|
||||
wasm_runtime_max_host_calls: int = Field(default=1_000, ge=0)
|
||||
wasm_runtime_max_http_calls: int = Field(default=20, ge=0)
|
||||
wasm_runtime_max_storage_calls: int = Field(default=100, ge=0)
|
||||
wasm_runtime_max_wallet_calls: int = Field(default=20, ge=0)
|
||||
|
||||
wasm_runtime_http_timeout_ms: int = Field(default=5_000, ge=0)
|
||||
wasm_runtime_max_http_response_bytes: int = Field(default=1024 * 1024, ge=0)
|
||||
|
||||
|
||||
class ExtensionsInstallSettings(LNbitsSettings):
|
||||
lnbits_extensions_default_install: list[str] = Field(default=[])
|
||||
# required due to GitHUb rate-limit
|
||||
@@ -390,8 +362,6 @@ class ExchangeProvidersSettings(LNbitsSettings):
|
||||
lnbits_exchange_rate_cache_seconds: int = Field(default=60, ge=0)
|
||||
lnbits_exchange_history_size: int = Field(default=60, ge=0)
|
||||
lnbits_exchange_history_refresh_interval_seconds: int = Field(default=300, ge=0)
|
||||
lnbits_price_aggregator_enabled: bool = Field(default=True)
|
||||
lnbits_price_aggregator_url: str = Field(default="https://price.lnbits.com")
|
||||
|
||||
lnbits_exchange_rate_providers: list[ExchangeRateProvider] = Field(
|
||||
default=[
|
||||
@@ -617,7 +587,6 @@ class PhoenixdFundingSource(LNbitsSettings):
|
||||
phoenixd_api_password: str | None = Field(default=None)
|
||||
phoenixd_data_dir: str | None = Field(default=None)
|
||||
phoenixd_mnemonic: str | None = Field(default=None)
|
||||
phoenixd_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
|
||||
|
||||
class AlbyFundingSource(LNbitsSettings):
|
||||
@@ -642,7 +611,6 @@ class SparkL2FundingSource(LNbitsSettings):
|
||||
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
|
||||
spark_l2_external_api_key: str | None = Field(default=None)
|
||||
spark_l2_mnemonic: str | None = Field(default=None)
|
||||
spark_l2_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
|
||||
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
|
||||
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
|
||||
@@ -680,7 +648,6 @@ class BoltzFundingSource(LNbitsSettings):
|
||||
boltz_client_password: str = Field(default="")
|
||||
boltz_client_cert: str | None = Field(default=None)
|
||||
boltz_mnemonic: str | None = Field(default=None)
|
||||
boltz_mnemonic_backup_confirmed: bool = Field(default=False)
|
||||
|
||||
|
||||
class StrikeFundingSource(LNbitsSettings):
|
||||
@@ -1034,7 +1001,6 @@ class AuditSettings(LNbitsSettings):
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
WasmRuntimeLimits,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
AssetSettings,
|
||||
@@ -1106,12 +1072,11 @@ class EnvSettings(LNbitsSettings):
|
||||
log_rotation: str = Field(default="100 MB")
|
||||
log_retention: str = Field(default="3 months")
|
||||
first_install_token: str | None = Field(default=None)
|
||||
|
||||
cleanup_wallets_days: int = Field(default=90, ge=0)
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
lnbits_max_users: int = Field(default=0, ge=0)
|
||||
lnbits_max_extensions: int = Field(default=0, ge=0)
|
||||
task_heart_beat_verbose: bool = Field(default=False)
|
||||
task_heart_beat_interval: int = Field(default=30)
|
||||
|
||||
@property
|
||||
def has_default_extension_path(self) -> bool:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -520,6 +520,7 @@ window.localisation.en = {
|
||||
extension_cost: 'This release requires a payment of minimum {cost} sats.',
|
||||
extension_paid_sats: 'You have already paid {paid_sats} sats.',
|
||||
extension_permissions_title: 'Grant extension permissions',
|
||||
extension_permissions_request: 'This extension requests these permissions:',
|
||||
extension_permissions_grant_install: 'Grant and install',
|
||||
extension_permissions_high_risk_warning:
|
||||
'This extension requests permissions that can move funds.',
|
||||
@@ -530,25 +531,41 @@ window.localisation.en = {
|
||||
'Can spend funds from wallets available to your account.',
|
||||
extension_permission_warning_extension_api_request_write:
|
||||
'Can write data or trigger actions in approved extensions.',
|
||||
extension_permission_warning_http_request:
|
||||
'Can send data to external services.',
|
||||
extension_permission_ext_storage_read: 'Read extension storage',
|
||||
extension_permission_ext_storage_read_public: 'Read public extension storage',
|
||||
extension_permission_ext_storage_write: 'Write extension storage',
|
||||
extension_permission_ext_storage_read_write: 'Read & Write extension storage',
|
||||
extension_permission_extension_api_request: 'Use other extensions',
|
||||
extension_permission_extension_api_request_desc:
|
||||
'Call approved installed extensions using your account permissions.',
|
||||
extension_permission_extension_api_request_extensions: 'Allowed extensions',
|
||||
extension_permission_access_read: 'Read',
|
||||
extension_permission_access_write: 'Write',
|
||||
extension_permission_http_request: 'Connect to external websites',
|
||||
extension_permission_http_request_desc:
|
||||
'Make HTTP requests to approved external hosts.',
|
||||
extension_permission_http_request_hosts: 'Allowed hosts',
|
||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||
extension_permission_utils_basic_desc:
|
||||
'Use public currency conversion, server health, and Lightning invoice helper functions.',
|
||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||
extension_permission_ui_camera_scan_qr_desc:
|
||||
'Use the LNbits scanner to read QR codes when you choose to scan.',
|
||||
extension_permission_payments_watch: 'Watch payments',
|
||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||
extension_permission_wallet_create_invoice_public:
|
||||
'Create Lightning invoices from public pages',
|
||||
extension_permission_wallet_create_invoice_public_desc:
|
||||
'Create incoming Lightning invoices from public pages.',
|
||||
extension_permission_wallet_balance_read: 'View wallet balances',
|
||||
extension_permission_wallet_balance_read_desc:
|
||||
'Read balances of wallets available to your account.',
|
||||
extension_permission_wallet_list: 'List wallets',
|
||||
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
||||
extension_permission_wallet_pay_invoice_desc:
|
||||
'Send Lightning payments from wallets available to your account.',
|
||||
create_extension: 'Create Extension',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
|
||||
@@ -62,6 +62,12 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
mounted() {
|
||||
this.getExchangeRateHistory()
|
||||
},
|
||||
created() {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (hash === 'exchange_providers') {
|
||||
this.showExchangeProvidersTab(hash)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getDefaultSetting(fieldName) {
|
||||
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
||||
@@ -121,21 +127,18 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
this.exchangeData.showTickerConversion = true
|
||||
},
|
||||
initExchangeChart(data) {
|
||||
if (this.exchangeRatesChart) {
|
||||
this.exchangeRatesChart.destroy()
|
||||
this.exchangeRatesChart = null
|
||||
}
|
||||
const xValues = data.map(d =>
|
||||
this.utils.formatTimestamp(d.timestamp, 'HH:mm')
|
||||
)
|
||||
const exchanges = this.formData.lnbits_price_aggregator_enabled
|
||||
? [{name: 'Aggregator'}]
|
||||
: [...this.formData.lnbits_exchange_rate_providers, {name: 'LNbits'}]
|
||||
const exchanges = [
|
||||
...this.formData.lnbits_exchange_rate_providers,
|
||||
{name: 'LNbits'}
|
||||
]
|
||||
const datasets = exchanges.map(exchange => ({
|
||||
label: exchange.name,
|
||||
data: data.map(d => d.rates[exchange.name]),
|
||||
pointStyle: true,
|
||||
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
|
||||
borderWidth: exchange.name === 'LNbits' ? 4 : 1,
|
||||
tension: 0.4
|
||||
}))
|
||||
this.exchangeRatesChart = new Chart(
|
||||
@@ -145,11 +148,7 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Bitcoin Price History'
|
||||
display: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
window.app.component('lnbits-admin-funding-seed-backup', {
|
||||
props: ['active', 'is-super-user', 'form-data', 'settings'],
|
||||
template: '#lnbits-admin-funding-seed-backup',
|
||||
data() {
|
||||
return {
|
||||
dialog: {
|
||||
show: false,
|
||||
step: 1,
|
||||
seed: '',
|
||||
visible: false,
|
||||
challenge: [],
|
||||
answers: {},
|
||||
error: '',
|
||||
confirmField: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
active(isActive) {
|
||||
if (isActive) {
|
||||
this.openIfRequired()
|
||||
}
|
||||
},
|
||||
'formData.lnbits_backend_wallet_class'(walletClass, previousWalletClass) {
|
||||
const source = this.seedBackupSource(walletClass)
|
||||
if (previousWalletClass && source && this.formData[source.seedField]) {
|
||||
this.formData[source.confirmField] = false
|
||||
}
|
||||
this.openIfRequired()
|
||||
},
|
||||
'formData.boltz_mnemonic'() {
|
||||
this.formData.boltz_mnemonic_backup_confirmed =
|
||||
this.formData.boltz_mnemonic === this.settings.boltz_mnemonic
|
||||
? this.settings.boltz_mnemonic_backup_confirmed
|
||||
: false
|
||||
this.openIfRequired()
|
||||
},
|
||||
'formData.phoenixd_mnemonic'() {
|
||||
this.formData.phoenixd_mnemonic_backup_confirmed =
|
||||
this.formData.phoenixd_mnemonic === this.settings.phoenixd_mnemonic
|
||||
? this.settings.phoenixd_mnemonic_backup_confirmed
|
||||
: false
|
||||
this.openIfRequired()
|
||||
},
|
||||
'formData.spark_l2_mnemonic'() {
|
||||
this.formData.spark_l2_mnemonic_backup_confirmed =
|
||||
this.formData.spark_l2_mnemonic === this.settings.spark_l2_mnemonic
|
||||
? this.settings.spark_l2_mnemonic_backup_confirmed
|
||||
: false
|
||||
this.openIfRequired()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seedWords() {
|
||||
return this.dialog.seed
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((word, index) => ({index, word}))
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.openIfRequired()
|
||||
},
|
||||
methods: {
|
||||
seedBackupSource(walletClass = this.formData.lnbits_backend_wallet_class) {
|
||||
if (walletClass === 'BoltzWallet') {
|
||||
return {
|
||||
seedField: 'boltz_mnemonic',
|
||||
confirmField: 'boltz_mnemonic_backup_confirmed'
|
||||
}
|
||||
}
|
||||
if (walletClass === 'PhoenixdWallet') {
|
||||
return {
|
||||
seedField: 'phoenixd_mnemonic',
|
||||
confirmField: 'phoenixd_mnemonic_backup_confirmed'
|
||||
}
|
||||
}
|
||||
if (walletClass === 'SparkL2Wallet') {
|
||||
return {
|
||||
seedField: 'spark_l2_mnemonic',
|
||||
confirmField: 'spark_l2_mnemonic_backup_confirmed'
|
||||
}
|
||||
}
|
||||
},
|
||||
openIfRequired() {
|
||||
if (!this.active || !this.isSuperUser) return
|
||||
|
||||
const source = this.seedBackupSource()
|
||||
if (!source) return
|
||||
|
||||
const seed = (this.formData[source.seedField] || '').trim()
|
||||
const confirmed = this.formData[source.confirmField]
|
||||
if (!seed || confirmed || this.dialog.show) return
|
||||
|
||||
this.dialog = {
|
||||
show: true,
|
||||
step: 1,
|
||||
seed,
|
||||
visible: false,
|
||||
challenge: [],
|
||||
answers: {},
|
||||
error: '',
|
||||
confirmField: source.confirmField
|
||||
}
|
||||
},
|
||||
prepareChallenge() {
|
||||
const words = this.dialog.seed.split(/\s+/).filter(Boolean)
|
||||
const count = Math.min(4, words.length)
|
||||
const indexes = _.shuffle([...Array(words.length).keys()]).slice(0, count)
|
||||
this.dialog.challenge = indexes
|
||||
.sort((a, b) => a - b)
|
||||
.map(index => ({index, word: words[index]}))
|
||||
this.dialog.answers = {}
|
||||
this.dialog.error = ''
|
||||
this.dialog.step = 2
|
||||
},
|
||||
submitChallenge() {
|
||||
const isValid = this.dialog.challenge.every(({index, word}) => {
|
||||
const answer = this.dialog.answers[index] || ''
|
||||
return answer.trim().toLowerCase() === word.toLowerCase()
|
||||
})
|
||||
if (!isValid) {
|
||||
this.dialog.error =
|
||||
'One or more words are incorrect. Check your backup and try again.'
|
||||
return
|
||||
}
|
||||
|
||||
const field = this.dialog.confirmField
|
||||
LNbits.api
|
||||
.request(
|
||||
'PATCH',
|
||||
'/admin/api/v1/settings',
|
||||
this.g.user.wallets[0].adminkey,
|
||||
{
|
||||
[field]: true
|
||||
}
|
||||
)
|
||||
.then(() => {
|
||||
this.formData[field] = true
|
||||
this.settings[field] = true
|
||||
this.dialog.show = false
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Seed backup confirmed',
|
||||
icon: 'check'
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
window.app.component('lnbits-admin-funding', {
|
||||
props: ['active', 'is-super-user', 'form-data', 'settings'],
|
||||
props: ['is-super-user', 'form-data', 'settings'],
|
||||
template: '#lnbits-admin-funding',
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
window.app.component('lnbits-admin-wasm-limit-config', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-wasm-limit-config',
|
||||
data() {
|
||||
return {
|
||||
selectedWasmExtensionId: null,
|
||||
wasmExtensionLimitDraft: {},
|
||||
wasmExtensionLimitsSaving: false,
|
||||
wasmRuntimeLimitExtensions: [],
|
||||
wasmRuntimeLimitExtensionsLoading: false,
|
||||
wasmLimitInfoDialog: {
|
||||
show: false,
|
||||
title: '',
|
||||
details: ''
|
||||
},
|
||||
wasmRuntimeLimitGroups: [
|
||||
{
|
||||
title: 'Execution',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_execution_ms',
|
||||
label: 'Max execution time (ms)',
|
||||
description:
|
||||
'Maximum wall-clock time allowed for one WASM invocation.',
|
||||
details:
|
||||
'This is the elapsed time from starting the invocation until the export returns. When the limit is reached LNbits requests an interrupt and records the invocation as timed out if it cannot finish quickly. Use this to stop long sleeps, slow host calls, and CPU loops that run for too long.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_fuel',
|
||||
label: 'Max fuel',
|
||||
description:
|
||||
'Maximum Wasmtime instruction budget for one invocation.',
|
||||
details:
|
||||
'Fuel is Wasmtime instruction budgeting. It is more deterministic than wall-clock time for CPU-heavy loops because each executed instruction consumes budget. Set this low enough to stop busy loops, but high enough for legitimate extension startup and JSON processing.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_wasm_stack_bytes',
|
||||
label: 'Max WASM stack (bytes)',
|
||||
description: 'Maximum stack space for WASM calls and recursion.',
|
||||
details:
|
||||
'This limits stack used by WebAssembly function calls. It protects the server from deep recursion or very large call chains in extension code. If legitimate extensions fail with stack overflow traps, raise this carefully.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Memory and Data Size',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_memory_bytes',
|
||||
label: 'Max memory (bytes)',
|
||||
description: 'Maximum WASM linear memory per invocation.',
|
||||
details:
|
||||
'This caps the linear memory visible to the WASM module. It limits memory.grow and can make instantiation fail if the module asks for too much memory up front. This does not include every byte used by the Python process or Wasmtime engine internals.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_request_bytes',
|
||||
label: 'Max request size (bytes)',
|
||||
description:
|
||||
'Maximum serialized input payload accepted before execution.',
|
||||
details:
|
||||
'This caps the serialized payload passed into a WASM export before execution starts. It protects against huge HTTP bodies, oversized event data, and expensive JSON parsing. Requests above this limit should be rejected before invoking the extension.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_response_bytes',
|
||||
label: 'Max response size (bytes)',
|
||||
description:
|
||||
'Maximum serialized response returned by a WASM export.',
|
||||
details:
|
||||
'This caps the JSON or string response returned by the WASM export. It prevents extensions from returning huge responses that consume memory, slow down API calls, or overload the browser. Responses above this limit are treated as invalid.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Wasmtime Objects',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_table_elements',
|
||||
label: 'Max table elements',
|
||||
description: 'Maximum total elements allowed in WASM tables.',
|
||||
details:
|
||||
'Tables store references used by WebAssembly, commonly function references. Limiting table elements prevents a module from allocating very large reference tables. Each table element also has host memory overhead.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_instances',
|
||||
label: 'Max instances',
|
||||
description:
|
||||
'Maximum WebAssembly instances allowed inside one store.',
|
||||
details:
|
||||
'This limits how many WebAssembly instances can be created inside one Wasmtime store. LNbits normally needs one instance per invocation, so a low value is expected. Raising it should only be needed if the runtime starts supporting modules that instantiate other modules.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_tables',
|
||||
label: 'Max tables',
|
||||
description:
|
||||
'Maximum WebAssembly tables allowed inside one store.',
|
||||
details:
|
||||
'This limits the number of WebAssembly tables in the store. It is separate from table elements: one setting limits the number of tables, the other limits their total size. Keep this small unless a component model module legitimately needs more tables.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_memories',
|
||||
label: 'Max memories',
|
||||
description:
|
||||
'Maximum WebAssembly linear memories allowed inside one store.',
|
||||
details:
|
||||
'This limits how many separate linear memories a module can create. Most extensions should need only one memory. Keep this small to reduce memory accounting complexity and prevent multi-memory abuse.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Concurrency',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations',
|
||||
label: 'Max concurrent invocations',
|
||||
description:
|
||||
'Maximum running WASM invocations across the server.',
|
||||
details:
|
||||
'This is the global cap for running WASM invocations across all extensions and users. It protects the LNbits process from thread exhaustion, CPU pressure, and too many simultaneous stores. New invocations should be rejected or queued once this is reached.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations_per_extension',
|
||||
label: 'Max concurrent per extension',
|
||||
description:
|
||||
'Maximum running WASM invocations for one extension.',
|
||||
details:
|
||||
'This caps how many invocations a single extension can run at once. It prevents one malicious or buggy extension from consuming the whole global concurrency budget. Set it lower than the global limit.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_concurrent_invocations_per_user',
|
||||
label: 'Max concurrent per user',
|
||||
description: 'Maximum running WASM invocations for one user.',
|
||||
details:
|
||||
'This caps concurrent invocations attributed to one user. It helps protect against a user repeatedly clicking, refreshing, or scripting extension calls. Invocations without a user can still be governed by the global and per-extension limits.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Host Calls',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_max_host_calls',
|
||||
label: 'Max host calls',
|
||||
description:
|
||||
'Maximum total calls from WASM into LNbits host APIs.',
|
||||
details:
|
||||
'This is the total budget for calls from the WASM module into LNbits host APIs during one invocation. It should count all categories together. It limits chatty extensions and prevents tight loops that repeatedly call back into Python.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_http_calls',
|
||||
label: 'Max HTTP calls',
|
||||
description: 'Maximum outbound HTTP host calls per invocation.',
|
||||
details:
|
||||
'This caps outbound HTTP requests made through the host API during one invocation. It reduces SSRF blast radius, protects network resources, and limits slow external dependencies. It should be enforced together with HTTP timeout and response-size limits.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_storage_calls',
|
||||
label: 'Max storage calls',
|
||||
description: 'Maximum storage host calls per invocation.',
|
||||
details:
|
||||
'This caps extension storage operations during one invocation. It protects the database from excessive reads and writes triggered by malicious loops. Use it with storage payload-size limits if those are added later.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_wallet_calls',
|
||||
label: 'Max wallet calls',
|
||||
description: 'Maximum wallet/payment host calls per invocation.',
|
||||
details:
|
||||
'This caps wallet and payment-related host calls during one invocation. These calls are security-sensitive and may touch balances, invoices, or payments. Keep this conservative and rely on explicit permissions for what the extension is allowed to do.'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'HTTP',
|
||||
fields: [
|
||||
{
|
||||
name: 'wasm_runtime_http_timeout_ms',
|
||||
label: 'HTTP timeout (ms)',
|
||||
description: 'Maximum time allowed for one WASM HTTP request.',
|
||||
details:
|
||||
'This is the per-request timeout for HTTP calls made through the WASM host API. It prevents a slow remote server from holding an invocation open indefinitely. The total invocation timeout still applies across all work.'
|
||||
},
|
||||
{
|
||||
name: 'wasm_runtime_max_http_response_bytes',
|
||||
label: 'Max HTTP response size (bytes)',
|
||||
description:
|
||||
'Maximum response body size accepted from one WASM HTTP request.',
|
||||
details:
|
||||
'This caps the response body accepted from each HTTP call made by an extension. It protects memory and parsing time when a remote server returns a very large body. Responses above the limit should fail the host call.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
adminKey() {
|
||||
return this.g.user.wallets[0].adminkey
|
||||
},
|
||||
isExtensionLimitRoute() {
|
||||
return this.$route.path.startsWith('/admin/extensions/wasm/limits/')
|
||||
},
|
||||
routeWasmExtensionId() {
|
||||
return this.isExtensionLimitRoute ? this.$route.params.extId : null
|
||||
},
|
||||
backRoute() {
|
||||
return this.isExtensionLimitRoute
|
||||
? '/admin/extensions/wasm/limits'
|
||||
: '/admin#extensions'
|
||||
},
|
||||
backTooltip() {
|
||||
return this.isExtensionLimitRoute
|
||||
? 'Wasm Limit Config'
|
||||
: 'Extensions Settings'
|
||||
},
|
||||
pageDescription() {
|
||||
if (this.isExtensionLimitRoute) {
|
||||
return 'Customize limits for one installed WASM extension.'
|
||||
}
|
||||
return 'These values are global defaults. Use 0 to disable a global limit.'
|
||||
},
|
||||
wasmRuntimeLimitExtensionOptions() {
|
||||
return this.wasmRuntimeLimitExtensions.map(extension => ({
|
||||
label: `${extension.name || extension.id} (${extension.id})`,
|
||||
value: extension.id
|
||||
}))
|
||||
},
|
||||
selectedWasmRuntimeLimitExtension() {
|
||||
return (
|
||||
this.wasmRuntimeLimitExtensions.find(
|
||||
extension => extension.id === this.selectedWasmExtensionId
|
||||
) || null
|
||||
)
|
||||
},
|
||||
customWasmLimitCount() {
|
||||
const extension = this.selectedWasmRuntimeLimitExtension
|
||||
if (!extension || !extension.wasm_runtime_limits) {
|
||||
return 0
|
||||
}
|
||||
return Object.keys(extension.wasm_runtime_limits).length
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
selectedWasmExtensionId() {
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
},
|
||||
routeWasmExtensionId() {
|
||||
this.syncWasmExtensionLimitRoute()
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchWasmRuntimeLimitExtensions()
|
||||
},
|
||||
methods: {
|
||||
async fetchWasmRuntimeLimitExtensions() {
|
||||
this.wasmRuntimeLimitExtensionsLoading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
'/api/v1/extension/wasm/runtime-limits/extensions',
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmRuntimeLimitExtensions = data || []
|
||||
if (
|
||||
this.selectedWasmExtensionId &&
|
||||
!this.wasmRuntimeLimitExtensions.some(
|
||||
extension => extension.id === this.selectedWasmExtensionId
|
||||
)
|
||||
) {
|
||||
this.selectedWasmExtensionId = null
|
||||
}
|
||||
this.syncWasmExtensionLimitRoute()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmRuntimeLimitExtensionsLoading = false
|
||||
}
|
||||
},
|
||||
syncWasmExtensionLimitRoute() {
|
||||
this.selectedWasmExtensionId = this.routeWasmExtensionId
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
},
|
||||
openWasmExtensionLimit(extensionId) {
|
||||
this.$router.push(
|
||||
`/admin/extensions/wasm/limits/${encodeURIComponent(extensionId)}`
|
||||
)
|
||||
},
|
||||
loadSelectedWasmRuntimeLimitExtension() {
|
||||
const extension = this.selectedWasmRuntimeLimitExtension
|
||||
this.wasmExtensionLimitDraft = extension
|
||||
? {...(extension.wasm_runtime_limits || {})}
|
||||
: {}
|
||||
},
|
||||
wasmExtensionLimitHint(field) {
|
||||
const globalValue = this.formData[field.name]
|
||||
return `Inherited global value: ${globalValue}. ${field.description}`
|
||||
},
|
||||
wasmExtensionLimitPlaceholder(field) {
|
||||
const globalValue = this.formData[field.name]
|
||||
return globalValue === undefined || globalValue === null
|
||||
? ''
|
||||
: String(globalValue)
|
||||
},
|
||||
normalizedWasmExtensionLimitDraft() {
|
||||
const limits = {}
|
||||
this.wasmRuntimeLimitGroups.forEach(group => {
|
||||
group.fields.forEach(field => {
|
||||
const value = this.wasmExtensionLimitDraft[field.name]
|
||||
const cleanValue = typeof value === 'string' ? value.trim() : value
|
||||
if (
|
||||
cleanValue === '' ||
|
||||
cleanValue === null ||
|
||||
cleanValue === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
const numericValue = Number(cleanValue)
|
||||
if (
|
||||
!Number.isFinite(numericValue) ||
|
||||
!Number.isInteger(numericValue) ||
|
||||
numericValue < 0
|
||||
) {
|
||||
throw new Error(`${field.label} must be a non-negative integer.`)
|
||||
}
|
||||
limits[field.name] = numericValue
|
||||
})
|
||||
})
|
||||
return limits
|
||||
},
|
||||
async clearWasmExtensionLimits() {
|
||||
this.wasmExtensionLimitDraft = {}
|
||||
await this.saveWasmExtensionLimits()
|
||||
},
|
||||
async saveWasmExtensionLimits() {
|
||||
if (!this.selectedWasmExtensionId) {
|
||||
return
|
||||
}
|
||||
this.wasmExtensionLimitsSaving = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/wasm/runtime-limits/${encodeURIComponent(
|
||||
this.selectedWasmExtensionId
|
||||
)}`,
|
||||
this.adminKey,
|
||||
{
|
||||
limits: this.normalizedWasmExtensionLimitDraft()
|
||||
}
|
||||
)
|
||||
const index = this.wasmRuntimeLimitExtensions.findIndex(
|
||||
extension => extension.id === data.id
|
||||
)
|
||||
if (index >= 0) {
|
||||
this.wasmRuntimeLimitExtensions.splice(index, 1, data)
|
||||
}
|
||||
this.loadSelectedWasmRuntimeLimitExtension()
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'WASM extension limits saved.'
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !error.response) {
|
||||
Quasar.Notify.create({
|
||||
type: 'negative',
|
||||
message: error.message
|
||||
})
|
||||
} else {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
} finally {
|
||||
this.wasmExtensionLimitsSaving = false
|
||||
}
|
||||
},
|
||||
showWasmLimitInfo(field) {
|
||||
this.wasmLimitInfoDialog = {
|
||||
show: true,
|
||||
title: field.label,
|
||||
details: field.details
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,380 +0,0 @@
|
||||
window.app.component('lnbits-admin-wasm-runtime', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-admin-wasm-runtime',
|
||||
data() {
|
||||
return {
|
||||
wasmRuntimeLoading: false,
|
||||
wasmHistoryLoading: false,
|
||||
wasmRuntimeTimer: null,
|
||||
wasmStats: {},
|
||||
wasmCurrentInvocations: [],
|
||||
wasmInvocationHistory: [],
|
||||
wasmStatItems: [
|
||||
{key: 'total', label: 'Total', icon: 'data_usage', color: 'primary'},
|
||||
{key: 'running', label: 'Running', icon: 'play_circle', color: 'green'},
|
||||
{key: 'completed', label: 'Completed', icon: 'task_alt', color: 'teal'},
|
||||
{key: 'failed', label: 'Failed', icon: 'error', color: 'red'},
|
||||
{
|
||||
key: 'stopped',
|
||||
label: 'Stopped',
|
||||
icon: 'stop_circle',
|
||||
color: 'orange'
|
||||
},
|
||||
{key: 'timeout', label: 'Timeouts', icon: 'timer_off', color: 'purple'}
|
||||
],
|
||||
wasmCurrentColumns: [
|
||||
{
|
||||
name: 'extension_id',
|
||||
label: 'Extension',
|
||||
field: 'extension_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'export_name',
|
||||
label: 'Export',
|
||||
field: 'export_name',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
label: 'Trigger',
|
||||
field: 'trigger_type',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
label: 'User',
|
||||
field: 'user_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'started_at',
|
||||
label: 'Started',
|
||||
field: 'started_at',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'duration_ms',
|
||||
label: 'Duration',
|
||||
field: row => row.duration_ms || 0,
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
label: 'Context',
|
||||
field: row => this.wasmContextValue(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{name: 'actions', label: '', field: 'actions', align: 'right'}
|
||||
],
|
||||
wasmHistoryColumns: [
|
||||
{
|
||||
name: 'extension_id',
|
||||
label: 'Extension',
|
||||
field: 'extension_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'export_name',
|
||||
label: 'Export',
|
||||
field: 'export_name',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'trigger_type',
|
||||
label: 'Trigger',
|
||||
field: 'trigger_type',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
field: 'status',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'user_id',
|
||||
label: 'User',
|
||||
field: 'user_id',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'started_at',
|
||||
label: 'Started',
|
||||
field: 'started_at',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'duration_ms',
|
||||
label: 'Duration',
|
||||
field: row => row.duration_ms || 0,
|
||||
align: 'right',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'calls',
|
||||
label: 'Calls',
|
||||
field: row => this.wasmCallCount(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'context',
|
||||
label: 'Context',
|
||||
field: row => this.wasmContextValue(row),
|
||||
align: 'left',
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
name: 'error_message',
|
||||
label: 'Error/Stop Reason',
|
||||
field: row => row.error_message || row.stop_reason || '',
|
||||
align: 'left',
|
||||
sortable: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
adminKey() {
|
||||
return this.g.user.wallets[0].adminkey
|
||||
},
|
||||
wasmExtensionId() {
|
||||
return this.$route.params.extId || null
|
||||
},
|
||||
wasmExtensionQuery() {
|
||||
if (!this.wasmExtensionId) {
|
||||
return ''
|
||||
}
|
||||
return `extension_id=${encodeURIComponent(this.wasmExtensionId)}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
wasmExtensionId() {
|
||||
this.fetchWasmRuntime()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchWasmRuntime() {
|
||||
await Promise.all([
|
||||
this.fetchWasmCurrentInvocations(),
|
||||
this.fetchWasmInvocationHistory(),
|
||||
this.fetchWasmInvocationStats()
|
||||
])
|
||||
},
|
||||
async fetchWasmCurrentInvocations() {
|
||||
this.wasmRuntimeLoading = true
|
||||
try {
|
||||
const query = this.wasmExtensionQuery
|
||||
? `?${this.wasmExtensionQuery}`
|
||||
: ''
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations/current${query}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmCurrentInvocations = data || []
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmRuntimeLoading = false
|
||||
}
|
||||
},
|
||||
async fetchWasmInvocationHistory() {
|
||||
this.wasmHistoryLoading = true
|
||||
try {
|
||||
const params = ['limit=50']
|
||||
if (this.wasmExtensionQuery) {
|
||||
params.push(this.wasmExtensionQuery)
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations?${params.join('&')}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmInvocationHistory = data || []
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.wasmHistoryLoading = false
|
||||
}
|
||||
},
|
||||
async fetchWasmInvocationStats() {
|
||||
try {
|
||||
const params = ['hours=24']
|
||||
if (this.wasmExtensionQuery) {
|
||||
params.push(this.wasmExtensionQuery)
|
||||
}
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/wasm/invocations/stats?${params.join('&')}`,
|
||||
this.adminKey
|
||||
)
|
||||
this.wasmStats = data || {}
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
async stopWasmInvocation(invocationId) {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/extension/wasm/invocations/${encodeURIComponent(invocationId)}/stop`,
|
||||
this.adminKey
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'WASM invocation stop requested.'
|
||||
})
|
||||
await this.fetchWasmRuntime()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
},
|
||||
deactivateWasmExtension(extensionId) {
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
`Deactivate extension '${extensionId}'?`,
|
||||
'Deactivate Extension'
|
||||
)
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'PUT',
|
||||
`/api/v1/extension/${encodeURIComponent(extensionId)}/deactivate`,
|
||||
this.adminKey
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: `Extension '${extensionId}' deactivated.`
|
||||
})
|
||||
await this.fetchWasmRuntime()
|
||||
} catch (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
}
|
||||
})
|
||||
},
|
||||
formatWasmStat(key) {
|
||||
const value = this.wasmStats[key]
|
||||
return value === undefined || value === null ? '0' : String(value)
|
||||
},
|
||||
formatWasmDate(value) {
|
||||
return value ? this.utils.formatDate(value) : ''
|
||||
},
|
||||
wasmStatusColor(status) {
|
||||
return (
|
||||
{
|
||||
running: 'green',
|
||||
stopping: 'orange',
|
||||
completed: 'teal',
|
||||
failed: 'red',
|
||||
stopped: 'orange',
|
||||
timeout: 'purple',
|
||||
abandoned: 'grey'
|
||||
}[status] || 'grey'
|
||||
)
|
||||
},
|
||||
wasmTriggerColor(triggerType) {
|
||||
return (
|
||||
{
|
||||
http: 'primary',
|
||||
event: 'purple'
|
||||
}[triggerType] || 'grey'
|
||||
)
|
||||
},
|
||||
formatWasmDuration(row) {
|
||||
let duration = row.duration_ms
|
||||
if (
|
||||
(row.status === 'running' || row.status === 'stopping') &&
|
||||
row.started_at
|
||||
) {
|
||||
duration = Date.now() - new Date(row.started_at).getTime()
|
||||
}
|
||||
if (duration === undefined || duration === null) {
|
||||
return ''
|
||||
}
|
||||
if (duration >= 1000) {
|
||||
return `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
return `${duration}ms`
|
||||
},
|
||||
formatWasmCalls(row) {
|
||||
return [
|
||||
`host ${row.host_call_count || 0}`,
|
||||
`http ${row.http_call_count || 0}`,
|
||||
`storage ${row.storage_call_count || 0}`,
|
||||
`wallet ${row.wallet_call_count || 0}`
|
||||
].join(' / ')
|
||||
},
|
||||
wasmCallCount(row) {
|
||||
return (
|
||||
(row.host_call_count || 0) +
|
||||
(row.http_call_count || 0) +
|
||||
(row.storage_call_count || 0) +
|
||||
(row.wallet_call_count || 0)
|
||||
)
|
||||
},
|
||||
wasmContextValue(row) {
|
||||
return [
|
||||
row.method,
|
||||
row.path,
|
||||
row.event_type,
|
||||
row.wallet_id,
|
||||
row.payment_hash
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
},
|
||||
formatWasmContext(row) {
|
||||
const items = [
|
||||
row.method,
|
||||
row.path,
|
||||
row.event_type,
|
||||
row.wallet_id ? `wallet ${row.wallet_id}` : '',
|
||||
row.payment_hash ? `payment ${row.payment_hash.slice(0, 12)}...` : ''
|
||||
].filter(Boolean)
|
||||
return items.join(' | ')
|
||||
},
|
||||
formatWasmUserId(userId) {
|
||||
if (!userId) {
|
||||
return '-'
|
||||
}
|
||||
const value = String(userId)
|
||||
if (value.length <= 12) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, 3)}...${value.slice(-3)}`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.fetchWasmRuntime()
|
||||
this.wasmRuntimeTimer = setInterval(() => {
|
||||
this.fetchWasmCurrentInvocations()
|
||||
}, 5000)
|
||||
},
|
||||
unmounted() {
|
||||
if (this.wasmRuntimeTimer) {
|
||||
clearInterval(this.wasmRuntimeTimer)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,324 +0,0 @@
|
||||
;(function () {
|
||||
function translate(translateFn, key) {
|
||||
return translateFn ? translateFn(key) : key
|
||||
}
|
||||
|
||||
function permissionI18nKey(permission) {
|
||||
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
|
||||
}
|
||||
|
||||
function permissionLabel(permission, translateFn) {
|
||||
const key = permissionI18nKey(permission)
|
||||
const label = translate(translateFn, key)
|
||||
return label === key ? permission.id : label
|
||||
}
|
||||
|
||||
function permissionManifestDescription(permission) {
|
||||
return typeof permission.description === 'string'
|
||||
? permission.description
|
||||
: ''
|
||||
}
|
||||
|
||||
function lowRisk(translateFn) {
|
||||
return {
|
||||
level: 'low',
|
||||
color: 'grey-6',
|
||||
label: translate(translateFn, 'extension_permission_risk_low'),
|
||||
warning: ''
|
||||
}
|
||||
}
|
||||
|
||||
function mediumRisk(translateFn) {
|
||||
return {
|
||||
level: 'medium',
|
||||
color: 'warning',
|
||||
label: translate(translateFn, 'extension_permission_risk_medium'),
|
||||
warning: ''
|
||||
}
|
||||
}
|
||||
|
||||
function highRisk(translateFn, warningKey) {
|
||||
return {
|
||||
level: 'high',
|
||||
color: 'negative',
|
||||
label: translate(translateFn, 'extension_permission_risk_high'),
|
||||
warning: translate(translateFn, warningKey)
|
||||
}
|
||||
}
|
||||
|
||||
function extensionDisplayName(extensions, extensionId) {
|
||||
const extension = (extensions || []).find(
|
||||
extension => extension.id === extensionId
|
||||
)
|
||||
return extension?.name || extensionId
|
||||
}
|
||||
|
||||
function extensionApiPermissionTargets(permission, extensions) {
|
||||
const extensionPolicies = permission.policies
|
||||
if (!Array.isArray(extensionPolicies)) return []
|
||||
return extensionPolicies
|
||||
.map(extension => {
|
||||
const extensionId =
|
||||
typeof extension === 'string' ? extension : extension?.id
|
||||
if (!extensionId) return null
|
||||
const access =
|
||||
typeof extension === 'string'
|
||||
? ['read']
|
||||
: Array.isArray(extension.access) && extension.access.length
|
||||
? extension.access
|
||||
: ['read']
|
||||
return {
|
||||
id: extensionId,
|
||||
name: extensionDisplayName(extensions, extensionId),
|
||||
access
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function permissionRiskForPermission(permission, extensions, translateFn) {
|
||||
if (permission.id === 'wallet.pay_invoice') {
|
||||
return highRisk(
|
||||
translateFn,
|
||||
'extension_permission_warning_wallet_pay_invoice'
|
||||
)
|
||||
}
|
||||
if (permission.id === 'extension.api.request') {
|
||||
const hasWriteAccess = extensionApiPermissionTargets(
|
||||
permission,
|
||||
extensions
|
||||
).some(target => target.access.includes('write'))
|
||||
return hasWriteAccess
|
||||
? highRisk(
|
||||
translateFn,
|
||||
'extension_permission_warning_extension_api_request_write'
|
||||
)
|
||||
: mediumRisk(translateFn)
|
||||
}
|
||||
if (permission.id === 'http.request') {
|
||||
return mediumRisk(translateFn)
|
||||
}
|
||||
if (
|
||||
[
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'wallet.create_invoice_public',
|
||||
'ext.storage.read_public',
|
||||
'payments.watch'
|
||||
].includes(permission.id)
|
||||
) {
|
||||
return mediumRisk(translateFn)
|
||||
}
|
||||
return lowRisk(translateFn)
|
||||
}
|
||||
|
||||
function permissionRisk(permissions, extensions, translateFn) {
|
||||
const risks = permissions.map(permission =>
|
||||
permissionRiskForPermission(permission, extensions, translateFn)
|
||||
)
|
||||
const highestRisk = risks.find(risk => risk.level === 'high')
|
||||
if (highestRisk) return highestRisk
|
||||
return risks.find(risk => risk.level === 'medium') || lowRisk(translateFn)
|
||||
}
|
||||
|
||||
function permissionOrderIndex(permissionId) {
|
||||
const order = [
|
||||
'wallet.pay_invoice',
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'extension.api.request',
|
||||
'http.request',
|
||||
'ui.camera.scan_qr',
|
||||
'ext.storage.read',
|
||||
'ext.storage.write',
|
||||
'ext.storage.read_public',
|
||||
'wallet.create_invoice_public',
|
||||
'wallet.create_invoice',
|
||||
'utils.basic'
|
||||
]
|
||||
const index = order.indexOf(permissionId)
|
||||
return index === -1 ? order.length : index
|
||||
}
|
||||
|
||||
function publicStorageFieldGroups(permission) {
|
||||
const tables = permission.policies
|
||||
if (!Array.isArray(tables)) return []
|
||||
return tables
|
||||
.map(table => {
|
||||
const tableName =
|
||||
typeof table === 'string' ? table : table?.table_name || ''
|
||||
const fields =
|
||||
typeof table === 'string' || !Array.isArray(table?.public_fields)
|
||||
? []
|
||||
: table.public_fields.filter(
|
||||
field => typeof field === 'string' && field
|
||||
)
|
||||
return tableName ? {table: tableName, fields} : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function httpRequestPermissionHosts(permission) {
|
||||
const hosts = permission.policies
|
||||
if (!Array.isArray(hosts)) return []
|
||||
return hosts
|
||||
.map(host => (typeof host === 'string' ? host : host?.host || ''))
|
||||
.filter(host => typeof host === 'string' && host)
|
||||
}
|
||||
|
||||
function publicInvoicePolicies(permission) {
|
||||
const policies = permission.policies
|
||||
if (!Array.isArray(policies)) return []
|
||||
return policies
|
||||
.map(policy => {
|
||||
if (!policy || typeof policy !== 'object') return null
|
||||
const table = policy.table
|
||||
const walletField = policy.wallet_field
|
||||
if (typeof table !== 'string' || !table) return null
|
||||
if (typeof walletField !== 'string' || !walletField) return null
|
||||
return {table, walletField}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function permissionDisplayItem(permissions, extensions, translateFn) {
|
||||
const permission = permissions[0]
|
||||
const isReadWriteStorage =
|
||||
permissions.length === 2 &&
|
||||
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
||||
permissions.some(permission => permission.id === 'ext.storage.write')
|
||||
const descriptions = permissions
|
||||
.map(permission => permissionManifestDescription(permission))
|
||||
.filter(Boolean)
|
||||
const item = {
|
||||
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
|
||||
label: isReadWriteStorage
|
||||
? translate(translateFn, 'extension_permission_ext_storage_read_write')
|
||||
: permissionLabel(permission, translateFn),
|
||||
risk: permissionRisk(permissions, extensions, translateFn),
|
||||
badges: [],
|
||||
descriptions,
|
||||
fieldGroups: [],
|
||||
invoicePolicies: [],
|
||||
extensionAccess: [],
|
||||
httpHosts: []
|
||||
}
|
||||
|
||||
if (permission.id === 'ext.storage.read_public') {
|
||||
item.fieldGroups = publicStorageFieldGroups(permission)
|
||||
item.badges = item.fieldGroups.map(group => ({
|
||||
key: group.table,
|
||||
label: group.table
|
||||
}))
|
||||
}
|
||||
|
||||
if (permission.id === 'extension.api.request') {
|
||||
item.extensionAccess = extensionApiPermissionTargets(
|
||||
permission,
|
||||
extensions
|
||||
)
|
||||
item.badges = item.extensionAccess.map(target => ({
|
||||
key: target.id,
|
||||
label: target.name
|
||||
}))
|
||||
}
|
||||
|
||||
if (permission.id === 'http.request') {
|
||||
item.httpHosts = httpRequestPermissionHosts(permission)
|
||||
}
|
||||
|
||||
if (permission.id === 'wallet.create_invoice_public') {
|
||||
item.invoicePolicies = publicInvoicePolicies(permission)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
function displayItems({permissions, extensions, translate}) {
|
||||
const permissionList = permissions || []
|
||||
const permissionsById = new Map(
|
||||
permissionList.map(permission => [permission.id, permission])
|
||||
)
|
||||
const hasReadWriteStorage =
|
||||
permissionsById.has('ext.storage.read') &&
|
||||
permissionsById.has('ext.storage.write')
|
||||
let addedReadWriteStorage = false
|
||||
|
||||
return permissionList
|
||||
.map((permission, index) => {
|
||||
if (
|
||||
hasReadWriteStorage &&
|
||||
['ext.storage.read', 'ext.storage.write'].includes(permission.id)
|
||||
) {
|
||||
if (addedReadWriteStorage) return null
|
||||
addedReadWriteStorage = true
|
||||
return {
|
||||
index,
|
||||
orderId: 'ext.storage.read',
|
||||
permissions: [
|
||||
permissionsById.get('ext.storage.read'),
|
||||
permissionsById.get('ext.storage.write')
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
orderId: permission.id,
|
||||
permissions: [permission]
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
const leftOrder = permissionOrderIndex(left.orderId)
|
||||
const rightOrder = permissionOrderIndex(right.orderId)
|
||||
return leftOrder === rightOrder
|
||||
? left.index - right.index
|
||||
: leftOrder - rightOrder
|
||||
})
|
||||
.map(group =>
|
||||
permissionDisplayItem(group.permissions, extensions || [], translate)
|
||||
)
|
||||
}
|
||||
|
||||
window.LNbitsExtensionPermissions = {
|
||||
displayItems,
|
||||
hasHighRisk({permissions, extensions, translate}) {
|
||||
return displayItems({permissions, extensions, translate}).some(
|
||||
permission => permission.risk.level === 'high'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
window.app.component('lnbits-extension-permissions', {
|
||||
template: '#lnbits-extension-permissions',
|
||||
props: {
|
||||
permissions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
extensions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayItems() {
|
||||
return window.LNbitsExtensionPermissions.displayItems({
|
||||
permissions: this.permissions,
|
||||
extensions: this.extensions,
|
||||
translate: key => this.$t(key)
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
publicInvoicePolicySentence(policy) {
|
||||
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
||||
},
|
||||
permissionAccessLabel(access) {
|
||||
const key = `extension_permission_access_${access}`
|
||||
const label = this.$t(key)
|
||||
return label === key ? access : label
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
@@ -99,26 +99,6 @@ const routes = [
|
||||
name: 'Users',
|
||||
component: PageUsers
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm',
|
||||
name: 'AdminWasmRuntime',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/limits',
|
||||
name: 'AdminWasmLimitConfig',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/limits/:extId',
|
||||
name: 'AdminWasmLimitConfigDetail',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin/extensions/wasm/:extId',
|
||||
name: 'AdminWasmRuntimeDetail',
|
||||
component: PageAdmin
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
name: 'Admin',
|
||||
|
||||
@@ -16,26 +16,18 @@ window.PageAdmin = {
|
||||
},
|
||||
watch: {
|
||||
tab(tab) {
|
||||
if (
|
||||
['wasm-runtime', 'wasm-limit-config'].includes(tab) &&
|
||||
this.$route.path.startsWith('/admin/extensions/wasm')
|
||||
) {
|
||||
return
|
||||
}
|
||||
const target = this.adminRouteForTab(tab)
|
||||
if (this.$route.fullPath !== target) {
|
||||
this.$router.push(target)
|
||||
}
|
||||
this.$router.push(`/admin#${tab}`)
|
||||
},
|
||||
$route(to) {
|
||||
const tab = this.adminTabFromRoute(to)
|
||||
if (this.tab !== tab) {
|
||||
this.tab = tab
|
||||
if (to.hash.length > 1) {
|
||||
this.tab = to.hash.replace('#', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
this.tab = this.adminTabFromRoute(this.$route)
|
||||
if (this.$route.hash.length > 1) {
|
||||
this.tab = this.$route.hash.replace('#', '')
|
||||
}
|
||||
await this.getSettings()
|
||||
},
|
||||
computed: {
|
||||
@@ -44,27 +36,6 @@ window.PageAdmin = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
adminTabFromRoute(route) {
|
||||
if (route.path.startsWith('/admin/extensions/wasm/limits')) {
|
||||
return 'wasm-limit-config'
|
||||
}
|
||||
if (route.path.startsWith('/admin/extensions/wasm')) {
|
||||
return 'wasm-runtime'
|
||||
}
|
||||
if (route.hash.length > 1) {
|
||||
return route.hash.replace('#', '')
|
||||
}
|
||||
return 'funding'
|
||||
},
|
||||
adminRouteForTab(tab) {
|
||||
if (tab === 'wasm-runtime') {
|
||||
return '/admin/extensions/wasm'
|
||||
}
|
||||
if (tab === 'wasm-limit-config') {
|
||||
return '/admin/extensions/wasm/limits'
|
||||
}
|
||||
return `/admin#${tab}`
|
||||
},
|
||||
getDefaultSetting(fieldName) {
|
||||
LNbits.api.getDefaultSetting(fieldName).then(response => {
|
||||
this.formData[fieldName] = response.data.default_value
|
||||
|
||||
@@ -164,11 +164,6 @@ window.PageExtensions = {
|
||||
)
|
||||
extension.isAvailable = true
|
||||
extension.isInstalled = true
|
||||
extension.isWasm =
|
||||
response.data.is_wasm === true ||
|
||||
response.data.isWasm === true ||
|
||||
release.extension_type === 'wasm' ||
|
||||
extension.isWasm === true
|
||||
extension.icon = response.data.icon || extension.icon
|
||||
extension.installedRelease = release
|
||||
this.toggleExtension(extension)
|
||||
@@ -730,11 +725,268 @@ window.PageExtensions = {
|
||||
}
|
||||
},
|
||||
permissionGrantHasHighRisk() {
|
||||
return window.LNbitsExtensionPermissions.hasHighRisk({
|
||||
permissions: this.permissionGrant.permissions,
|
||||
extensions: this.extensions,
|
||||
translate: key => this.$t(key)
|
||||
})
|
||||
return this.permissionGrantDisplayItems().some(
|
||||
permission => permission.risk.level === 'high'
|
||||
)
|
||||
},
|
||||
permissionGrantDisplayItems() {
|
||||
const permissions = this.permissionGrant.permissions || []
|
||||
const permissionsById = new Map(
|
||||
permissions.map(permission => [permission.id, permission])
|
||||
)
|
||||
const hasReadWriteStorage =
|
||||
permissionsById.has('ext.storage.read') &&
|
||||
permissionsById.has('ext.storage.write')
|
||||
let addedReadWriteStorage = false
|
||||
|
||||
return permissions
|
||||
.map((permission, index) => {
|
||||
if (
|
||||
hasReadWriteStorage &&
|
||||
['ext.storage.read', 'ext.storage.write'].includes(permission.id)
|
||||
) {
|
||||
if (addedReadWriteStorage) return null
|
||||
addedReadWriteStorage = true
|
||||
return {
|
||||
index,
|
||||
orderId: 'ext.storage.read',
|
||||
permissions: [
|
||||
permissionsById.get('ext.storage.read'),
|
||||
permissionsById.get('ext.storage.write')
|
||||
]
|
||||
}
|
||||
}
|
||||
return {
|
||||
index,
|
||||
orderId: permission.id,
|
||||
permissions: [permission]
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => {
|
||||
const leftOrder = this.permissionOrderIndex(left.orderId)
|
||||
const rightOrder = this.permissionOrderIndex(right.orderId)
|
||||
return leftOrder === rightOrder
|
||||
? left.index - right.index
|
||||
: leftOrder - rightOrder
|
||||
})
|
||||
.map(group => this.permissionDisplayItem(group.permissions))
|
||||
},
|
||||
permissionDisplayItem(permissions) {
|
||||
const permission = permissions[0]
|
||||
const isReadWriteStorage =
|
||||
permissions.length === 2 &&
|
||||
permissions.some(permission => permission.id === 'ext.storage.read') &&
|
||||
permissions.some(permission => permission.id === 'ext.storage.write')
|
||||
const descriptions = permissions
|
||||
.map(permission => this.permissionManifestDescription(permission))
|
||||
.filter(Boolean)
|
||||
const item = {
|
||||
id: isReadWriteStorage ? 'ext.storage.read_write' : permission.id,
|
||||
label: isReadWriteStorage
|
||||
? this.$t('extension_permission_ext_storage_read_write')
|
||||
: this.permissionLabel(permission),
|
||||
risk: this.permissionRisk(permissions),
|
||||
badges: [],
|
||||
descriptions,
|
||||
fieldGroups: [],
|
||||
invoicePolicies: [],
|
||||
extensionAccess: [],
|
||||
httpHosts: []
|
||||
}
|
||||
|
||||
if (permission.id === 'ext.storage.read_public') {
|
||||
item.fieldGroups = this.publicStorageFieldGroups(permission)
|
||||
item.badges = item.fieldGroups.map(group => ({
|
||||
key: group.table,
|
||||
label: group.table
|
||||
}))
|
||||
}
|
||||
|
||||
if (permission.id === 'extension.api.request') {
|
||||
item.extensionAccess = this.extensionApiPermissionTargets(permission)
|
||||
item.badges = item.extensionAccess.map(target => ({
|
||||
key: target.id,
|
||||
label: target.name
|
||||
}))
|
||||
}
|
||||
|
||||
if (permission.id === 'http.request') {
|
||||
item.httpHosts = this.httpRequestPermissionHosts(permission)
|
||||
}
|
||||
|
||||
if (permission.id === 'wallet.create_invoice_public') {
|
||||
item.invoicePolicies = this.publicInvoicePolicies(permission)
|
||||
}
|
||||
|
||||
return item
|
||||
},
|
||||
permissionRisk(permissions) {
|
||||
const risks = permissions.map(permission =>
|
||||
this.permissionRiskForPermission(permission)
|
||||
)
|
||||
const highestRisk = risks.find(risk => risk.level === 'high')
|
||||
if (highestRisk) return highestRisk
|
||||
return risks.find(risk => risk.level === 'medium') || this.lowRisk()
|
||||
},
|
||||
permissionRiskForPermission(permission) {
|
||||
if (permission.id === 'wallet.pay_invoice') {
|
||||
return this.highRisk('extension_permission_warning_wallet_pay_invoice')
|
||||
}
|
||||
if (permission.id === 'extension.api.request') {
|
||||
const hasWriteAccess = this.extensionApiPermissionTargets(
|
||||
permission
|
||||
).some(target => target.access.includes('write'))
|
||||
return hasWriteAccess
|
||||
? this.highRisk(
|
||||
'extension_permission_warning_extension_api_request_write'
|
||||
)
|
||||
: this.mediumRisk()
|
||||
}
|
||||
if (permission.id === 'http.request') {
|
||||
return this.mediumRisk()
|
||||
}
|
||||
if (
|
||||
[
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'wallet.create_invoice_public',
|
||||
'ext.storage.read_public',
|
||||
'payments.watch'
|
||||
].includes(permission.id)
|
||||
) {
|
||||
return this.mediumRisk()
|
||||
}
|
||||
return this.lowRisk()
|
||||
},
|
||||
lowRisk() {
|
||||
return {
|
||||
level: 'low',
|
||||
color: 'grey-6',
|
||||
label: this.$t('extension_permission_risk_low'),
|
||||
warning: ''
|
||||
}
|
||||
},
|
||||
mediumRisk() {
|
||||
return {
|
||||
level: 'medium',
|
||||
color: 'warning',
|
||||
label: this.$t('extension_permission_risk_medium'),
|
||||
warning: ''
|
||||
}
|
||||
},
|
||||
highRisk(warningKey) {
|
||||
return {
|
||||
level: 'high',
|
||||
color: 'negative',
|
||||
label: this.$t('extension_permission_risk_high'),
|
||||
warning: this.$t(warningKey)
|
||||
}
|
||||
},
|
||||
permissionOrderIndex(permissionId) {
|
||||
const order = [
|
||||
'wallet.pay_invoice',
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'extension.api.request',
|
||||
'http.request',
|
||||
'ui.camera.scan_qr',
|
||||
'ext.storage.read',
|
||||
'ext.storage.write',
|
||||
'ext.storage.read_public',
|
||||
'wallet.create_invoice_public',
|
||||
'wallet.create_invoice',
|
||||
'utils.basic'
|
||||
]
|
||||
const index = order.indexOf(permissionId)
|
||||
return index === -1 ? order.length : index
|
||||
},
|
||||
publicStorageFieldGroups(permission) {
|
||||
const tables = permission.policies
|
||||
if (!Array.isArray(tables)) return []
|
||||
return tables
|
||||
.map(table => {
|
||||
const tableName =
|
||||
typeof table === 'string' ? table : table?.table_name || ''
|
||||
const fields =
|
||||
typeof table === 'string' || !Array.isArray(table?.public_fields)
|
||||
? []
|
||||
: table.public_fields.filter(
|
||||
field => typeof field === 'string' && field
|
||||
)
|
||||
return tableName ? {table: tableName, fields} : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
},
|
||||
httpRequestPermissionHosts(permission) {
|
||||
const hosts = permission.policies
|
||||
if (!Array.isArray(hosts)) return []
|
||||
return hosts
|
||||
.map(host => (typeof host === 'string' ? host : host?.host || ''))
|
||||
.filter(host => typeof host === 'string' && host)
|
||||
},
|
||||
publicInvoicePolicies(permission) {
|
||||
const policies = permission.policies
|
||||
if (!Array.isArray(policies)) return []
|
||||
return policies
|
||||
.map(policy => {
|
||||
if (!policy || typeof policy !== 'object') return null
|
||||
const table = policy.table
|
||||
const walletField = policy.wallet_field
|
||||
if (typeof table !== 'string' || !table) return null
|
||||
if (typeof walletField !== 'string' || !walletField) return null
|
||||
return {table, walletField}
|
||||
})
|
||||
.filter(Boolean)
|
||||
},
|
||||
publicInvoicePolicySentence(policy) {
|
||||
return `Invoices will be created using ${policy.walletField} from ${policy.table}.`
|
||||
},
|
||||
extensionApiPermissionTargets(permission) {
|
||||
const extensions = permission.policies
|
||||
if (!Array.isArray(extensions)) return []
|
||||
return extensions
|
||||
.map(extension => {
|
||||
const extensionId =
|
||||
typeof extension === 'string' ? extension : extension?.id
|
||||
if (!extensionId) return null
|
||||
const access =
|
||||
typeof extension === 'string'
|
||||
? ['read']
|
||||
: Array.isArray(extension.access) && extension.access.length
|
||||
? extension.access
|
||||
: ['read']
|
||||
return {
|
||||
id: extensionId,
|
||||
name: this.extensionDisplayName(extensionId),
|
||||
access
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
},
|
||||
extensionDisplayName(extensionId) {
|
||||
const extension = (this.extensions || []).find(
|
||||
extension => extension.id === extensionId
|
||||
)
|
||||
return extension?.name || extensionId
|
||||
},
|
||||
permissionAccessLabel(access) {
|
||||
const key = `extension_permission_access_${access}`
|
||||
const label = this.$t(key)
|
||||
return label === key ? access : label
|
||||
},
|
||||
permissionManifestDescription(permission) {
|
||||
return typeof permission.description === 'string'
|
||||
? permission.description
|
||||
: ''
|
||||
},
|
||||
permissionI18nKey(permission) {
|
||||
return `extension_permission_${permission.id.replace(/[^A-Za-z0-9]/g, '_')}`
|
||||
},
|
||||
permissionLabel(permission) {
|
||||
const key = this.permissionI18nKey(permission)
|
||||
const label = this.$t(key)
|
||||
return label === key ? permission.id : label
|
||||
},
|
||||
async selectAllUpdatableExtensionss() {
|
||||
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
||||
|
||||
@@ -414,19 +414,12 @@ window.PageWallet = {
|
||||
switch (action.tag) {
|
||||
case 'url':
|
||||
Quasar.Notify.create({
|
||||
message: action.url,
|
||||
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
|
||||
caption: action.description,
|
||||
html: false,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Open link',
|
||||
color: 'white',
|
||||
handler: () => this.utils.openUrlInNewTab(action.url)
|
||||
}
|
||||
]
|
||||
closeBtn: true
|
||||
})
|
||||
break
|
||||
case 'message':
|
||||
@@ -438,29 +431,15 @@ window.PageWallet = {
|
||||
})
|
||||
break
|
||||
case 'aes':
|
||||
this.utils
|
||||
.decryptLnurlPayAES(action, response.data.preimage)
|
||||
.then(value => {
|
||||
Quasar.Notify.create({
|
||||
message: value,
|
||||
caption: action.description,
|
||||
html: false,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
Quasar.Notify.create({
|
||||
message: action.description || 'Payment successful.',
|
||||
caption: 'Could not decrypt success action.',
|
||||
html: false,
|
||||
type: 'warning',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
})
|
||||
break
|
||||
this.utils.decryptLnurlPayAES(action, response.data.preimage)
|
||||
Quasar.Notify.create({
|
||||
message: value,
|
||||
caption: extra.success_action.description,
|
||||
html: true,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -365,27 +365,5 @@ window._lnbitsUtils = {
|
||||
let decoder = new TextDecoder('utf-8')
|
||||
return decoder.decode(valueb)
|
||||
})
|
||||
},
|
||||
validateBrowsableUrl(urlString, allowLoopback = false) {
|
||||
const url = new URL(urlString)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('Invalid protocol')
|
||||
}
|
||||
if (!allowLoopback) {
|
||||
const host = url.hostname
|
||||
if (
|
||||
host === 'localhost' ||
|
||||
host === '[::1]' ||
|
||||
host === '::1' ||
|
||||
host.startsWith('127.') ||
|
||||
host.startsWith('::ffff:127.')
|
||||
) {
|
||||
throw new Error('Loopback addresses are not allowed')
|
||||
}
|
||||
}
|
||||
},
|
||||
openUrlInNewTab(urlString, allowLoopback = false) {
|
||||
this.validateBrowsableUrl(urlString, allowLoopback)
|
||||
window.open(urlString, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"js/pages/users.js",
|
||||
"js/pages/account.js",
|
||||
"js/pages/admin.js",
|
||||
"js/components/admin/lnbits-admin-funding-seed-backup.js",
|
||||
"js/components/admin/lnbits-admin-funding.js",
|
||||
"js/components/admin/lnbits-admin-funding-sources.js",
|
||||
"js/components/admin/lnbits-admin-fiat-providers.js",
|
||||
@@ -67,8 +66,6 @@
|
||||
"js/components/admin/lnbits-admin-users.js",
|
||||
"js/components/admin/lnbits-admin-server.js",
|
||||
"js/components/admin/lnbits-admin-extensions.js",
|
||||
"js/components/admin/lnbits-admin-wasm-runtime.js",
|
||||
"js/components/admin/lnbits-admin-wasm-limit-config.js",
|
||||
"js/components/admin/lnbits-admin-notifications.js",
|
||||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
@@ -92,7 +89,6 @@
|
||||
"js/components/lnbits-theme.js",
|
||||
"js/components/lnbits-qrcode-scanner.js",
|
||||
"js/components/lnbits-manage-extension-list.js",
|
||||
"js/components/lnbits-extension-permissions.js",
|
||||
"js/components/lnbits-manage-wallet-list.js",
|
||||
"js/components/lnbits-language-dropdown.js",
|
||||
"js/components/lnbits-payment-list.js",
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Callable, Coroutine
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class PublicTask(BaseModel):
|
||||
"""Public model used to expose task information via the API."""
|
||||
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class Task:
|
||||
"""Model used on the backend to keep track of background tasks."""
|
||||
|
||||
coro: Coroutine
|
||||
name: str
|
||||
created_at: datetime
|
||||
task: asyncio.Task
|
||||
invoice_queue: asyncio.Queue[Payment] | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coro: Coroutine,
|
||||
name: str | None = None,
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
) -> None:
|
||||
self.coro = coro
|
||||
self.name = name or f"task_{uuid.uuid4()}"
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.task = asyncio.create_task(self.coro, name=self.name)
|
||||
self.invoice_queue = invoice_queue
|
||||
|
||||
|
||||
class TaskManager:
|
||||
"""Singleton class to manage background tasks."""
|
||||
|
||||
tasks: list[Task] = []
|
||||
invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
|
||||
internal_invoice_queue: asyncio.Queue[Payment] = asyncio.Queue()
|
||||
|
||||
def init(self) -> None:
|
||||
self.create_permanent_task(
|
||||
func=self._heart_beat,
|
||||
interval=settings.task_heart_beat_interval,
|
||||
)
|
||||
self.create_permanent_task(self._invoice_listener_consumer)
|
||||
self.create_permanent_task(self._internal_invoice_listener_consumer)
|
||||
|
||||
def get_task(self, name: str) -> Task | None:
|
||||
"""Get a running task by name."""
|
||||
for task in self.tasks:
|
||||
if task.name == name:
|
||||
return task
|
||||
return None
|
||||
|
||||
def get_public_tasks(self) -> list[PublicTask]:
|
||||
"""Get a list of public tasks."""
|
||||
return [PublicTask(name=t.name, created_at=t.created_at) for t in self.tasks]
|
||||
|
||||
def cancel_task(self, task: Task) -> None:
|
||||
"""Cancel a running task."""
|
||||
self.tasks.remove(task)
|
||||
try:
|
||||
task.task.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task `{task.name}`: {exc!s}")
|
||||
|
||||
def cancel_all_tasks(self) -> None:
|
||||
"""Cancel all running tasks."""
|
||||
for task in list(self.tasks):
|
||||
self.cancel_task(task)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
coro: Coroutine,
|
||||
name: str | None = None,
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
) -> Task:
|
||||
"""Create a task. If a task with the same name exists, it will be cancelled."""
|
||||
if name:
|
||||
task = self.get_task(name)
|
||||
if task:
|
||||
self.cancel_task(task)
|
||||
task = Task(coro=coro, name=name, invoice_queue=invoice_queue)
|
||||
self.tasks.append(task)
|
||||
return task
|
||||
|
||||
def create_permanent_task(
|
||||
self,
|
||||
func: Callable[[], Coroutine],
|
||||
invoice_queue: asyncio.Queue | None = None,
|
||||
name: str | None = None,
|
||||
interval: int = 0,
|
||||
) -> Task:
|
||||
"""Create a task that runs forever and restarts on failure."""
|
||||
|
||||
async def wrapper():
|
||||
while settings.lnbits_running:
|
||||
await self._catch_everything_and_restart(func)
|
||||
if interval > 0:
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
return self.create_task(
|
||||
coro=wrapper(), name=name or func.__name__, invoice_queue=invoice_queue
|
||||
)
|
||||
|
||||
def register_invoice_listener(
|
||||
self,
|
||||
func: Callable[[Payment], Coroutine],
|
||||
name: str | None = None,
|
||||
) -> Task:
|
||||
"""
|
||||
A method intended for extensions to call when they want to be notified about
|
||||
incoming payments. Will call provided Coroutine with the updated payment.
|
||||
"""
|
||||
name = f"{name or uuid.uuid4()}_invoice_listener"
|
||||
queue: asyncio.Queue[Payment] = asyncio.Queue()
|
||||
return self.create_permanent_task(
|
||||
self._invoice_listener_worker(func, queue),
|
||||
name=name,
|
||||
invoice_queue=queue,
|
||||
)
|
||||
|
||||
async def _heart_beat(self) -> None:
|
||||
"""A heartbeat that removes done tasks logs the number of tasks."""
|
||||
for task in self.tasks:
|
||||
state = task.task._state if task.task else "NOT RUNNING"
|
||||
if settings.task_heart_beat_verbose:
|
||||
logger.debug(
|
||||
f"Task Manager: `{task.name}` state: `{state}` "
|
||||
f"created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}`"
|
||||
)
|
||||
if task.task and task.task.done():
|
||||
logger.debug(f"Task Manager: task `{task.name}` is done.")
|
||||
self.cancel_task(task)
|
||||
listeners_count = sum(1 for task in self.tasks if task.invoice_queue)
|
||||
logger.debug(
|
||||
f"Task Manager: {len(self.tasks) - listeners_count} tasks "
|
||||
f"and {listeners_count} invoice listeners."
|
||||
)
|
||||
|
||||
async def _catch_everything_and_restart(
|
||||
self,
|
||||
func: Callable[[], Coroutine],
|
||||
restart_interval: int = 5,
|
||||
) -> None:
|
||||
"""Catches all exceptions from a function and restarts it after 5 seconds."""
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
return await func()
|
||||
except asyncio.CancelledError:
|
||||
raise # because we must pass this up
|
||||
except Exception as exc:
|
||||
if not settings.lnbits_running:
|
||||
return
|
||||
logger.error(f"exception in background task `{func.__name__}`:", exc)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.info(
|
||||
f"`{func.__name__}` restarts in {restart_interval} seconds."
|
||||
)
|
||||
await asyncio.sleep(restart_interval)
|
||||
|
||||
def _invoice_listener_worker(
|
||||
self, func: Callable[[Payment], Coroutine], queue: asyncio.Queue[Payment]
|
||||
) -> Callable:
|
||||
async def wrapper() -> None:
|
||||
payment: Payment = await queue.get()
|
||||
await func(payment)
|
||||
|
||||
return wrapper
|
||||
|
||||
def _invoice_dispatcher(self, payment: Payment) -> None:
|
||||
"""Dispatches a payment to all registered invoice listeners."""
|
||||
for task in self.tasks:
|
||||
if not task.invoice_queue:
|
||||
continue
|
||||
logger.debug(f"Enqueing payment to task {task.name}")
|
||||
task.invoice_queue.put_nowait(payment)
|
||||
|
||||
async def _invoice_listener_consumer(self) -> None:
|
||||
payment = await self.invoice_queue.get()
|
||||
logger.info(f"got a payment notification {payment.checking_id}")
|
||||
self._invoice_dispatcher(payment)
|
||||
|
||||
async def _internal_invoice_listener_consumer(self) -> None:
|
||||
payment = await self.internal_invoice_queue.get()
|
||||
logger.info(f"got an internal payment notification {payment.checking_id}")
|
||||
self._invoice_dispatcher(payment)
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
+120
-33
@@ -1,83 +1,146 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Callable, Coroutine
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services.payments import get_standalone_payment
|
||||
from lnbits.core.services.payments import (
|
||||
get_standalone_payment,
|
||||
update_invoice_from_paid_invoices_stream,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
tasks: list[asyncio.Task] = []
|
||||
unique_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
|
||||
# DEPRECATED: use task_manager.create_task instead.
|
||||
def create_task(coro: Coroutine) -> asyncio.Task:
|
||||
logger.debug("DEPRECATED: use task_manager.create_task instead.")
|
||||
return task_manager.create_task(coro).task
|
||||
task = asyncio.create_task(coro)
|
||||
tasks.append(task)
|
||||
return task
|
||||
|
||||
|
||||
# DEPRECATED: use task_manager.create_task with `name` kwarg.
|
||||
def create_unique_task(name: str, coro: Coroutine) -> asyncio.Task:
|
||||
logger.debug("DEPRECATED: use task_manager.create_task instead.")
|
||||
return task_manager.create_task(coro, name=name).task
|
||||
if unique_tasks.get(name):
|
||||
logger.warning(f"task `{name}` already exists, cancelling it")
|
||||
try:
|
||||
unique_tasks[name].cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
|
||||
task = asyncio.create_task(coro)
|
||||
unique_tasks[name] = task
|
||||
return task
|
||||
|
||||
|
||||
# DEPRECATED: use task_manager.create_permanent_task instead.
|
||||
def create_permanent_task(func: Callable[[], Coroutine]) -> asyncio.Task:
|
||||
logger.debug("DEPRECATED: use task_manager.create_permanent_task instead.")
|
||||
return task_manager.create_permanent_task(func).task
|
||||
return create_task(catch_everything_and_restart(func))
|
||||
|
||||
|
||||
# DEPRECATED: use task_manager.create_permanent_task with `name` argument instead.
|
||||
def create_permanent_unique_task(
|
||||
name: str, coro: Callable[[], Coroutine]
|
||||
) -> asyncio.Task:
|
||||
return create_unique_task(name, catch_everything_and_restart(coro, name))
|
||||
|
||||
|
||||
# DEPRECATED don't use this, use task_manager.create_permanent_task instead.
|
||||
def cancel_all_tasks() -> None:
|
||||
for task in tasks:
|
||||
try:
|
||||
task.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task: {exc!s}")
|
||||
for name, task in unique_tasks.items():
|
||||
try:
|
||||
task.cancel()
|
||||
except Exception as exc:
|
||||
logger.warning(f"error while cancelling task `{name}`: {exc!s}")
|
||||
|
||||
|
||||
async def catch_everything_and_restart(
|
||||
func: Callable[[], Coroutine],
|
||||
name: str = "unnamed",
|
||||
) -> None:
|
||||
_ = name
|
||||
return await task_manager._catch_everything_and_restart(func)
|
||||
) -> Coroutine:
|
||||
try:
|
||||
return await func()
|
||||
except asyncio.CancelledError:
|
||||
raise # because we must pass this up
|
||||
except Exception as exc:
|
||||
logger.error(f"exception in background task `{name}`:", exc)
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("will restart the task in 5 seconds.")
|
||||
await asyncio.sleep(5)
|
||||
return await catch_everything_and_restart(func, name)
|
||||
|
||||
|
||||
invoice_listeners: dict[str, asyncio.Queue] = {}
|
||||
|
||||
|
||||
# TODO: name should not be optional
|
||||
# some extensions still dont use a name, but they should
|
||||
def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None):
|
||||
"""
|
||||
DEPRECATED: use task_manager.register_invoice_listener instead,
|
||||
which also allows to pass a callback instead of a queue.
|
||||
This method will still work but it is not recommended for new code.
|
||||
A method intended for extensions (and core/tasks.py) to call when they want to be
|
||||
notified about new invoice payments incoming. Will emit all incoming payments.
|
||||
"""
|
||||
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
|
||||
name = f"forward_{name or str(uuid.uuid4())[:8]}"
|
||||
if not name:
|
||||
# fallback to a random name if extension didn't provide one
|
||||
name = f"no_name_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# here we just forwarding the payments to the provided queue
|
||||
async def forward_queue(payment: Payment):
|
||||
send_chan.put_nowait(payment)
|
||||
if invoice_listeners.get(name):
|
||||
logger.warning(f"invoice listener `{name}` already exists, replacing it")
|
||||
|
||||
task_manager.register_invoice_listener(forward_queue, name=name)
|
||||
logger.trace(f"registering invoice listener `{name}`")
|
||||
invoice_listeners[name] = send_chan
|
||||
|
||||
|
||||
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
|
||||
|
||||
|
||||
async def internal_invoice_queue_put(checking_id: str) -> None:
|
||||
"""
|
||||
DEPRECATED: use task_manager.internal_invoice_queue instead,
|
||||
A method to call when it wants to notify about an internal invoice payment.
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"internal_invoice_queue_put: payment {checking_id} not found")
|
||||
return
|
||||
await task_manager.internal_invoice_queue.put(payment)
|
||||
await internal_invoice_queue.put(checking_id)
|
||||
|
||||
|
||||
async def internal_invoice_listener() -> None:
|
||||
"""
|
||||
internal_invoice_queue will be filled directly in core/services.py
|
||||
after the payment was deemed to be settled internally.
|
||||
|
||||
Called by the app startup sequence.
|
||||
"""
|
||||
while settings.lnbits_running:
|
||||
checking_id = await internal_invoice_queue.get()
|
||||
logger.info(f"got an internal payment notification {checking_id}")
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if payment:
|
||||
logger.success(f"internal invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
|
||||
|
||||
async def invoice_listener() -> None:
|
||||
"""
|
||||
invoice_listener will collect all invoices that come directly
|
||||
from the backend wallet.
|
||||
|
||||
Called by the app startup sequence.
|
||||
"""
|
||||
funding_source = get_funding_source()
|
||||
async for checking_id in funding_source.paid_invoices_stream():
|
||||
logger.info(f"got a payment notification {checking_id}")
|
||||
payment = await update_invoice_from_paid_invoices_stream(checking_id)
|
||||
if payment:
|
||||
logger.success(f"fundingsource invoice {checking_id} settled")
|
||||
await invoice_callback_dispatcher(payment)
|
||||
|
||||
|
||||
# DEPRECATED use task_manager.register_invoice_listener(coro, name="myext")
|
||||
def wait_for_paid_invoices(
|
||||
invoice_listener_name: str,
|
||||
func: Callable[[Payment], Coroutine],
|
||||
) -> Callable[[], Coroutine]:
|
||||
logger.debug("DEPRECATED: use task_manager.register_invoice_listener instead.")
|
||||
|
||||
async def wrapper() -> None:
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
@@ -87,3 +150,27 @@ def wait_for_paid_invoices(
|
||||
await func(payment)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def run_interval(
|
||||
interval_seconds: int,
|
||||
func: Callable[[], Coroutine],
|
||||
) -> Callable[[], Coroutine]:
|
||||
"""Run a function at a specified interval in seconds, while the server is running"""
|
||||
|
||||
async def wrapper() -> None:
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await func()
|
||||
except Exception as e:
|
||||
logger.error(f"Error occurred in interval task: {e}")
|
||||
logger.warning(traceback.format_exc())
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
async def invoice_callback_dispatcher(payment: Payment):
|
||||
for name, send_chan in invoice_listeners.items():
|
||||
logger.trace(f"invoice listeners: sending to `{name}`")
|
||||
await send_chan.put(payment)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{% include('components/admin/funding_seed_backup.vue') %} {%
|
||||
include('components/admin/funding.vue') %} {%
|
||||
{% include('components/admin/funding.vue') %} {%
|
||||
include('components/admin/funding_sources.vue') %} {%
|
||||
include('components/admin/fiat_providers.vue') %} {%
|
||||
include('components/admin/exchange_providers.vue') %} {%
|
||||
@@ -8,8 +7,6 @@ include('components/admin/users.vue') %} {%
|
||||
include('components/admin/site_customisation.vue') %} {%
|
||||
include('components/admin/audit.vue') %} {%
|
||||
include('components/admin/extensions.vue') %} {%
|
||||
include('components/admin/wasm-runtime.vue') %} {%
|
||||
include('components/admin/wasm-limit-config.vue') %} {%
|
||||
include('components/admin/assets-config.vue') %} {%
|
||||
include('components/admin/notifications.vue') %} {%
|
||||
include('components/admin/server.vue') %} {%
|
||||
@@ -22,7 +19,6 @@ include('components/lnbits-header-wallets.vue') %} {%
|
||||
include('components/lnbits-drawer.vue') %} {%
|
||||
include('components/lnbits-home-logos.vue') %} {%
|
||||
include('components/lnbits-manage-extension-list.vue') %} {%
|
||||
include('components/lnbits-extension-permissions.vue') %} {%
|
||||
include('components/lnbits-manage-wallet-list.vue') %} {%
|
||||
include('components/lnbits-language-dropdown.vue') %} {%
|
||||
include('components/lnbits-payment-list.vue') %} {%
|
||||
|
||||
@@ -1,46 +1,7 @@
|
||||
<template id="lnbits-admin-exchange-providers">
|
||||
<h6 class="q-my-none q-mb-xs">LNbits Price Aggregator</h6>
|
||||
<p class="q-mb-md text-caption text-grey">
|
||||
A privacy-friendly, open-source Bitcoin price aggregator maintained by the
|
||||
LNbits team. Aggregates prices from multiple exchanges and returns a median,
|
||||
no API keys required.
|
||||
<a href="https://price.lnbits.com" target="_blank" rel="noopener"
|
||||
>price.lnbits.com</a
|
||||
>
|
||||
—
|
||||
<a
|
||||
href="https://github.com/lnbits/lnbits-price-aggregator"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>GitHub</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="row q-mb-md items-start">
|
||||
<div class="col-auto q-mr-md q-mt-sm">
|
||||
<q-toggle
|
||||
v-model="formData.lnbits_price_aggregator_enabled"
|
||||
@update:model-value="formData.touch = null"
|
||||
label="Use Price Aggregator"
|
||||
>
|
||||
</q-toggle>
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
<q-input
|
||||
filled
|
||||
v-model="formData.lnbits_price_aggregator_url"
|
||||
type="text"
|
||||
label="Price Aggregator URL"
|
||||
hint="Fetch BTC price from this aggregator instead of individual providers below."
|
||||
:disable="!formData.lnbits_price_aggregator_enabled"
|
||||
@update:model-value="formData.touch = null"
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">Bitcoin Price History</h6>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-8">
|
||||
@@ -92,11 +53,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
<h6 class="q-my-none q-mb-sm">
|
||||
<span v-text="$t('exchange_providers')"></span>
|
||||
</h6>
|
||||
|
||||
<div class="row q-mt-md">
|
||||
<div class="col-6">
|
||||
<q-btn
|
||||
@@ -104,7 +60,6 @@
|
||||
label="Add Exchange Provider"
|
||||
color="primary"
|
||||
class="q-mb-md"
|
||||
:disable="formData.lnbits_price_aggregator_enabled"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
@@ -115,20 +70,12 @@
|
||||
:label="$t('reset_defaults')"
|
||||
color="primary"
|
||||
class="float-right"
|
||||
:disable="formData.lnbits_price_aggregator_enabled"
|
||||
>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-auto"
|
||||
:style="
|
||||
formData.lnbits_price_aggregator_enabled
|
||||
? 'opacity:0.4;pointer-events:none'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="overflow-auto">
|
||||
<q-table
|
||||
row-key="name"
|
||||
:rows="formData.lnbits_exchange_rate_providers"
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<template id="lnbits-admin-extensions">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('extensions')"></span>
|
||||
</h6>
|
||||
</div>
|
||||
<h6 class="q-my-none">
|
||||
<span v-text="$t('extensions')"></span>
|
||||
</h6>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 q-mb-md">
|
||||
<p>
|
||||
@@ -35,27 +33,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 q-mb-md">
|
||||
<p>Wasm Extension</p>
|
||||
<div class="row q-gutter-sm">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="memory"
|
||||
label="WASM Runtime"
|
||||
to="/admin/extensions/wasm"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="tune"
|
||||
label="Wasm Limit Config"
|
||||
to="/admin/extensions/wasm/limits"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<p>
|
||||
@@ -147,15 +124,6 @@
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
v-model.number="formData.lnbits_wasm_invocation_retention_days"
|
||||
type="number"
|
||||
min="0"
|
||||
label="WASM invocation retention days"
|
||||
hint="Set to 0 to disable automatic cleanup."
|
||||
></q-input>
|
||||
<br />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
|
||||
@@ -301,11 +301,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<lnbits-admin-funding-seed-backup
|
||||
:active="active"
|
||||
:is-super-user="isSuperUser"
|
||||
:form-data="formData"
|
||||
:settings="settings"
|
||||
></lnbits-admin-funding-seed-backup>
|
||||
</q-card-section>
|
||||
</template>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
<template id="lnbits-admin-funding-seed-backup">
|
||||
<q-dialog v-model="dialog.show">
|
||||
<q-card style="width: 760px; max-width: 95vw; border-radius: 8px">
|
||||
<q-card-section class="q-pb-md">
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div class="col-6">
|
||||
<q-chip
|
||||
square
|
||||
class="full-width"
|
||||
icon="looks_one"
|
||||
:color="dialog.step === 1 ? 'primary' : 'grey-9'"
|
||||
text-color="white"
|
||||
label="Backup"
|
||||
></q-chip>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-chip
|
||||
square
|
||||
class="full-width"
|
||||
icon="looks_two"
|
||||
:color="dialog.step === 2 ? 'primary' : 'grey-9'"
|
||||
text-color="white"
|
||||
label="Verify"
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-separator></q-separator>
|
||||
|
||||
<q-card-section v-if="dialog.step === 1">
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div>
|
||||
<div
|
||||
class="text-subtitle1"
|
||||
v-text="`${seedWords.length}-word recovery phrase`"
|
||||
></div>
|
||||
<div
|
||||
class="text-caption text-grey-5"
|
||||
v-text="'Write these words down in order.'"
|
||||
></div>
|
||||
</div>
|
||||
<q-btn
|
||||
outline
|
||||
no-caps
|
||||
color="primary"
|
||||
:icon="dialog.visible ? 'visibility_off' : 'visibility'"
|
||||
:label="dialog.visible ? 'Hide words' : 'Show words'"
|
||||
@click="dialog.visible = !dialog.visible"
|
||||
></q-btn>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-sm">
|
||||
<div
|
||||
class="col-4 col-md-3"
|
||||
v-for="word in seedWords"
|
||||
:key="word.index"
|
||||
>
|
||||
<div
|
||||
class="row items-center no-wrap rounded-borders"
|
||||
style="
|
||||
min-height: 42px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="text-caption text-grey-5 text-center"
|
||||
style="
|
||||
width: 42px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||
"
|
||||
v-text="word.index + 1"
|
||||
></div>
|
||||
<div
|
||||
class="text-body2 text-weight-medium q-px-sm"
|
||||
style="min-width: 0; overflow-wrap: anywhere"
|
||||
v-text="dialog.visible ? word.word : '••••••'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-mt-lg">
|
||||
<q-btn
|
||||
color="primary"
|
||||
no-caps
|
||||
label="I have written it down"
|
||||
@click="prepareChallenge"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="dialog.step === 2">
|
||||
<div class="q-mb-md">
|
||||
<div class="text-subtitle1" v-text="'Confirm your backup'"></div>
|
||||
<div
|
||||
class="text-caption text-grey-5"
|
||||
v-text="
|
||||
'Enter the requested words from your written recovery phrase.'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-md">
|
||||
<div
|
||||
class="col-12 col-sm-6"
|
||||
v-for="word in dialog.challenge"
|
||||
:key="word.index"
|
||||
>
|
||||
<q-input
|
||||
v-model.trim="dialog.answers[word.index]"
|
||||
filled
|
||||
:label="`Word ${word.index + 1}`"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-negative q-mt-sm"
|
||||
v-if="dialog.error"
|
||||
v-text="dialog.error"
|
||||
></div>
|
||||
<div class="row justify-between q-mt-lg">
|
||||
<q-btn flat no-caps label="Back" @click="dialog.step = 1"></q-btn>
|
||||
<q-btn
|
||||
color="primary"
|
||||
icon="check"
|
||||
no-caps
|
||||
label="Confirm backup"
|
||||
@click="submitChallenge"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
@@ -1,242 +0,0 @@
|
||||
<template id="lnbits-admin-wasm-limit-config">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md q-col-gutter-sm">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn flat dense round icon="arrow_back" :to="backRoute">
|
||||
<q-tooltip v-text="backTooltip"></q-tooltip>
|
||||
</q-btn>
|
||||
<div>
|
||||
<h6 class="q-my-none">Wasm Limit Config</h6>
|
||||
<div
|
||||
class="text-caption text-grey-7"
|
||||
v-text="pageDescription"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn-dropdown
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="tune"
|
||||
label="Custom Extension Limit"
|
||||
:loading="wasmRuntimeLimitExtensionsLoading"
|
||||
>
|
||||
<q-list style="min-width: 280px; max-width: min(420px, 90vw)">
|
||||
<q-item-label header>
|
||||
Select an extension from the list to customize
|
||||
</q-item-label>
|
||||
<q-item
|
||||
v-if="
|
||||
!wasmRuntimeLimitExtensionsLoading &&
|
||||
wasmRuntimeLimitExtensionOptions.length === 0
|
||||
"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>No installed WASM extensions found.</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
v-for="extension in wasmRuntimeLimitExtensionOptions"
|
||||
:key="extension.value"
|
||||
clickable
|
||||
v-close-popup
|
||||
@click="openWasmExtensionLimit(extension.value)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label v-text="extension.label"></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-btn-dropdown>
|
||||
</div>
|
||||
|
||||
<template v-if="!isExtensionLimitRoute">
|
||||
<div
|
||||
v-for="(group, index) in wasmRuntimeLimitGroups"
|
||||
:key="group.title"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
:default-opened="group.title === 'Execution'"
|
||||
:label="group.title"
|
||||
header-class="text-subtitle2 text-weight-medium"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-pt-md">
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="field.name"
|
||||
class="col-12 col-md-6"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
v-model.number="formData[field.name]"
|
||||
:label="field.label"
|
||||
:hint="field.description"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="info"
|
||||
color="primary"
|
||||
@click.stop.prevent="showWasmLimitInfo(field)"
|
||||
>
|
||||
<q-tooltip max-width="360px">
|
||||
<span v-text="field.details"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator
|
||||
v-if="index < wasmRuntimeLimitGroups.length - 1"
|
||||
class="q-mt-md"
|
||||
></q-separator>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isExtensionLimitRoute"
|
||||
class="row items-center justify-between q-mb-md"
|
||||
>
|
||||
<div>
|
||||
<div class="text-subtitle1 text-weight-medium">
|
||||
Extension overrides
|
||||
</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
Empty values inherit the global defaults. A saved 0 disables that
|
||||
limit for the selected extension.
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedWasmRuntimeLimitExtension" class="col-12 col-md-7">
|
||||
<div class="row items-center q-gutter-sm full-height">
|
||||
<q-chip
|
||||
dense
|
||||
:color="
|
||||
selectedWasmRuntimeLimitExtension.active ? 'positive' : 'grey-7'
|
||||
"
|
||||
text-color="white"
|
||||
:label="
|
||||
selectedWasmRuntimeLimitExtension.active ? 'Active' : 'Inactive'
|
||||
"
|
||||
></q-chip>
|
||||
<q-chip
|
||||
dense
|
||||
outline
|
||||
color="primary"
|
||||
:label="customWasmLimitCount + ' custom overrides'"
|
||||
></q-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-banner
|
||||
v-if="
|
||||
isExtensionLimitRoute &&
|
||||
!wasmRuntimeLimitExtensionsLoading &&
|
||||
!selectedWasmRuntimeLimitExtension
|
||||
"
|
||||
rounded
|
||||
class="bg-grey-2 text-grey-8 q-mb-lg"
|
||||
>
|
||||
WASM extension not found.
|
||||
</q-banner>
|
||||
|
||||
<div v-if="isExtensionLimitRoute && selectedWasmRuntimeLimitExtension">
|
||||
<div
|
||||
v-for="(group, index) in wasmRuntimeLimitGroups"
|
||||
:key="'extension-' + group.title"
|
||||
class="q-mb-md"
|
||||
>
|
||||
<q-expansion-item
|
||||
expand-separator
|
||||
:default-opened="group.title === 'Execution'"
|
||||
:label="group.title"
|
||||
header-class="text-subtitle2 text-weight-medium"
|
||||
>
|
||||
<div class="row q-col-gutter-md q-pt-md">
|
||||
<div
|
||||
v-for="field in group.fields"
|
||||
:key="'extension-' + field.name"
|
||||
class="col-12 col-md-6"
|
||||
>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
min="0"
|
||||
v-model="wasmExtensionLimitDraft[field.name]"
|
||||
:label="field.label"
|
||||
:hint="wasmExtensionLimitHint(field)"
|
||||
:placeholder="wasmExtensionLimitPlaceholder(field)"
|
||||
>
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
round
|
||||
icon="info"
|
||||
color="primary"
|
||||
@click.stop.prevent="showWasmLimitInfo(field)"
|
||||
>
|
||||
<q-tooltip max-width="360px">
|
||||
<span v-text="field.details"></span>
|
||||
</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator
|
||||
v-if="index < wasmRuntimeLimitGroups.length - 1"
|
||||
class="q-mt-md"
|
||||
></q-separator>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-gutter-sm q-mt-md">
|
||||
<q-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="restart_alt"
|
||||
label="Clear Overrides"
|
||||
:disable="wasmExtensionLimitsSaving"
|
||||
@click="clearWasmExtensionLimits"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
icon="save"
|
||||
label="Save Extension Limits"
|
||||
:loading="wasmExtensionLimitsSaving"
|
||||
@click="saveWasmExtensionLimits"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="wasmLimitInfoDialog.show">
|
||||
<q-card style="width: min(560px, calc(100vw - 32px)); max-width: 560px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6" v-text="wasmLimitInfoDialog.title"></div>
|
||||
<q-space></q-space>
|
||||
<q-btn v-close-popup flat round dense icon="close"></q-btn>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div
|
||||
class="text-body1"
|
||||
style="line-height: 1.6"
|
||||
v-text="wasmLimitInfoDialog.details"
|
||||
></div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
@@ -1,245 +0,0 @@
|
||||
<template id="lnbits-admin-wasm-runtime">
|
||||
<q-card-section class="q-pa-none">
|
||||
<div>
|
||||
<div class="row items-center justify-between q-mb-md">
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="arrow_back"
|
||||
:to="
|
||||
wasmExtensionId ? '/admin/extensions/wasm' : '/admin#extensions'
|
||||
"
|
||||
>
|
||||
<q-tooltip
|
||||
v-text="
|
||||
wasmExtensionId ? 'Global WASM Runtime' : 'Extensions Settings'
|
||||
"
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<div>
|
||||
<h6 class="q-my-none">WASM Runtime</h6>
|
||||
<div
|
||||
v-if="wasmExtensionId"
|
||||
class="text-caption text-grey-7"
|
||||
v-text="`Extension: ${wasmExtensionId}`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
icon="refresh"
|
||||
:loading="wasmRuntimeLoading"
|
||||
@click="fetchWasmRuntime"
|
||||
>
|
||||
<q-tooltip>Refresh runtime data</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div
|
||||
v-for="item in wasmStatItems"
|
||||
:key="item.key"
|
||||
class="col-6 col-sm-4 col-md-2"
|
||||
>
|
||||
<q-card flat bordered class="full-height">
|
||||
<q-card-section class="q-pa-sm">
|
||||
<div class="row items-center no-wrap q-gutter-sm">
|
||||
<q-avatar
|
||||
rounded
|
||||
size="34px"
|
||||
:color="item.color"
|
||||
text-color="white"
|
||||
:icon="item.icon"
|
||||
></q-avatar>
|
||||
<div class="col">
|
||||
<div
|
||||
class="text-caption text-grey-7"
|
||||
v-text="item.label"
|
||||
></div>
|
||||
<div
|
||||
class="text-h6 text-weight-bold"
|
||||
v-text="formatWasmStat(item.key)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
class="q-mb-lg"
|
||||
dense
|
||||
flat
|
||||
wrap-cells
|
||||
:rows="wasmCurrentInvocations"
|
||||
:columns="wasmCurrentColumns"
|
||||
row-key="id"
|
||||
:loading="wasmRuntimeLoading"
|
||||
:pagination="{rowsPerPage: 5}"
|
||||
table-style="table-layout: fixed; width: 100%"
|
||||
title="Current Invocations"
|
||||
>
|
||||
<template v-slot:body-cell-extension_id="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
:label="props.row.extension_id"
|
||||
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-trigger_type="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
outline
|
||||
:color="wasmTriggerColor(props.row.trigger_type)"
|
||||
v-text="props.row.trigger_type"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
:color="wasmStatusColor(props.row.status)"
|
||||
v-text="props.row.status"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-user_id="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.user_id">
|
||||
<span v-text="formatWasmUserId(props.row.user_id)"></span>
|
||||
<q-tooltip v-text="props.row.user_id"></q-tooltip>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-started_at="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
v-text="formatWasmDate(props.row.started_at)"
|
||||
></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-duration_ms="props">
|
||||
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-context="props">
|
||||
<q-td :props="props">
|
||||
<div
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="formatWasmContext(props.row)"
|
||||
></div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-actions="props">
|
||||
<q-td :props="props">
|
||||
<div class="row justify-end q-gutter-xs">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
color="negative"
|
||||
icon="stop_circle"
|
||||
@click="stopWasmInvocation(props.row.id)"
|
||||
>
|
||||
<q-tooltip>Stop Invocation</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
dense
|
||||
unelevated
|
||||
color="negative"
|
||||
label="Deactivate Extension"
|
||||
@click="deactivateWasmExtension(props.row.extension_id)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
wrap-cells
|
||||
:rows="wasmInvocationHistory"
|
||||
:columns="wasmHistoryColumns"
|
||||
row-key="id"
|
||||
:loading="wasmHistoryLoading"
|
||||
:pagination="{rowsPerPage: 10}"
|
||||
table-style="table-layout: fixed; width: 100%"
|
||||
title="Recent Invocations"
|
||||
>
|
||||
<template v-slot:body-cell-extension_id="props">
|
||||
<q-td :props="props">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
no-caps
|
||||
color="primary"
|
||||
:label="props.row.extension_id"
|
||||
:to="`/admin/extensions/wasm/${encodeURIComponent(props.row.extension_id)}`"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-trigger_type="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
outline
|
||||
:color="wasmTriggerColor(props.row.trigger_type)"
|
||||
v-text="props.row.trigger_type"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-status="props">
|
||||
<q-td :props="props">
|
||||
<q-badge
|
||||
:color="wasmStatusColor(props.row.status)"
|
||||
v-text="props.row.status"
|
||||
></q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-user_id="props">
|
||||
<q-td :props="props">
|
||||
<span v-if="props.row.user_id">
|
||||
<span v-text="formatWasmUserId(props.row.user_id)"></span>
|
||||
<q-tooltip v-text="props.row.user_id"></q-tooltip>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-started_at="props">
|
||||
<q-td
|
||||
:props="props"
|
||||
v-text="formatWasmDate(props.row.started_at)"
|
||||
></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-duration_ms="props">
|
||||
<q-td :props="props" v-text="formatWasmDuration(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-calls="props">
|
||||
<q-td :props="props" v-text="formatWasmCalls(props.row)"></q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-context="props">
|
||||
<q-td :props="props">
|
||||
<div
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="formatWasmContext(props.row)"
|
||||
></div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-error_message="props">
|
||||
<q-td :props="props">
|
||||
<span
|
||||
style="white-space: normal; word-break: break-word"
|
||||
v-text="props.row.error_message || props.row.stop_reason || ''"
|
||||
></span>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</template>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template id="lnbits-extension-permissions">
|
||||
<q-list bordered separator>
|
||||
<q-expansion-item
|
||||
v-for="permission of displayItems"
|
||||
:key="permission.id"
|
||||
dense
|
||||
expand-separator
|
||||
class="q-pt-xs"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
<span v-text="permission.label"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
||||
side
|
||||
top
|
||||
>
|
||||
<div class="row items-center justify-end q-gutter-xs">
|
||||
<q-badge
|
||||
v-for="badge of permission.badges"
|
||||
:key="badge.key"
|
||||
outline
|
||||
color="primary"
|
||||
v-text="badge.label"
|
||||
></q-badge>
|
||||
<q-badge
|
||||
v-if="permission.risk.level !== 'low'"
|
||||
:color="permission.risk.color"
|
||||
v-text="permission.risk.label"
|
||||
></q-badge>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<div class="q-px-md q-pb-sm">
|
||||
<div
|
||||
v-if="permission.risk.warning"
|
||||
class="row items-center text-negative text-caption q-mb-xs"
|
||||
>
|
||||
<q-icon name="warning" size="16px" class="q-mr-xs"></q-icon>
|
||||
<span v-text="permission.risk.warning"></span>
|
||||
</div>
|
||||
<p
|
||||
v-for="description of permission.descriptions"
|
||||
:key="description"
|
||||
class="text-caption q-mb-xs"
|
||||
v-text="description"
|
||||
></p>
|
||||
<p
|
||||
v-for="policy of permission.invoicePolicies"
|
||||
:key="policy.table + ':' + policy.walletField"
|
||||
class="text-caption q-mb-xs"
|
||||
v-text="publicInvoicePolicySentence(policy)"
|
||||
></p>
|
||||
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
||||
<li v-for="group of permission.fieldGroups" :key="group.table">
|
||||
<span v-text="group.table"></span>
|
||||
<ul v-if="group.fields.length" class="q-pl-md">
|
||||
<li
|
||||
v-for="field of group.fields"
|
||||
:key="group.table + ':' + field"
|
||||
v-text="field"
|
||||
></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('extension_permission_extension_api_request_extensions')"
|
||||
></div>
|
||||
<div
|
||||
v-for="target of permission.extensionAccess"
|
||||
:key="target.id"
|
||||
class="row items-center q-gutter-xs q-mt-xs"
|
||||
>
|
||||
<span class="text-caption" v-text="target.name"></span>
|
||||
<q-badge
|
||||
v-for="access of target.access"
|
||||
:key="target.id + access"
|
||||
color="grey-7"
|
||||
v-text="permissionAccessLabel(access)"
|
||||
></q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="permission.httpHosts.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('extension_permission_http_request_hosts')"
|
||||
></div>
|
||||
<ul class="q-my-sm q-pl-md">
|
||||
<li
|
||||
v-for="host of permission.httpHosts"
|
||||
:key="host"
|
||||
v-text="host"
|
||||
></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</template>
|
||||
@@ -6,9 +6,6 @@
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-card-section>
|
||||
<q-banner dense rounded class="bg-warning text-black q-mb-md">
|
||||
These keys should be kept safe, sharing them could risk losing funds.
|
||||
</q-banner>
|
||||
<q-list>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
|
||||
@@ -74,13 +74,7 @@
|
||||
<div class="col q-gutter-y-md">
|
||||
<q-card>
|
||||
<!-- Mobile: Dropdown menu at top -->
|
||||
<div
|
||||
v-if="
|
||||
$q.screen.lt.md &&
|
||||
!['wasm-runtime', 'wasm-limit-config'].includes(tab)
|
||||
"
|
||||
class="q-px-md q-pt-md"
|
||||
>
|
||||
<div v-if="$q.screen.lt.md" class="q-px-md q-pt-md">
|
||||
<q-select
|
||||
v-model="tab"
|
||||
:options="[
|
||||
@@ -205,7 +199,6 @@
|
||||
>
|
||||
<q-tab-panel name="funding">
|
||||
<lnbits-admin-funding
|
||||
:active="tab === 'funding'"
|
||||
:is-super-user="isSuperUser"
|
||||
:settings="settings"
|
||||
:form-data="formData"
|
||||
@@ -226,12 +219,6 @@
|
||||
<q-tab-panel name="extensions">
|
||||
<lnbits-admin-extensions :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="wasm-runtime">
|
||||
<lnbits-admin-wasm-runtime :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="wasm-limit-config">
|
||||
<lnbits-admin-wasm-limit-config :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
<q-tab-panel name="notifications">
|
||||
<lnbits-admin-notifications :form-data="formData" />
|
||||
</q-tab-panel>
|
||||
|
||||
@@ -463,6 +463,10 @@
|
||||
<q-card v-if="permissionGrant.show" class="q-pa-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6" v-text="$t('extension_permissions_title')"></div>
|
||||
<div
|
||||
class="text-body2 q-mt-sm"
|
||||
v-text="$t('extension_permissions_request')"
|
||||
></div>
|
||||
<q-banner
|
||||
v-if="permissionGrantHasHighRisk()"
|
||||
dense
|
||||
@@ -475,11 +479,111 @@
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<lnbits-extension-permissions
|
||||
class="q-mt-md"
|
||||
:permissions="permissionGrant.permissions"
|
||||
:extensions="extensions"
|
||||
></lnbits-extension-permissions>
|
||||
<q-list bordered separator class="q-mt-md">
|
||||
<q-expansion-item
|
||||
v-for="permission of permissionGrantDisplayItems()"
|
||||
:key="permission.id"
|
||||
dense
|
||||
expand-separator
|
||||
class="q-pt-xs"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">
|
||||
<span v-text="permission.label"></span>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
v-if="permission.risk.level !== 'low' || permission.badges.length"
|
||||
side
|
||||
top
|
||||
>
|
||||
<div class="row items-center justify-end q-gutter-xs">
|
||||
<q-badge
|
||||
v-for="badge of permission.badges"
|
||||
:key="badge.key"
|
||||
outline
|
||||
color="primary"
|
||||
v-text="badge.label"
|
||||
></q-badge>
|
||||
<q-badge
|
||||
v-if="permission.risk.level !== 'low'"
|
||||
:color="permission.risk.color"
|
||||
v-text="permission.risk.label"
|
||||
></q-badge>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<div class="q-px-md q-pb-sm">
|
||||
<div
|
||||
v-if="permission.risk.warning"
|
||||
class="row items-center text-negative text-caption q-mb-xs"
|
||||
>
|
||||
<q-icon name="warning" size="16px" class="q-mr-xs"></q-icon>
|
||||
<span v-text="permission.risk.warning"></span>
|
||||
</div>
|
||||
<p
|
||||
v-for="description of permission.descriptions"
|
||||
:key="description"
|
||||
class="text-caption q-mb-xs"
|
||||
v-text="description"
|
||||
></p>
|
||||
<p
|
||||
v-for="policy of permission.invoicePolicies"
|
||||
:key="policy.table + ':' + policy.walletField"
|
||||
class="text-caption q-mb-xs"
|
||||
v-text="publicInvoicePolicySentence(policy)"
|
||||
></p>
|
||||
<ul v-if="permission.fieldGroups.length" class="q-my-sm q-pl-md">
|
||||
<li v-for="group of permission.fieldGroups" :key="group.table">
|
||||
<span v-text="group.table"></span>
|
||||
<ul v-if="group.fields.length" class="q-pl-md">
|
||||
<li
|
||||
v-for="field of group.fields"
|
||||
:key="group.table + ':' + field"
|
||||
v-text="field"
|
||||
></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="permission.extensionAccess.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="
|
||||
$t('extension_permission_extension_api_request_extensions')
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
v-for="target of permission.extensionAccess"
|
||||
:key="target.id"
|
||||
class="row items-center q-gutter-xs q-mt-xs"
|
||||
>
|
||||
<span class="text-caption" v-text="target.name"></span>
|
||||
<q-badge
|
||||
v-for="access of target.access"
|
||||
:key="target.id + access"
|
||||
color="grey-7"
|
||||
v-text="permissionAccessLabel(access)"
|
||||
></q-badge>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="permission.httpHosts.length" class="q-mt-sm">
|
||||
<div
|
||||
class="text-caption text-grey"
|
||||
v-text="$t('extension_permission_http_request_hosts')"
|
||||
></div>
|
||||
<ul class="q-my-sm q-pl-md">
|
||||
<li
|
||||
v-for="host of permission.httpHosts"
|
||||
:key="host"
|
||||
v-text="host"
|
||||
></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
||||
+17
-6
@@ -1,8 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from time import time
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
||||
class Cached(NamedTuple):
|
||||
value: Any
|
||||
@@ -17,7 +22,8 @@ class Cache:
|
||||
Small caching utility providing simple get/set interface (very much like redis)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, interval: float = 10) -> None:
|
||||
self.interval = interval
|
||||
self._values: dict[Any, Cached] = {}
|
||||
|
||||
def value(self, key: str) -> Cached | None:
|
||||
@@ -53,11 +59,16 @@ class Cache:
|
||||
self.set(key, value, expiry=expiry)
|
||||
return value
|
||||
|
||||
async def invalidate_cache(self):
|
||||
ts = time()
|
||||
expired = [k for k, v in self._values.items() if v.expiry < ts]
|
||||
for k in expired:
|
||||
self._values.pop(k)
|
||||
async def invalidate_forever(self):
|
||||
while settings.lnbits_running:
|
||||
try:
|
||||
await asyncio.sleep(self.interval)
|
||||
ts = time()
|
||||
expired = [k for k, v in self._values.items() if v.expiry < ts]
|
||||
for k in expired:
|
||||
self._values.pop(k)
|
||||
except Exception:
|
||||
logger.error("Error invalidating cache")
|
||||
|
||||
|
||||
cache = Cache()
|
||||
|
||||
@@ -289,32 +289,7 @@ async def btc_rates(currency: str) -> list[tuple[str, float]]:
|
||||
return apply_trimmed_mean_filter(all_rates)
|
||||
|
||||
|
||||
async def btc_price_from_aggregator(currency: str) -> float | None:
|
||||
url = settings.lnbits_price_aggregator_url.rstrip("/")
|
||||
try:
|
||||
headers = {"User-Agent": settings.user_agent}
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
r = await client.get(f"{url}/rate/{currency.upper()}", timeout=3)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
median = data.get("rates", {}).get("median")
|
||||
if median:
|
||||
return float(median)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch price from aggregator {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def btc_price(currency: str) -> float:
|
||||
if (
|
||||
settings.lnbits_price_aggregator_enabled
|
||||
and settings.lnbits_price_aggregator_url
|
||||
):
|
||||
price = await btc_price_from_aggregator(currency)
|
||||
if price:
|
||||
return price
|
||||
logger.warning("Price aggregator failed, falling back to exchange providers.")
|
||||
|
||||
rates = await btc_rates(currency)
|
||||
if not rates:
|
||||
logger.warning("Could not fetch any Bitcoin price.")
|
||||
|
||||
@@ -41,16 +41,19 @@ def log_server_info():
|
||||
|
||||
def initialize_server_websocket_logger() -> Callable:
|
||||
super_user_hash = sha256(settings.super_user.encode("utf-8")).hexdigest()
|
||||
|
||||
serverlog_queue: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
while settings.lnbits_running:
|
||||
msg = await serverlog_queue.get()
|
||||
await websocket_updater(super_user_hash, msg)
|
||||
|
||||
logger.add(
|
||||
lambda msg: serverlog_queue.put_nowait(msg),
|
||||
format=Formatter().format,
|
||||
)
|
||||
|
||||
async def update_websocket_serverlog():
|
||||
msg = await serverlog_queue.get()
|
||||
await websocket_updater(super_user_hash, msg)
|
||||
|
||||
return update_websocket_serverlog
|
||||
|
||||
|
||||
|
||||
+138
-921
File diff suppressed because it is too large
Load Diff
@@ -111,7 +111,6 @@
|
||||
"js/pages/users.js",
|
||||
"js/pages/account.js",
|
||||
"js/pages/admin.js",
|
||||
"js/components/admin/lnbits-admin-funding-seed-backup.js",
|
||||
"js/components/admin/lnbits-admin-funding.js",
|
||||
"js/components/admin/lnbits-admin-funding-sources.js",
|
||||
"js/components/admin/lnbits-admin-fiat-providers.js",
|
||||
@@ -120,8 +119,6 @@
|
||||
"js/components/admin/lnbits-admin-users.js",
|
||||
"js/components/admin/lnbits-admin-server.js",
|
||||
"js/components/admin/lnbits-admin-extensions.js",
|
||||
"js/components/admin/lnbits-admin-wasm-runtime.js",
|
||||
"js/components/admin/lnbits-admin-wasm-limit-config.js",
|
||||
"js/components/admin/lnbits-admin-notifications.js",
|
||||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-assets-config.js",
|
||||
@@ -145,7 +142,6 @@
|
||||
"js/components/lnbits-theme.js",
|
||||
"js/components/lnbits-qrcode-scanner.js",
|
||||
"js/components/lnbits-manage-extension-list.js",
|
||||
"js/components/lnbits-extension-permissions.js",
|
||||
"js/components/lnbits-manage-wallet-list.js",
|
||||
"js/components/lnbits-language-dropdown.js",
|
||||
"js/components/lnbits-payment-list.js",
|
||||
|
||||
Generated
+109
-2
@@ -1633,7 +1633,7 @@ version = "3.3.2"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
groups = ["main", "dev"]
|
||||
files = [
|
||||
{file = "greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d"},
|
||||
{file = "greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13"},
|
||||
@@ -2972,6 +2972,28 @@ files = [
|
||||
{file = "platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.61.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "playwright-1.61.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:ff138c3a604f69911e9d42fd036e55c2a171e5616edf04c1e7f60a2a285540b0"},
|
||||
{file = "playwright-1.61.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:009588c2a7e499bc5a8b425b61fa65490968bbda9cd69e0cf2cff10f8304659a"},
|
||||
{file = "playwright-1.61.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:9f7de4536088d12037c13a52b7ea34b59270b78926bb56935070597ffac6b1af"},
|
||||
{file = "playwright-1.61.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:54f3b39f6eab832e33458c1dd7da0b5682aedab3b09ae731b5c59fa12fd2024e"},
|
||||
{file = "playwright-1.61.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93454322ade8c11d5d6c211bfd91bdfb9ffb4810e3e026371bcbc4bec1b7ee4c"},
|
||||
{file = "playwright-1.61.0-py3-none-win32.whl", hash = "sha256:372d55a6f1248fa1dd47599686980cb8fb5bbe6fcda59eab793eb657c11d8a9b"},
|
||||
{file = "playwright-1.61.0-py3-none-win_amd64.whl", hash = "sha256:35c6cc4589a5d00964a59d7b3e59641e0aac0c02f15479a7af77d20f6bc79597"},
|
||||
{file = "playwright-1.61.0-py3-none-win_arm64.whl", hash = "sha256:e9fcbffcf557a8620fdedd92491eb59a32d18e23d6f3b4f6214b952be324fe51"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = ">=3.1.1,<4.0.0"
|
||||
pyee = ">=13,<14"
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -3386,6 +3408,24 @@ typing-extensions = ">=4.2.0"
|
||||
dotenv = ["python-dotenv (>=0.10.4)"]
|
||||
email = ["email-validator (>=1.0.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228"},
|
||||
{file = "pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -3627,6 +3667,25 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
||||
[package.extras]
|
||||
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-base-url"
|
||||
version = "2.1.0"
|
||||
description = "pytest plugin for URL based testing"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6"},
|
||||
{file = "pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=7.0.0"
|
||||
requests = ">=2.9"
|
||||
|
||||
[package.extras]
|
||||
test = ["black (>=22.1.0)", "flake8 (>=4.0.1)", "pre-commit (>=2.17.0)", "pytest-localserver (>=0.7.1)", "tox (>=3.24.5)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
@@ -3695,6 +3754,24 @@ pytest = ">=6.2.5"
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "pytest-asyncio", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.8.0"
|
||||
description = "A pytest wrapper with fixtures for Playwright to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "pytest_playwright-0.8.0-py3-none-any.whl", hash = "sha256:856aae6efd4bc055f2ef229c647768760bcaad5cd3a5983c314ac260a974a933"},
|
||||
{file = "pytest_playwright-0.8.0.tar.gz", hash = "sha256:7888d4a2443160c82e0c506c437076679f86b36d1910427250d90fbf1843a981"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
playwright = ">=1.18"
|
||||
pytest = ">=6.2.4,<10.0.0"
|
||||
pytest-base-url = ">=1.0.0,<3.0.0"
|
||||
python-slugify = ">=6.0.0,<9.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "python-crontab"
|
||||
version = "3.3.0"
|
||||
@@ -3758,6 +3835,24 @@ files = [
|
||||
{file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
description = "A Python slugify application that also handles Unicode"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"},
|
||||
{file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
text-unidecode = ">=1.3"
|
||||
|
||||
[package.extras]
|
||||
unidecode = ["Unidecode (>=1.1.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.4.1"
|
||||
@@ -4350,6 +4445,18 @@ typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""
|
||||
[package.extras]
|
||||
full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "text-unidecode"
|
||||
version = "1.3"
|
||||
description = "The most basic Text::Unidecode port"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"},
|
||||
{file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tibs"
|
||||
version = "0.5.7"
|
||||
@@ -5148,4 +5255,4 @@ migration = ["psycopg2-binary"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.10,<3.13"
|
||||
content-hash = "3097673d0cd279b0bf2b8fa59c1e523273f63d430f2c68ccc268a3cf232068af"
|
||||
content-hash = "09f0ddc9546d2ea7e6fb686200e3e15ab7e8db0f1ac33d49c94afe1cbd701fe8"
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "lnbits"
|
||||
version = "1.5.6"
|
||||
version = "1.5.5"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
description = "LNbits, free and open-source Lightning wallet and accounts system."
|
||||
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
|
||||
@@ -86,6 +86,7 @@ dev = [
|
||||
"types-mock~=5.2.0.20250924",
|
||||
"mock~=5.2.0",
|
||||
"grpcio-tools~=1.76.0",
|
||||
"pytest-playwright>=0.8.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from lnbits.core.crud.settings import get_settings_field, set_settings_field
|
||||
from lnbits.server import server_restart
|
||||
from lnbits.settings import Settings
|
||||
|
||||
@@ -82,8 +81,7 @@ async def test_admin_audit_monitor_and_test_email(
|
||||
headers={"Authorization": f"Bearer {superuser_token}"},
|
||||
)
|
||||
assert monitor.status_code == 200
|
||||
task_names = [t["name"] for t in monitor.json()]
|
||||
assert any("invoice_listener" in name for name in task_names)
|
||||
assert "invoice_listeners" in monitor.json()
|
||||
|
||||
test_email = await client.get(
|
||||
"/admin/api/v1/testemail",
|
||||
@@ -152,15 +150,6 @@ async def test_admin_partial_reset_restart_and_backup(
|
||||
async def test_admin_delete_settings_requires_superuser(
|
||||
client: AsyncClient, superuser_token: str
|
||||
):
|
||||
await set_settings_field("lnbits_site_title", "Reset me")
|
||||
await set_settings_field("lnbits_backend_wallet_class", "BoltzWallet")
|
||||
await set_settings_field("boltz_mnemonic", "keep boltz seed")
|
||||
await set_settings_field("boltz_mnemonic_backup_confirmed", True)
|
||||
await set_settings_field("phoenixd_mnemonic", "keep phoenixd seed")
|
||||
await set_settings_field("phoenixd_mnemonic_backup_confirmed", True)
|
||||
await set_settings_field("spark_l2_mnemonic", "keep spark seed")
|
||||
await set_settings_field("spark_l2_mnemonic_backup_confirmed", True)
|
||||
|
||||
server_restart.clear()
|
||||
response = await client.delete(
|
||||
"/admin/api/v1/settings",
|
||||
@@ -168,21 +157,4 @@ async def test_admin_delete_settings_requires_superuser(
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert server_restart.is_set() is True
|
||||
assert await get_settings_field("lnbits_site_title") is None
|
||||
|
||||
backend_wallet = await get_settings_field("lnbits_backend_wallet_class")
|
||||
boltz_seed = await get_settings_field("boltz_mnemonic")
|
||||
boltz_confirmed = await get_settings_field("boltz_mnemonic_backup_confirmed")
|
||||
phoenixd_seed = await get_settings_field("phoenixd_mnemonic")
|
||||
phoenixd_confirmed = await get_settings_field("phoenixd_mnemonic_backup_confirmed")
|
||||
spark_l2_seed = await get_settings_field("spark_l2_mnemonic")
|
||||
spark_l2_confirmed = await get_settings_field("spark_l2_mnemonic_backup_confirmed")
|
||||
assert backend_wallet and backend_wallet.value == "BoltzWallet"
|
||||
assert boltz_seed and boltz_seed.value == "keep boltz seed"
|
||||
assert boltz_confirmed and boltz_confirmed.value is True
|
||||
assert phoenixd_seed and phoenixd_seed.value == "keep phoenixd seed"
|
||||
assert phoenixd_confirmed and phoenixd_confirmed.value is True
|
||||
assert spark_l2_seed and spark_l2_seed.value == "keep spark seed"
|
||||
assert spark_l2_confirmed and spark_l2_confirmed.value is True
|
||||
|
||||
server_restart.clear()
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ def run_before_and_after_tests(settings: Settings):
|
||||
@pytest.fixture(scope="session")
|
||||
async def app(settings: Settings):
|
||||
app = create_app()
|
||||
async with LifespanManager(app, startup_timeout=30) as manager:
|
||||
async with LifespanManager(app) as manager:
|
||||
settings.first_install = True
|
||||
await first_install(
|
||||
UpdateSuperuserPassword(
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"id": "lnbits-wasm-test-extension",
|
||||
"name": "WASM Test Extension",
|
||||
"short_description": "Minimal WASM extension used by LNbits e2e tests.",
|
||||
"version": "0.0.1",
|
||||
"min_lnbits_version": "1.5.5",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {
|
||||
"module": "wasm/module.wasm",
|
||||
"exports": [
|
||||
{
|
||||
"name": "list-wallets",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "invoice-details",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "get-selection",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "save-selection",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "pay-large-invoice",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "create-tip-jar",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "list-tip-jars",
|
||||
"visibility": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "get-public-tip-jar",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"name": "create-tip-invoice",
|
||||
"visibility": "public"
|
||||
},
|
||||
{
|
||||
"name": "record-payment",
|
||||
"visibility": "event"
|
||||
}
|
||||
]
|
||||
},
|
||||
"events": {
|
||||
"onInvoicePaid": "record-payment"
|
||||
},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/lnbits-wasm-test-extension",
|
||||
"entrypoint": "ui/admin.html",
|
||||
"auth": "user"
|
||||
},
|
||||
{
|
||||
"path": "/lnbits-wasm-test-extension/public/{item_id}",
|
||||
"entrypoint": "ui/public.html",
|
||||
"auth": "public",
|
||||
"path_params": {
|
||||
"item_id": "itemId"
|
||||
}
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/wallets",
|
||||
"export": "list-wallets",
|
||||
"auth": "user"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/payments",
|
||||
"export": "pay-large-invoice",
|
||||
"auth": "user"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/jars/{jar_id}",
|
||||
"export": "get-public-tip-jar",
|
||||
"auth": "public",
|
||||
"path_params": {
|
||||
"jar_id": "jarId"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/invoice",
|
||||
"export": "create-tip-invoice",
|
||||
"auth": "public"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"id": "wallet.pay_invoice",
|
||||
"description": "Pay Lightning invoices from selected wallets."
|
||||
},
|
||||
{
|
||||
"id": "wallet.list",
|
||||
"description": "List wallets available to the installing user."
|
||||
},
|
||||
{
|
||||
"id": "wallet.balance.read",
|
||||
"description": "Read wallet balances for payment selection."
|
||||
},
|
||||
{
|
||||
"id": "extension.api.request",
|
||||
"description": "Call APIs exposed by another installed extension.",
|
||||
"policies": [
|
||||
{
|
||||
"id": "watchonly",
|
||||
"access": ["read", "write"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ui.camera.scan_qr",
|
||||
"description": "Use the LNbits scanner to read QR codes when requested."
|
||||
},
|
||||
{
|
||||
"id": "ext.storage.read",
|
||||
"description": "Read extension storage rows."
|
||||
},
|
||||
{
|
||||
"id": "ext.storage.write",
|
||||
"description": "Write extension storage rows."
|
||||
},
|
||||
{
|
||||
"id": "ext.storage.read_public",
|
||||
"description": "Read public extension storage fields.",
|
||||
"policies": [
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"currency",
|
||||
"suggested_amounts"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "wallet.create_invoice",
|
||||
"description": "Create incoming Lightning invoices from authenticated pages."
|
||||
},
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"description": "Create incoming Lightning invoices from public pages.",
|
||||
"policies": [
|
||||
{
|
||||
"table": "tip_jars",
|
||||
"wallet_field": "wallet_id"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "utils.basic",
|
||||
"description": "Use LNbits utility functions."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
window.__lnbitsWasmTestExtensionLoaded = true
|
||||
;(function () {
|
||||
const channel = new MessageChannel()
|
||||
const pending = new Map()
|
||||
let counter = 0
|
||||
|
||||
const ready = new Promise(resolve => {
|
||||
channel.port1.addEventListener('message', event => {
|
||||
const message = event.data || {}
|
||||
|
||||
if (message.type === 'lnbits-extension:connected') {
|
||||
resolve(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type !== 'lnbits-extension:response') return
|
||||
const callback = pending.get(message.id)
|
||||
if (!callback) return
|
||||
|
||||
pending.delete(message.id)
|
||||
callback(message)
|
||||
})
|
||||
channel.port1.start()
|
||||
window.parent.postMessage(
|
||||
{type: 'lnbits-extension:connect', id: 'wasm-test-extension'},
|
||||
'*',
|
||||
[channel.port2]
|
||||
)
|
||||
})
|
||||
|
||||
window.lnbitsWasmTestBridge = {
|
||||
ready() {
|
||||
return ready
|
||||
},
|
||||
request(message) {
|
||||
return ready.then(() => {
|
||||
return new Promise(resolve => {
|
||||
const id = `wasm-test-${++counter}`
|
||||
pending.set(id, resolve)
|
||||
channel.port1.postMessage({
|
||||
type: 'lnbits-extension:request',
|
||||
id,
|
||||
...message
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1 @@
|
||||
{"unsafe": true}
|
||||
@@ -0,0 +1,4 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>This must not be served as JavaScript.</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>WASM Test Extension Admin</title>
|
||||
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="wasm-test-admin">WASM Test Admin</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>WASM Test Extension Public</title>
|
||||
<script src="/ext-assets/lnbits-wasm-test-extension/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="wasm-test-public">WASM Test Public</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -13,13 +13,10 @@ from lnbits.core.services import (
|
||||
fee_reserve_total,
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
pay_invoice,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.services.payments import pay_invoice, update_wallet_balance
|
||||
from lnbits.core.services.users import create_user_account
|
||||
from lnbits.exceptions import PaymentError
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.tasks import create_task, wait_for_paid_invoices
|
||||
from lnbits.wallets import get_funding_source
|
||||
|
||||
from ..helpers import is_fake, is_regtest
|
||||
@@ -163,11 +160,12 @@ async def test_create_real_invoice(
|
||||
assert not payment_status["paid"]
|
||||
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
|
||||
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
|
||||
|
||||
pay_real_invoice(invoice["bolt11"])
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert on_paid_mock.call_count == 1
|
||||
payment = on_paid_mock.call_args_list[0][0][0]
|
||||
|
||||
@@ -395,11 +393,12 @@ async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
assert not payment_status["paid"]
|
||||
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
task_manager.register_invoice_listener(on_paid_mock, "test_create_invoice")
|
||||
create_task(wait_for_paid_invoices("test_create_invoice", on_paid_mock)())
|
||||
|
||||
pay_real_invoice(invoice["bolt11"])
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert on_paid_mock.call_count == 1
|
||||
payment = on_paid_mock.call_args_list[0][0][0]
|
||||
|
||||
@@ -413,8 +412,6 @@ async def test_receive_real_invoice_set_pending_and_check_state(
|
||||
payment_status = response.json()
|
||||
assert payment_status["paid"]
|
||||
|
||||
assert payment
|
||||
|
||||
# set the incoming invoice to pending
|
||||
payment.status = PaymentState.PENDING
|
||||
await update_payment(payment)
|
||||
|
||||
+14
-24
@@ -5,7 +5,6 @@ import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.cache import Cache, Cached
|
||||
|
||||
key = "foo"
|
||||
@@ -14,10 +13,11 @@ value = "bar"
|
||||
|
||||
@pytest.fixture
|
||||
async def cache():
|
||||
cache = Cache()
|
||||
task = task_manager.create_permanent_task(cache.invalidate_cache, interval=1)
|
||||
cache = Cache(interval=0.1)
|
||||
|
||||
task = asyncio.create_task(cache.invalidate_forever())
|
||||
yield cache
|
||||
task_manager.cancel_task(task)
|
||||
task.cancel()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -31,13 +31,13 @@ async def test_cache_get_set(cache):
|
||||
@pytest.mark.anyio
|
||||
async def test_cache_expiry(cache):
|
||||
# gets expired by `get` call
|
||||
cache.set(key, value, expiry=1)
|
||||
await asyncio.sleep(2)
|
||||
cache.set(key, value, expiry=0.01)
|
||||
await asyncio.sleep(0.02)
|
||||
assert not cache.get(key)
|
||||
|
||||
# gets expired by invalidation task
|
||||
cache.set(key, value, expiry=1)
|
||||
await asyncio.sleep(2)
|
||||
cache.set(key, value, expiry=0.1)
|
||||
await asyncio.sleep(0.2)
|
||||
assert key not in cache._values
|
||||
assert not cache.get(key)
|
||||
|
||||
@@ -94,33 +94,23 @@ async def test_cache_pop_expired_returns_default(cache):
|
||||
async def test_invalidate_forever_logs_and_recovers_from_errors(
|
||||
settings: Settings, mocker: MockerFixture
|
||||
):
|
||||
test_cache = Cache()
|
||||
test_cache = Cache(interval=0)
|
||||
logger_error = mocker.patch("lnbits.utils.cache.logger.error")
|
||||
original_running = settings.lnbits_running
|
||||
calls = 0
|
||||
|
||||
original_invalidate = test_cache.invalidate_cache
|
||||
|
||||
async def fake_invalidate():
|
||||
async def fake_sleep(_interval):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
raise RuntimeError("boom")
|
||||
settings.lnbits_running = False
|
||||
await original_invalidate()
|
||||
|
||||
mocker.patch.object(test_cache, "invalidate_cache", side_effect=fake_invalidate)
|
||||
mocker.patch("lnbits.task_manager.asyncio.sleep")
|
||||
logger_error = mocker.patch("lnbits.task_manager.logger.error")
|
||||
|
||||
bg_task = None
|
||||
try:
|
||||
settings.lnbits_running = True
|
||||
bg_task = task_manager.create_permanent_task(test_cache.invalidate_cache)
|
||||
await bg_task.task
|
||||
mocker.patch("lnbits.utils.cache.asyncio.sleep", side_effect=fake_sleep)
|
||||
await test_cache.invalidate_forever()
|
||||
finally:
|
||||
settings.lnbits_running = original_running
|
||||
if bg_task:
|
||||
task_manager.cancel_task(bg_task)
|
||||
|
||||
assert logger_error.called
|
||||
assert calls == 2
|
||||
logger_error.assert_called_once_with("Error invalidating cache")
|
||||
|
||||
@@ -275,10 +275,6 @@ async def test_btc_rates_skips_unsupported_and_failing_providers(
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_btc_price_handles_empty_single_and_multiple_rates(mocker: MockerFixture):
|
||||
mocker.patch(
|
||||
"lnbits.utils.exchange_rates.btc_price_from_aggregator",
|
||||
AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch("lnbits.utils.exchange_rates.btc_rates", AsyncMock(return_value=[]))
|
||||
assert await btc_price("usd") == 0.0
|
||||
|
||||
|
||||
@@ -1433,7 +1433,7 @@ def test_check_revolut_signature_multiple_v1_headers():
|
||||
check_revolut_signature(payload, sig_header, timestamp, secret)
|
||||
|
||||
|
||||
def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
|
||||
def test_check_revolut_signature_docs_vector():
|
||||
payload = (
|
||||
b'{"data":{"id":"645a7696-22f3-aa47-9c74-cbae0449cc46",'
|
||||
b'"new_state":"completed","old_state":"pending",'
|
||||
@@ -1445,16 +1445,9 @@ def test_check_revolut_signature_docs_vector(mocker: MockerFixture):
|
||||
secret = "wsk_r59a4HfWVAKycbCaNO1RvgCJec02gRd8"
|
||||
sig = "v1=bca326fb378d0da7f7c490ad584a8106bab9723d8d9cdd0d50b4c5b3be3837c0"
|
||||
|
||||
# This is a fixed vector straight from Revolut's docs, so its timestamp is
|
||||
# necessarily in the past. Freeze time to it instead of growing
|
||||
# tolerance_seconds indefinitely as real time marches on.
|
||||
mocker.patch(
|
||||
"lnbits.core.services.fiat_providers.time.time",
|
||||
return_value=int(timestamp) / 1000,
|
||||
check_revolut_signature(
|
||||
payload, sig, timestamp, secret, tolerance_seconds=100000000
|
||||
)
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
check_revolut_signature(payload, sig, timestamp, secret)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1724,9 +1717,7 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
|
||||
"lnbits.core.services.fiat_providers.get_fiat_provider",
|
||||
AsyncMock(return_value=provider),
|
||||
)
|
||||
queue_put = mocker.patch(
|
||||
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
|
||||
)
|
||||
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
|
||||
|
||||
success_status = await check_fiat_status(
|
||||
Payment(
|
||||
@@ -1743,8 +1734,7 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
assert success_status.success is True
|
||||
queue_put.assert_called_once()
|
||||
assert queue_put.call_args[0][0].checking_id == "fiat_pending"
|
||||
queue_put.assert_awaited_once_with("fiat_pending")
|
||||
|
||||
await check_fiat_status(
|
||||
Payment(
|
||||
@@ -1759,7 +1749,7 @@ async def test_check_fiat_status_handles_internal_states(mocker: MockerFixture):
|
||||
extra={"fiat_checking_id": "stripe_checking_id"},
|
||||
)
|
||||
)
|
||||
assert queue_put.call_count == 1
|
||||
assert queue_put.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1796,9 +1786,7 @@ async def test_check_fiat_status_persists_successful_payment(
|
||||
"lnbits.fiat.StripeWallet.get_invoice_status",
|
||||
AsyncMock(return_value=FiatPaymentStatus(paid=True)),
|
||||
)
|
||||
queue_put = mocker.patch(
|
||||
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait"
|
||||
)
|
||||
queue_put = mocker.patch("lnbits.tasks.internal_invoice_queue.put", AsyncMock())
|
||||
|
||||
status = await check_fiat_status(payment)
|
||||
|
||||
@@ -1806,7 +1794,7 @@ async def test_check_fiat_status_persists_successful_payment(
|
||||
assert payment.status == PaymentState.SUCCESS
|
||||
updated_payment = await get_payment(payment.checking_id)
|
||||
assert updated_payment.status == PaymentState.SUCCESS
|
||||
queue_put.assert_called_once_with(payment)
|
||||
queue_put.assert_awaited_once_with(payment.checking_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -12,12 +12,15 @@ from lnbits.core.crud import create_wallet, get_standalone_payment, get_wallet
|
||||
from lnbits.core.crud.payments import get_payment, get_payments_paginated
|
||||
from lnbits.core.models import PaymentState, Wallet
|
||||
from lnbits.core.services import create_invoice, create_user_account, pay_invoice
|
||||
from lnbits.core.services.payments import (
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.services.payments import update_wallet_balance
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from lnbits.settings import Settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.tasks import (
|
||||
create_task,
|
||||
internal_invoice_listener,
|
||||
internal_invoice_queue,
|
||||
wait_for_paid_invoices,
|
||||
)
|
||||
from lnbits.wallets.base import PaymentResponse
|
||||
from lnbits.wallets.fake import FakeWallet
|
||||
|
||||
@@ -234,30 +237,24 @@ async def test_notification_for_internal_payment(
|
||||
test_name = "test_notification_for_internal_payment"
|
||||
|
||||
# Drain stale items left by session-scoped fixtures (e.g. update_wallet_balance)
|
||||
while not task_manager.internal_invoice_queue.empty():
|
||||
while not internal_invoice_queue.empty():
|
||||
try:
|
||||
task_manager.internal_invoice_queue.get_nowait()
|
||||
internal_invoice_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
|
||||
on_paid_mock = mocker.AsyncMock()
|
||||
# create_task(internal_invoice_listener())
|
||||
|
||||
task_manager.register_invoice_listener(on_paid_mock, test_name)
|
||||
|
||||
create_task(internal_invoice_listener())
|
||||
create_task(wait_for_paid_invoices(test_name, on_paid_mock)())
|
||||
payment = await create_invoice(
|
||||
wallet_id=to_wallet.id,
|
||||
amount=123,
|
||||
memo=test_name,
|
||||
webhook="http://test.404.lnbits.com",
|
||||
)
|
||||
paid_payment = await pay_invoice(
|
||||
await pay_invoice(
|
||||
wallet_id=to_wallet.id, payment_request=payment.bolt11, extra={"tag": "lnurlp"}
|
||||
)
|
||||
assert paid_payment.status == PaymentState.SUCCESS.value
|
||||
assert paid_payment.bolt11 == payment.bolt11
|
||||
assert paid_payment.amount == -123_000
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
assert on_paid_mock.call_count == 1
|
||||
@@ -267,8 +264,6 @@ async def test_notification_for_internal_payment(
|
||||
assert _payment.status == PaymentState.SUCCESS.value
|
||||
assert _payment.bolt11 == payment.bolt11
|
||||
assert _payment.amount == 123_000
|
||||
assert _payment.checking_id == payment.checking_id
|
||||
|
||||
updated_payment = await get_payment(_payment.checking_id)
|
||||
assert (
|
||||
updated_payment.webhook_status is not None
|
||||
|
||||
@@ -14,21 +14,14 @@ from lnbits.core.models.extensions import (
|
||||
InstallableExtension,
|
||||
ReleasePaymentInfo,
|
||||
)
|
||||
from lnbits.core.services import extensions as extension_services
|
||||
from lnbits.core.services.extensions import (
|
||||
activate_extension,
|
||||
attach_wasm_invocation_runtime,
|
||||
deactivate_extension,
|
||||
finish_wasm_invocation,
|
||||
get_current_wasm_invocations,
|
||||
get_valid_extension,
|
||||
get_valid_extensions,
|
||||
install_extension,
|
||||
record_wasm_invocation_host_call,
|
||||
start_extension_background_work,
|
||||
start_wasm_invocation,
|
||||
stop_extension_background_work,
|
||||
stop_wasm_invocation,
|
||||
uninstall_extension,
|
||||
)
|
||||
from lnbits.settings import Settings
|
||||
@@ -226,269 +219,6 @@ async def test_stop_extension_background_work_handles_missing_and_async_stops(
|
||||
assert called["stop"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_tracking_counts_and_stops(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
trigger_type="http",
|
||||
method="POST",
|
||||
path="/api/v1/ext/demoext/run",
|
||||
context={"origin": "https://example.com"},
|
||||
)
|
||||
store = SimpleNamespace(deadline=None)
|
||||
store.set_epoch_deadline = lambda deadline: setattr(store, "deadline", deadline)
|
||||
engine = SimpleNamespace(increments=0)
|
||||
|
||||
def increment_epoch():
|
||||
engine.increments += 1
|
||||
|
||||
engine.increment_epoch = increment_epoch
|
||||
|
||||
attach_wasm_invocation_runtime(invocation.id, engine=engine, store=store)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
assert await stop_wasm_invocation(invocation.id, reason="test stop") is True
|
||||
current = get_current_wasm_invocations()
|
||||
assert current[0].status == "stopping"
|
||||
assert store.deadline == 1
|
||||
assert engine.increments == 1
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
assert update_mock.await_args is not None
|
||||
saved = update_mock.await_args.args[0]
|
||||
assert saved.status == "stopped"
|
||||
assert saved.stop_reason == "test stop"
|
||||
assert saved.host_call_count == 2
|
||||
assert saved.http_call_count == 1
|
||||
assert saved.storage_call_count == 1
|
||||
|
||||
|
||||
def _reset_wasm_invocation_state():
|
||||
with extension_services._wasm_invocation_lock:
|
||||
extension_services._wasm_invocation_handles.clear()
|
||||
extension_services._wasm_invocations_marked_stale = False
|
||||
extension_services._wasm_invocations_last_cleanup_at = None
|
||||
|
||||
|
||||
def test_wasm_runtime_limits_merge_sparse_extension_overrides(settings: Settings):
|
||||
original_execution_ms = settings.wasm_runtime_max_execution_ms
|
||||
original_memory_bytes = settings.wasm_runtime_max_memory_bytes
|
||||
try:
|
||||
settings.wasm_runtime_max_execution_ms = 5_000
|
||||
settings.wasm_runtime_max_memory_bytes = 64 * 1024 * 1024
|
||||
extension = InstallableExtension(
|
||||
id="wasm_demo",
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
wasm_runtime_limits={
|
||||
"wasm_runtime_max_execution_ms": 20_000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
},
|
||||
)
|
||||
|
||||
limits = extension_services.resolve_wasm_runtime_limits(extension)
|
||||
|
||||
assert limits["wasm_runtime_max_execution_ms"] == 20_000
|
||||
assert limits["wasm_runtime_max_fuel"] == 0
|
||||
assert limits["wasm_runtime_max_memory_bytes"] == 64 * 1024 * 1024
|
||||
finally:
|
||||
settings.wasm_runtime_max_execution_ms = original_execution_ms
|
||||
settings.wasm_runtime_max_memory_bytes = original_memory_bytes
|
||||
|
||||
|
||||
def test_wasm_runtime_limit_override_validation():
|
||||
assert extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "7000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
}
|
||||
) == {
|
||||
"wasm_runtime_max_execution_ms": 7000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown WASM runtime limit field"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides({"unknown": 1})
|
||||
|
||||
with pytest.raises(ValueError, match="cannot be negative"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": -1}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": True}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="must be an integer"):
|
||||
extension_services.validate_wasm_runtime_limit_overrides(
|
||||
{"wasm_runtime_max_execution_ms": 1.5}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_wasm_extension_runtime_limits_saves_sparse_overrides(
|
||||
tmp_path,
|
||||
settings: Settings,
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
ext_id = "wasm_demo"
|
||||
original_extensions_path = settings.lnbits_extensions_path
|
||||
try:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
config_dir = tmp_path / "extensions" / ext_id
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "config.json").write_text(
|
||||
'{"extension_type": "wasm"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
installed_extension = InstallableExtension(
|
||||
id=ext_id,
|
||||
name="WASM Demo",
|
||||
version="1.0.0",
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
update_mock = mocker.patch(
|
||||
"lnbits.core.services.extensions."
|
||||
"update_installed_extension_wasm_runtime_limits",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
saved_limits = await extension_services.update_wasm_extension_runtime_limits(
|
||||
ext_id,
|
||||
{
|
||||
"wasm_runtime_max_execution_ms": "15000",
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
"wasm_runtime_max_memory_bytes": "",
|
||||
},
|
||||
)
|
||||
finally:
|
||||
settings.lnbits_extensions_path = original_extensions_path
|
||||
|
||||
assert saved_limits == {
|
||||
"wasm_runtime_max_execution_ms": 15000,
|
||||
"wasm_runtime_max_fuel": 0,
|
||||
}
|
||||
update_mock.assert_awaited_once_with(ext_id=ext_id, limits=saved_limits)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_concurrency_limits(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits.update(
|
||||
{
|
||||
"wasm_runtime_max_concurrent_invocations": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_extension": 1,
|
||||
"wasm_runtime_max_concurrent_invocations_per_user": 1,
|
||||
}
|
||||
)
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many active invocations"):
|
||||
await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
user_id="user-id",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="completed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_host_call_limits(mocker: MockerFixture):
|
||||
_reset_wasm_invocation_state()
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.create_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.update_wasm_invocation",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.get_wasm_invocation",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.mark_stale_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.services.extensions.delete_old_wasm_invocations",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
limits = extension_services.wasm_runtime_limit_defaults()
|
||||
limits["wasm_runtime_max_host_calls"] = 1
|
||||
|
||||
invocation = await start_wasm_invocation(
|
||||
extension_id="demoext",
|
||||
export_name="render",
|
||||
runtime_limits=limits,
|
||||
)
|
||||
record_wasm_invocation_host_call(invocation.id, "http.request")
|
||||
|
||||
with pytest.raises(ValueError, match="host call limit"):
|
||||
record_wasm_invocation_host_call(invocation.id, "storage.get")
|
||||
|
||||
await finish_wasm_invocation(invocation.id, status="failed")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_start_extension_background_work_handles_missing_and_sync_starts(
|
||||
mocker: MockerFixture,
|
||||
|
||||
@@ -197,7 +197,8 @@ async def test_update_wallet_balance_validates_credit_and_debit(
|
||||
|
||||
settings.lnbits_wallet_limit_max_balance = 0
|
||||
queue_mock = mocker.patch(
|
||||
"lnbits.task_manager.task_manager.internal_invoice_queue.put_nowait",
|
||||
"lnbits.tasks.internal_invoice_queue_put",
|
||||
mocker.AsyncMock(),
|
||||
)
|
||||
|
||||
await update_wallet_balance(wallet, 5)
|
||||
@@ -211,8 +212,7 @@ async def test_update_wallet_balance_validates_credit_and_debit(
|
||||
]
|
||||
assert credit_payments
|
||||
assert credit_payments[0].status == PaymentState.SUCCESS
|
||||
queue_mock.assert_called_once()
|
||||
assert queue_mock.call_args[0][0].checking_id == credit_payments[0].checking_id
|
||||
queue_mock.assert_awaited_once_with(credit_payments[0].checking_id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.wasm_ext.api.host import ExtensionHostAPI
|
||||
from lnbits.core.wasm_ext.api.models import PayInvoiceRequest
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_wallet_pay_invoice_resolves_ln_address(mocker):
|
||||
calls = {}
|
||||
|
||||
async def get_wallet(wallet_id: str):
|
||||
calls["get_wallet"] = wallet_id
|
||||
return SimpleNamespace(user="user1")
|
||||
|
||||
async def get_pr_from_lnurl(lnurl: str, amount_msat: int, comment: str | None):
|
||||
calls["lnurl"] = (lnurl, amount_msat, comment)
|
||||
return "lnbc1resolved"
|
||||
|
||||
async def pay_invoice(**kwargs):
|
||||
calls["pay_invoice"] = kwargs
|
||||
return SimpleNamespace(
|
||||
checking_id="checking",
|
||||
payment_hash="hash",
|
||||
status="success",
|
||||
amount=-21_000,
|
||||
fee=-10,
|
||||
pending=False,
|
||||
success=True,
|
||||
)
|
||||
|
||||
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
|
||||
mocker.patch("lnbits.core.services.lnurl.get_pr_from_lnurl", get_pr_from_lnurl)
|
||||
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
|
||||
|
||||
api = ExtensionHostAPI("demoext", ["wallet.pay_invoice"], user_id="user1")
|
||||
response = await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet1",
|
||||
payment_request="alice@example.com",
|
||||
max_sat=21,
|
||||
description="winner",
|
||||
)
|
||||
)
|
||||
|
||||
assert response.ok is True
|
||||
assert response.checking_id == "checking"
|
||||
assert calls["get_wallet"] == "wallet1"
|
||||
assert calls["lnurl"] == ("alice@example.com", 21_000, "winner")
|
||||
assert calls["pay_invoice"]["payment_request"] == "lnbc1resolved"
|
||||
assert calls["pay_invoice"]["max_sat"] == 21
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_wallet_pay_invoice_allows_event_wallet_only(mocker):
|
||||
async def get_wallet(wallet_id: str):
|
||||
return SimpleNamespace(user="other-user")
|
||||
|
||||
async def pay_invoice(**kwargs):
|
||||
return SimpleNamespace(
|
||||
checking_id="checking",
|
||||
payment_hash="hash",
|
||||
status="success",
|
||||
amount=-1_000,
|
||||
fee=0,
|
||||
pending=False,
|
||||
success=True,
|
||||
)
|
||||
|
||||
mocker.patch("lnbits.core.crud.wallets.get_wallet", get_wallet)
|
||||
mocker.patch("lnbits.core.services.payments.pay_invoice", pay_invoice)
|
||||
|
||||
api = ExtensionHostAPI(
|
||||
"demoext",
|
||||
["wallet.pay_invoice"],
|
||||
context="event",
|
||||
owner_id="owner",
|
||||
wallet_id="wallet1",
|
||||
)
|
||||
allowed = await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet1",
|
||||
payment_request="lnbc1invoice",
|
||||
max_sat=None,
|
||||
description="",
|
||||
)
|
||||
)
|
||||
|
||||
assert allowed.ok is True
|
||||
|
||||
with pytest.raises(PermissionError, match="authenticated user context"):
|
||||
await api.wallet_pay_invoice(
|
||||
PayInvoiceRequest(
|
||||
wallet_id="wallet2",
|
||||
payment_request="lnbc1invoice",
|
||||
max_sat=None,
|
||||
description="",
|
||||
)
|
||||
)
|
||||
@@ -1,223 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.models.misc import WasmExtensionRegistry
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.config import parse_wasm_extension_config
|
||||
from lnbits.core.wasm_ext.wasm.loader import WasmExtension, load_wasm_extension
|
||||
from lnbits.settings import Settings
|
||||
from tests.helpers import make_installable_extension
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_missing_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=None)
|
||||
|
||||
with pytest.raises(ValueError, match="config must define id"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_rejects_mismatched_config_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id="otherext")
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
load_wasm_extension(ext_id)
|
||||
|
||||
|
||||
def test_load_wasm_extension_uses_canonical_extension_id(
|
||||
tmp_path: Path, settings: Settings
|
||||
):
|
||||
ext_id = "demoext"
|
||||
_write_wasm_extension(settings, tmp_path, ext_id, config_id=ext_id)
|
||||
|
||||
extension = load_wasm_extension(ext_id)
|
||||
|
||||
assert extension.id == ext_id
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["unexpected"] = True
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert not hasattr(parsed, "unexpected")
|
||||
|
||||
|
||||
def test_wasm_extension_config_rejects_coerced_scalar_types():
|
||||
config = _wasm_config("demoext")
|
||||
config["wasm"] = {"module": 123}
|
||||
|
||||
with pytest.raises(ValueError, match="str type expected"):
|
||||
parse_wasm_extension_config("demoext", config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config_update",
|
||||
[
|
||||
{"wasm": {"module": "extension.wasm", "host_api": "custom.HostAPI"}},
|
||||
{
|
||||
"wasm": {
|
||||
"module": "extension.wasm",
|
||||
"resource_limits": {"max_response_bytes": 1024},
|
||||
}
|
||||
},
|
||||
{"build": {"source": "dev", "command": "npm run build"}},
|
||||
],
|
||||
)
|
||||
def test_wasm_extension_config_ignores_removed_extension_control_fields(
|
||||
config_update: dict[str, Any],
|
||||
):
|
||||
config = _wasm_config("demoext")
|
||||
config.update(config_update)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.wasm.module == "extension.wasm"
|
||||
assert not hasattr(parsed.wasm, "host_api")
|
||||
assert not hasattr(parsed.wasm, "resource_limits")
|
||||
assert not hasattr(parsed, "build")
|
||||
|
||||
|
||||
def test_wasm_extension_config_accepts_supported_optional_sections():
|
||||
config = _wasm_config("demoext")
|
||||
config.update(
|
||||
{
|
||||
"tile": "static/icon.png",
|
||||
"min_lnbits_version": "1.0.0",
|
||||
"max_lnbits_version": "2.0.0",
|
||||
"wasm": {
|
||||
"module": "wasm/module.wasm",
|
||||
"wit": "wasm/lnbits-extension.wit",
|
||||
"world": "lnbits-extension",
|
||||
"exports": [
|
||||
{"name": "render", "visibility": "public"},
|
||||
{"name": "on_invoice_paid", "visibility": "event"},
|
||||
],
|
||||
},
|
||||
"events": {"onInvoicePaid": "on_invoice_paid"},
|
||||
"ui": {"entrypoint": "static/index.html", "sandbox": True},
|
||||
"sdk": {"frontend_js": "static/lnbits-extension-sdk.js"},
|
||||
"ui_routes": [
|
||||
{
|
||||
"path": "/demo/{item_id}",
|
||||
"entrypoint": "static/index.html",
|
||||
"auth": "user",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"api_routes": [
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/demo/{item_id}",
|
||||
"export": "render",
|
||||
"auth": "public",
|
||||
"path_params": {"item_id": "str"},
|
||||
}
|
||||
],
|
||||
"permissions": [{"id": "utils.basic", "description": "Basic utils"}],
|
||||
}
|
||||
)
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.events.on_invoice_paid == "on_invoice_paid"
|
||||
assert parsed.wasm.world == "lnbits-extension"
|
||||
|
||||
|
||||
def test_wasm_extension_config_ignores_unknown_permission_fields():
|
||||
config = _wasm_config("demoext")
|
||||
config["permissions"] = [
|
||||
{"id": "utils.basic", "label": "Basic utilities", "unknown": True}
|
||||
]
|
||||
|
||||
parsed = parse_wasm_extension_config("demoext", config)
|
||||
|
||||
assert parsed.permissions == [ExtensionPermission(id="utils.basic")]
|
||||
|
||||
|
||||
def test_install_time_permission_validation_rejects_config_id_mismatch():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = {
|
||||
"id": "otherext",
|
||||
"extension_type": "wasm",
|
||||
"permissions": [{"id": "utils.basic"}],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="id mismatch"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[ExtensionPermission(id="utils.basic")],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_wasm_extension_registry_rejects_same_id_from_different_root(tmp_path: Path):
|
||||
registry = WasmExtensionRegistry()
|
||||
first = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_same_root = _wasm_extension("demoext", tmp_path / "one")
|
||||
second_different_root = _wasm_extension("demoext", tmp_path / "two")
|
||||
|
||||
registry.register(first)
|
||||
registry.register(second_same_root)
|
||||
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
registry.register(second_different_root)
|
||||
|
||||
|
||||
def _write_wasm_extension(
|
||||
settings: Settings,
|
||||
tmp_path: Path,
|
||||
ext_id: str,
|
||||
*,
|
||||
config_id: str | None,
|
||||
) -> None:
|
||||
settings.lnbits_extensions_path = str(tmp_path)
|
||||
ext_dir = tmp_path / "extensions" / ext_id
|
||||
ext_dir.mkdir(parents=True)
|
||||
(ext_dir / "extension.wasm").write_bytes(b"\0asm")
|
||||
config = {
|
||||
"name": "Demo",
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
if config_id is not None:
|
||||
config["id"] = config_id
|
||||
(ext_dir / "config.json").write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
|
||||
def _wasm_extension(ext_id: str, root_path: Path) -> WasmExtension:
|
||||
config = parse_wasm_extension_config(ext_id, _wasm_config(ext_id))
|
||||
return WasmExtension(
|
||||
id=ext_id,
|
||||
name=ext_id,
|
||||
version="1.0.0",
|
||||
root_path=root_path,
|
||||
module_path=root_path / "extension.wasm",
|
||||
wit_path=None,
|
||||
world="",
|
||||
exports=[],
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
}
|
||||
@@ -1,210 +1,70 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pytest_mock.plugin import MockerFixture
|
||||
|
||||
from lnbits.core.models.extensions import ExtensionPermission
|
||||
from lnbits.core.wasm_ext.api.permissions import validate_wasm_extension_permissions
|
||||
from lnbits.core.wasm_ext.wasm.events import _wasm_invoice_paid_owner_id
|
||||
from lnbits.core.wasm_ext.wasm.invoke import _active_installed_extension
|
||||
from tests.helpers import make_installable_extension
|
||||
from lnbits.core.models.extensions import ExtensionPermission, InstallableExtension
|
||||
from lnbits.core.wasm_ext.api.permissions import (
|
||||
validate_extension_permissions,
|
||||
validate_wasm_extension_permissions,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "http.request",
|
||||
"policies": [{"host": "https://api.example.com"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="http.request",
|
||||
policies=[
|
||||
{"host": "https://api.example.com"},
|
||||
{"host": "https://evil.example.com"},
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_stores_narrower_policy_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "ext.storage.read_public",
|
||||
"description": "Read public storage.",
|
||||
"policies": [
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title", "description"],
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
assert permissions == [
|
||||
ExtensionPermission(
|
||||
id="ext.storage.read_public",
|
||||
description="Read public storage.",
|
||||
policies=[
|
||||
{
|
||||
"table_name": "tip_jars",
|
||||
"public_fields": ["id", "title"],
|
||||
}
|
||||
],
|
||||
)
|
||||
def test_validate_extension_permissions_rejects_unknown_permission() -> None:
|
||||
permissions = [
|
||||
ExtensionPermission(id="wallet.list"),
|
||||
ExtensionPermission(id="unknown.permission"),
|
||||
]
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_broader_extension_api_access():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "extension.api.request",
|
||||
"policies": [{"id": "targetext", "access": ["read"]}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="broader policies"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(
|
||||
id="extension.api.request",
|
||||
policies=[{"id": "targetext", "access": ["read", "write"]}],
|
||||
)
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
with pytest.raises(ValueError, match="unknown.permission"):
|
||||
validate_extension_permissions("demo", permissions)
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_allows_empty_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [{"table": "tip_jars", "wallet_field": "wallet_id"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert validate_wasm_extension_permissions(ext_info, [], extension_config) == []
|
||||
|
||||
|
||||
def test_validate_wasm_permissions_rejects_unrequested_permission_grant():
|
||||
ext_info = make_installable_extension("demoext")
|
||||
extension_config = _wasm_config("demoext", [{"id": "utils.basic"}])
|
||||
|
||||
with pytest.raises(ValueError, match="unrequested permissions"):
|
||||
validate_wasm_extension_permissions(
|
||||
ext_info,
|
||||
[
|
||||
ExtensionPermission(id="utils.basic"),
|
||||
ExtensionPermission(id="wallet.list"),
|
||||
],
|
||||
extension_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invoice_paid_owner_lookup_uses_stored_granted_policies(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(
|
||||
id="demoext",
|
||||
config=_wasm_config(
|
||||
"demoext",
|
||||
[
|
||||
{
|
||||
"id": "wallet.create_invoice_public",
|
||||
"policies": [
|
||||
{"table": "requested_table", "wallet_field": "wallet_id"}
|
||||
],
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
payment = SimpleNamespace(extra={"source_id": "source-1"})
|
||||
installed_extension = SimpleNamespace(
|
||||
permissions=[
|
||||
ExtensionPermission(
|
||||
id="wallet.create_invoice_public",
|
||||
policies=[{"table": "granted_table", "wallet_field": "wallet_id"}],
|
||||
)
|
||||
]
|
||||
)
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=installed_extension),
|
||||
)
|
||||
storage_mock = mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.events.storage_get_row_owner_id",
|
||||
mocker.AsyncMock(return_value="owner-1"),
|
||||
)
|
||||
|
||||
owner_id = await _wasm_invoice_paid_owner_id(extension, payment)
|
||||
|
||||
assert owner_id == "owner-1"
|
||||
storage_mock.assert_awaited_once_with("demoext", "granted_table", "source-1")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_invocation_requires_installed_active_extension(
|
||||
mocker: MockerFixture,
|
||||
):
|
||||
extension = SimpleNamespace(id="demoext")
|
||||
mocker.patch(
|
||||
"lnbits.core.wasm_ext.wasm.invoke.get_installed_extension",
|
||||
mocker.AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError, match="deactivated"):
|
||||
await _active_installed_extension(cast(Any, extension))
|
||||
|
||||
|
||||
def _wasm_config(ext_id: str, permissions: list[dict]) -> dict:
|
||||
return {
|
||||
"id": ext_id,
|
||||
"name": ext_id,
|
||||
"short_description": "Demo extension",
|
||||
"version": "1.0.0",
|
||||
def test_validate_wasm_extension_permissions_requires_grant() -> None:
|
||||
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
|
||||
config = {
|
||||
"extension_type": "wasm",
|
||||
"wasm": {"module": "extension.wasm"},
|
||||
"permissions": permissions,
|
||||
"permissions": [{"id": "wallet.list"}],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="requires permission approval"):
|
||||
validate_wasm_extension_permissions(extension, None, config)
|
||||
|
||||
|
||||
def test_validate_wasm_extension_permissions_requires_exact_grants() -> None:
|
||||
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
|
||||
config = {
|
||||
"extension_type": "wasm",
|
||||
"permissions": [{"id": "wallet.list"}, {"id": "utils.basic"}],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="was not granted all requested permissions"):
|
||||
validate_wasm_extension_permissions(
|
||||
extension,
|
||||
[ExtensionPermission(id="wallet.list")],
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_wasm_extension_permissions_returns_core_normalized_grants() -> None:
|
||||
extension = InstallableExtension(id="demo", name="Demo", version="0.0.1")
|
||||
config = {
|
||||
"extension_type": "wasm",
|
||||
"permissions": [
|
||||
{
|
||||
"id": "wallet.list",
|
||||
"label": "Extension supplied label",
|
||||
"description": "Show wallets.",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
permissions = validate_wasm_extension_permissions(
|
||||
extension,
|
||||
[ExtensionPermission(id="wallet.list", label="Extension supplied label")],
|
||||
config,
|
||||
)
|
||||
|
||||
assert len(permissions) == 1
|
||||
assert permissions[0].id == "wallet.list"
|
||||
assert permissions[0].label is None
|
||||
assert permissions[0].description == "Show wallets."
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
from lnbits.core.wasm_ext.routes.api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_read_api_payload,
|
||||
_read_json_object_with_size,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_content_length_without_reading():
|
||||
request = _FakeRequest([b"{}"], content_length="11")
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError, match="11 bytes"):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=10)
|
||||
|
||||
assert request.stream_started is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_json_reader_rejects_large_stream_without_content_length():
|
||||
request = _FakeRequest([b'{"value":"', b"x" * 20, b'"}'])
|
||||
|
||||
with pytest.raises(WasmRequestBodyTooLargeError):
|
||||
await _read_json_object_with_size(cast(Request, request), max_body_bytes=16)
|
||||
|
||||
assert request.stream_started is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wasm_api_payload_records_actual_body_bytes():
|
||||
body = b'{"amount":21}'
|
||||
request = _FakeRequest(
|
||||
[body],
|
||||
path_params={"invoice_id": "abc"},
|
||||
query_params={"include_paid": "true"},
|
||||
)
|
||||
|
||||
payload = await _read_api_payload(
|
||||
cast(Request, request),
|
||||
{"invoice_id": "invoiceId"},
|
||||
max_body_bytes=100,
|
||||
)
|
||||
|
||||
assert payload.data == {
|
||||
"invoiceId": "abc",
|
||||
"includePaid": "true",
|
||||
"amount": 21,
|
||||
}
|
||||
assert payload.request_bytes == len(body)
|
||||
|
||||
|
||||
class _FakeRequest:
|
||||
method = "POST"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: list[bytes],
|
||||
*,
|
||||
content_length: str | None = None,
|
||||
path_params: dict[str, str] | None = None,
|
||||
query_params: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
self._chunks = chunks
|
||||
self.headers: dict[str, str] = {}
|
||||
if content_length is not None:
|
||||
self.headers["content-length"] = content_length
|
||||
self.path_params = path_params or {}
|
||||
self.query_params = query_params or {}
|
||||
self.stream_started = False
|
||||
|
||||
async def stream(self) -> AsyncIterator[bytes]:
|
||||
self.stream_started = True
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -13,7 +12,7 @@ from Cryptodome.Util.Padding import pad, unpad
|
||||
from websockets import ServerConnection
|
||||
from websockets import serve as ws_serve
|
||||
|
||||
from lnbits.wallets.nwc import NWCConnection, NWCWallet
|
||||
from lnbits.wallets.nwc import NWCWallet
|
||||
from tests.wallets.helpers import (
|
||||
WalletTest,
|
||||
build_test_id,
|
||||
@@ -100,8 +99,6 @@ async def handle( # noqa: C901
|
||||
event,
|
||||
)
|
||||
await websocket.send(json.dumps(["EVENT", sub_id, event]))
|
||||
elif 23195 in kinds:
|
||||
assert sub_filter["authors"] == [mock_settings["service_public_key"]]
|
||||
elif msg[0] == "EVENT":
|
||||
event = msg[1]
|
||||
decrypted_content = decrypt_content(
|
||||
@@ -180,129 +177,6 @@ async def run(data: WalletTest):
|
||||
await nwcwallet.cleanup()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nwc_rejects_event_from_unexpected_pubkey(mocker):
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
|
||||
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
|
||||
|
||||
service_private_key = PrivateKey()
|
||||
service_public_key = service_private_key.public_key.format().hex()[2:]
|
||||
attacker_private_key = PrivateKey()
|
||||
attacker_public_key = attacker_private_key.public_key.format().hex()[2:]
|
||||
account_private_key = PrivateKey()
|
||||
|
||||
conn = NWCConnection(
|
||||
service_public_key,
|
||||
account_private_key.secret.hex(),
|
||||
"ws://127.0.0.1:8555",
|
||||
)
|
||||
try:
|
||||
event = {
|
||||
"kind": 23195,
|
||||
"content": "{}",
|
||||
"created_at": int(time.time()),
|
||||
"tags": [["e", "request-event-id"]],
|
||||
}
|
||||
sign_event(attacker_public_key, attacker_private_key.secret.hex(), event)
|
||||
|
||||
with pytest.raises(Exception, match="Invalid event signature"):
|
||||
await conn._on_event_message(["EVENT", "subid", event])
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nwc_marks_pending_invoice_settled_only_once():
|
||||
wallet = NWCWallet.__new__(NWCWallet)
|
||||
wallet.pending_invoice_details = {"checking-id": {"checking_id": "checking-id"}}
|
||||
wallet.pending_invoices = ["checking-id"]
|
||||
wallet.paid_invoices_queue = asyncio.Queue(0)
|
||||
|
||||
wallet._mark_invoice_settled("checking-id", source="notification")
|
||||
wallet._mark_invoice_settled("checking-id", source="notification")
|
||||
|
||||
assert wallet.paid_invoices_queue.qsize() == 1
|
||||
assert await wallet.paid_invoices_queue.get() == "checking-id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nwc_registers_notification_subscriptions(mocker):
|
||||
async def _noop(*args, **kwargs):
|
||||
return None
|
||||
|
||||
mocker.patch("lnbits.wallets.nwc.NWCConnection._connect_to_relay", new=_noop)
|
||||
mocker.patch("lnbits.wallets.nwc.NWCConnection._handle_timeouts", new=_noop)
|
||||
|
||||
service_private_key = PrivateKey()
|
||||
service_public_key = service_private_key.public_key.format().hex()[2:]
|
||||
account_private_key = PrivateKey()
|
||||
|
||||
conn = NWCConnection(
|
||||
service_public_key,
|
||||
account_private_key.secret.hex(),
|
||||
"ws://127.0.0.1:8555",
|
||||
)
|
||||
send_mock = mocker.patch.object(conn, "_send", mocker.AsyncMock())
|
||||
|
||||
try:
|
||||
await conn._subscribe_to_notifications()
|
||||
|
||||
assert len(conn.notification_subscription_ids) == 2
|
||||
assert len(conn.subscriptions) == 2
|
||||
assert set(conn.subscriptions.keys()) == conn.notification_subscription_ids
|
||||
assert all(
|
||||
subscription["method"] == "notification_sub"
|
||||
and subscription["event_id"] == subscription["sub_id"]
|
||||
for subscription in conn.subscriptions.values()
|
||||
)
|
||||
assert send_mock.await_count == 2
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nwc_spreads_fallback_lookups_with_cooldown(mocker):
|
||||
def _schedule_next_lookup(
|
||||
invoice: dict[str, object], now: float | None = None
|
||||
) -> None:
|
||||
assert now is not None
|
||||
invoice["next_lookup_at"] = now + 1
|
||||
|
||||
wallet = NWCWallet.__new__(NWCWallet)
|
||||
wallet.shutdown = False
|
||||
wallet.pending_invoices = ["checking-1", "checking-2"]
|
||||
wallet.pending_invoice_details = {
|
||||
"checking-1": {
|
||||
"checking_id": "checking-1",
|
||||
"next_lookup_at": 0.0,
|
||||
"lookup_attempts": 0,
|
||||
},
|
||||
"checking-2": {
|
||||
"checking_id": "checking-2",
|
||||
"next_lookup_at": 0.0,
|
||||
"lookup_attempts": 0,
|
||||
},
|
||||
}
|
||||
wallet.pending_invoices_lookup_cooldown = 1.0
|
||||
wallet._is_shutting_down = lambda: False
|
||||
wallet._payment_data_is_settled = lambda payment_data: False
|
||||
wallet._cache_payment_data = lambda *args, **kwargs: None
|
||||
wallet._schedule_next_lookup = _schedule_next_lookup
|
||||
wallet.conn = mocker.Mock()
|
||||
wallet.conn.get_info = mocker.AsyncMock()
|
||||
wallet.conn.supports_method = mocker.Mock(return_value=True)
|
||||
wallet.conn.call = mocker.AsyncMock(return_value={"settled_at": None})
|
||||
sleep_mock = mocker.patch("lnbits.wallets.nwc.asyncio.sleep", mocker.AsyncMock())
|
||||
|
||||
await wallet._run_fallback_lookups(100.0)
|
||||
|
||||
assert wallet.conn.call.await_count == 2
|
||||
sleep_mock.assert_awaited_once_with(1.0)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize(
|
||||
"test_data",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tests.wasm_ext.helpers import (
|
||||
EXTENSION_ID,
|
||||
REPO_ROOT,
|
||||
SERVER_HOST,
|
||||
LiveLNbitsServer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_name() -> str:
|
||||
return "chromium"
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def lnbits_server(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[LiveLNbitsServer]:
|
||||
run_root = tmp_path_factory.mktemp("wasm_ext_e2e")
|
||||
data_dir = run_root / "data"
|
||||
extensions_root = run_root / "extensions-root"
|
||||
extension_target = extensions_root / "extensions" / EXTENSION_ID
|
||||
fixture_extension = REPO_ROOT / "tests" / "fixtures" / EXTENSION_ID
|
||||
|
||||
shutil.copytree(fixture_extension, extension_target)
|
||||
|
||||
port = _free_tcp_port()
|
||||
base_url = f"http://{SERVER_HOST}:{port}"
|
||||
env = {
|
||||
**os.environ,
|
||||
"AUTH_HTTPS_ONLY": "false",
|
||||
"DEBUG": "true",
|
||||
"HOST": SERVER_HOST,
|
||||
"LNBITS_ADMIN_UI": "true",
|
||||
"LNBITS_BACKEND_WALLET_CLASS": "FakeWallet",
|
||||
"LNBITS_DATA_FOLDER": str(data_dir),
|
||||
"LNBITS_EXTENSIONS_DEACTIVATE_ALL": "false",
|
||||
"LNBITS_EXTENSIONS_PATH": str(extensions_root),
|
||||
"LNBITS_PATH": str(REPO_ROOT),
|
||||
"PORT": str(port),
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"lnbits.__main__:app",
|
||||
"--host",
|
||||
SERVER_HOST,
|
||||
"--port",
|
||||
str(port),
|
||||
"--loop",
|
||||
"asyncio",
|
||||
"--log-level",
|
||||
"warning",
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
try:
|
||||
_wait_for_first_install_page(process, base_url)
|
||||
auth_cookies = _complete_first_install(base_url)
|
||||
yield LiveLNbitsServer(base_url=base_url, auth_cookies=auth_cookies)
|
||||
finally:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_page(page: Any, lnbits_server: LiveLNbitsServer) -> Any:
|
||||
page.context.add_cookies(lnbits_server.auth_cookies)
|
||||
return page
|
||||
|
||||
|
||||
def _free_tcp_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind((SERVER_HOST, 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _wait_for_first_install_page(
|
||||
process: subprocess.Popen[str],
|
||||
base_url: str,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + 60
|
||||
last_error: Exception | None = None
|
||||
with httpx.Client(follow_redirects=False, timeout=2) as client:
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
output = process.stdout.read() if process.stdout else ""
|
||||
raise RuntimeError(f"LNbits exited before startup:\n{output}")
|
||||
try:
|
||||
response = client.get(f"{base_url}/first_install")
|
||||
if response.status_code == 200:
|
||||
return
|
||||
except httpx.HTTPError as exc:
|
||||
last_error = exc
|
||||
time.sleep(0.25)
|
||||
|
||||
output = process.stdout.read() if process.stdout else ""
|
||||
raise TimeoutError(f"LNbits did not start: {last_error}\n{output}")
|
||||
|
||||
|
||||
def _complete_first_install(base_url: str) -> list[dict[str, Any]]:
|
||||
with httpx.Client(base_url=base_url, follow_redirects=False, timeout=10) as client:
|
||||
response = client.put(
|
||||
"/api/v1/auth/first_install",
|
||||
json={
|
||||
"username": "wasmtest-admin",
|
||||
"password": "secret1234",
|
||||
"password_repeat": "secret1234",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
enable_response = client.put(f"/api/v1/extension/{EXTENSION_ID}/enable")
|
||||
enable_response.raise_for_status()
|
||||
|
||||
return [
|
||||
{
|
||||
"name": cookie.name,
|
||||
"value": cookie.value,
|
||||
"url": base_url,
|
||||
"secure": cookie.secure,
|
||||
"sameSite": "Lax",
|
||||
}
|
||||
for cookie in client.cookies.jar
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
EXTENSION_ID = "lnbits-wasm-test-extension"
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
SERVER_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveLNbitsServer:
|
||||
base_url: str
|
||||
auth_cookies: list[dict[str, Any]]
|
||||
extension_id: str = EXTENSION_ID
|
||||
@@ -0,0 +1,385 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from re import Pattern
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Frame, Page, expect
|
||||
|
||||
from tests.wasm_ext.helpers import EXTENSION_ID, REPO_ROOT, LiveLNbitsServer
|
||||
|
||||
|
||||
def test_permission_grant_dialog_logic_is_compact_and_explicit(page: Page) -> None:
|
||||
permissions = _fixture_permissions()
|
||||
page.goto("about:blank")
|
||||
page.add_script_tag(path=str(REPO_ROOT / "lnbits/static/js/pages/extensions.js"))
|
||||
|
||||
result = page.evaluate(
|
||||
"""
|
||||
({permissions, translations}) => {
|
||||
const methods = window.PageExtensions.methods
|
||||
const context = {
|
||||
extensions: [{id: 'watchonly', name: 'Watchonly'}],
|
||||
permissionGrant: {show: false, permissions: [], resolve: null},
|
||||
selectedExtension: {isWasm: true},
|
||||
selectedRelease: null,
|
||||
showManageExtensionDialog: false,
|
||||
$t: key => translations[key] || key
|
||||
}
|
||||
Object.assign(context, methods)
|
||||
|
||||
const release = {extension_type: 'wasm', permissions}
|
||||
const pendingGrant = context.resolveExtensionPermissionGrant(release)
|
||||
const opened =
|
||||
context.permissionGrant.show === true &&
|
||||
context.showManageExtensionDialog === true
|
||||
const items = context.permissionGrantDisplayItems()
|
||||
context.grantExtensionPermissions()
|
||||
|
||||
return pendingGrant.then(grantedPermissions => ({
|
||||
opened,
|
||||
closed:
|
||||
context.permissionGrant.show === false &&
|
||||
context.showManageExtensionDialog === false,
|
||||
grantedPermissionIds: grantedPermissions.map(permission => permission.id),
|
||||
items
|
||||
}))
|
||||
}
|
||||
""",
|
||||
{"permissions": permissions, "translations": _permission_translations()},
|
||||
)
|
||||
|
||||
assert result["opened"] is True
|
||||
assert result["closed"] is True
|
||||
assert result["grantedPermissionIds"] == [
|
||||
permission["id"] for permission in permissions
|
||||
]
|
||||
|
||||
items = result["items"]
|
||||
assert [item["id"] for item in items] == [
|
||||
"wallet.pay_invoice",
|
||||
"wallet.list",
|
||||
"wallet.balance.read",
|
||||
"extension.api.request",
|
||||
"ui.camera.scan_qr",
|
||||
"ext.storage.read_write",
|
||||
"ext.storage.read_public",
|
||||
"wallet.create_invoice_public",
|
||||
"wallet.create_invoice",
|
||||
"utils.basic",
|
||||
]
|
||||
|
||||
by_id = {item["id"]: item for item in items}
|
||||
assert by_id["wallet.pay_invoice"]["risk"]["level"] == "high"
|
||||
assert by_id["extension.api.request"]["risk"]["level"] == "high"
|
||||
assert by_id["ext.storage.read_write"]["risk"]["level"] == "low"
|
||||
assert by_id["wallet.create_invoice"]["risk"]["level"] == "low"
|
||||
assert by_id["ui.camera.scan_qr"]["risk"]["level"] == "low"
|
||||
|
||||
assert by_id["ext.storage.read_write"]["label"] == (
|
||||
"Read & Write extension storage"
|
||||
)
|
||||
assert by_id["ext.storage.read_public"]["badges"] == [
|
||||
{"key": "tip_jars", "label": "tip_jars"}
|
||||
]
|
||||
assert by_id["ext.storage.read_public"]["fieldGroups"] == [
|
||||
{
|
||||
"table": "tip_jars",
|
||||
"fields": [
|
||||
"id",
|
||||
"title",
|
||||
"description",
|
||||
"currency",
|
||||
"suggested_amounts",
|
||||
],
|
||||
}
|
||||
]
|
||||
assert by_id["extension.api.request"]["badges"] == [
|
||||
{"key": "watchonly", "label": "Watchonly"}
|
||||
]
|
||||
assert by_id["extension.api.request"]["extensionAccess"] == [
|
||||
{"id": "watchonly", "name": "Watchonly", "access": ["read", "write"]}
|
||||
]
|
||||
assert by_id["wallet.create_invoice_public"]["invoicePolicies"] == [
|
||||
{"table": "tip_jars", "walletField": "wallet_id"}
|
||||
]
|
||||
|
||||
|
||||
def test_public_wasm_page_loads_sandboxed_frame(
|
||||
page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
public_url = (
|
||||
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}/public/item-123"
|
||||
"?source=test"
|
||||
)
|
||||
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
|
||||
|
||||
with page.expect_response(lambda response: frame_url_part in response.url) as info:
|
||||
page.goto(public_url)
|
||||
|
||||
frame_response = info.value
|
||||
assert frame_response.status == 200
|
||||
assert "sandbox allow-scripts" in frame_response.headers["content-security-policy"]
|
||||
assert frame_response.headers["cache-control"] == "no-store"
|
||||
assert frame_response.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
frame = page.locator("iframe.wasm-extension-frame")
|
||||
expect(frame).to_have_attribute("sandbox", "allow-scripts")
|
||||
expect(frame).to_have_attribute("allow", "clipboard-write")
|
||||
expect(frame).to_have_attribute("referrerpolicy", "no-referrer")
|
||||
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
|
||||
expect(
|
||||
page.frame_locator("iframe.wasm-extension-frame").locator("body")
|
||||
).to_contain_text("WASM Test Public")
|
||||
|
||||
|
||||
def test_static_assets_are_strictly_whitelisted(
|
||||
page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
assets_base = f"{lnbits_server.base_url}/ext-assets/{lnbits_server.extension_id}"
|
||||
|
||||
script = page.request.get(f"{assets_base}/app.js")
|
||||
assert script.status == 200
|
||||
assert script.headers["content-type"].startswith("text/javascript")
|
||||
assert script.headers["x-content-type-options"] == "nosniff"
|
||||
assert script.headers["cache-control"] == "no-store"
|
||||
|
||||
core_script = page.request.get(f"{assets_base}/_lnbits/vue.global.prod.js")
|
||||
assert core_script.status == 200
|
||||
assert core_script.headers["content-type"].startswith("text/javascript")
|
||||
assert core_script.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
unsupported_extension = page.request.get(f"{assets_base}/data.json")
|
||||
assert unsupported_extension.status == 404
|
||||
|
||||
html_like_javascript = page.request.get(f"{assets_base}/html-like.js")
|
||||
assert html_like_javascript.status == 404
|
||||
|
||||
|
||||
def test_frame_config_exposes_permissions_and_filters_public_routes(
|
||||
authenticated_page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
public_config = authenticated_page.request.post(
|
||||
_frame_config_url(lnbits_server),
|
||||
data={
|
||||
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
|
||||
"query": {"source_id": "abc", "empty": None},
|
||||
},
|
||||
)
|
||||
assert public_config.status == 200
|
||||
public_bridge = public_config.json()["bridge"]
|
||||
assert public_bridge["public"] is True
|
||||
assert public_bridge["routeParams"] == {"itemId": "item-123"}
|
||||
assert public_bridge["query"] == {"sourceId": "abc"}
|
||||
assert {route["path"] for route in public_bridge["apiRoutes"]} == {
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
|
||||
}
|
||||
|
||||
private_config = authenticated_page.request.post(
|
||||
_frame_config_url(lnbits_server),
|
||||
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
|
||||
)
|
||||
assert private_config.status == 200
|
||||
private_bridge = private_config.json()["bridge"]
|
||||
assert private_bridge["public"] is False
|
||||
assert set(private_bridge["permissions"]) == {
|
||||
permission["id"] for permission in _fixture_permissions()
|
||||
}
|
||||
assert {route["path"] for route in private_bridge["apiRoutes"]} == {
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/wallets",
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/payments",
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/jars/{{jar_id}}",
|
||||
f"/api/v1/ext/{lnbits_server.extension_id}/invoice",
|
||||
}
|
||||
|
||||
|
||||
def test_private_frame_config_and_api_routes_require_auth(
|
||||
page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
private_config = page.request.post(
|
||||
_frame_config_url(lnbits_server),
|
||||
data={"path": f"/ext/{lnbits_server.extension_id}", "query": {}},
|
||||
)
|
||||
assert private_config.status == 401
|
||||
|
||||
private_api = page.request.get(
|
||||
f"{lnbits_server.base_url}/api/v1/ext/{lnbits_server.extension_id}/wallets"
|
||||
)
|
||||
assert private_api.status == 401
|
||||
|
||||
public_config = page.request.post(
|
||||
_frame_config_url(lnbits_server),
|
||||
data={
|
||||
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
|
||||
"query": {},
|
||||
},
|
||||
)
|
||||
assert public_config.status == 200
|
||||
|
||||
|
||||
def test_bridge_context_and_denied_api_request(
|
||||
page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/1"
|
||||
|
||||
with page.expect_response(lambda response: frame_url_part in response.url):
|
||||
page.goto(
|
||||
f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}"
|
||||
"/public/item-123?source=test"
|
||||
)
|
||||
|
||||
frame = _wasm_frame(page, frame_url_part)
|
||||
assert frame.evaluate("() => window.lnbitsWasmTestBridge.ready()") is True
|
||||
|
||||
context_response = frame.evaluate("""
|
||||
() => window.lnbitsWasmTestBridge.request({
|
||||
action: 'context'
|
||||
})
|
||||
""")
|
||||
assert context_response == {
|
||||
"type": "lnbits-extension:response",
|
||||
"id": "wasm-test-1",
|
||||
"ok": True,
|
||||
"data": {
|
||||
"extensionId": EXTENSION_ID,
|
||||
"public": True,
|
||||
"routeParams": {"itemId": "item-123"},
|
||||
"query": {"source": "test"},
|
||||
},
|
||||
}
|
||||
|
||||
denied_response = frame.evaluate("""
|
||||
() => window.lnbitsWasmTestBridge.request({
|
||||
action: 'api',
|
||||
method: 'GET',
|
||||
path: '/api/v1/wallets'
|
||||
})
|
||||
""")
|
||||
assert denied_response["ok"] is False
|
||||
assert denied_response["error"] == "Extension API route is not allowed."
|
||||
|
||||
unknown_response = frame.evaluate("""
|
||||
() => window.lnbitsWasmTestBridge.request({
|
||||
action: 'unknown'
|
||||
})
|
||||
""")
|
||||
assert unknown_response["ok"] is False
|
||||
assert unknown_response["error"] == "Unknown extension bridge action."
|
||||
|
||||
|
||||
def test_frame_token_is_route_bound_required_and_single_use(
|
||||
page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
frame_config = page.request.post(
|
||||
f"{lnbits_server.base_url}/api/v1/ext/"
|
||||
f"{lnbits_server.extension_id}/_ui/frame",
|
||||
data={
|
||||
"path": f"/ext/{lnbits_server.extension_id}/public/item-123",
|
||||
"query": {"source": "test"},
|
||||
},
|
||||
)
|
||||
assert frame_config.status == 200
|
||||
frame_url = frame_config.json()["frameUrl"]
|
||||
|
||||
missing_token = page.request.get(
|
||||
f"{lnbits_server.base_url}/ext-frame/{lnbits_server.extension_id}/1"
|
||||
)
|
||||
assert missing_token.status == 404
|
||||
|
||||
wrong_route = page.request.get(
|
||||
f"{lnbits_server.base_url}{frame_url.replace('/1?', '/0?')}"
|
||||
)
|
||||
assert wrong_route.status == 404
|
||||
|
||||
first_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
|
||||
assert first_use.status == 200
|
||||
assert "form-action 'none'" in first_use.headers["content-security-policy"]
|
||||
|
||||
second_use = page.request.get(f"{lnbits_server.base_url}{frame_url}")
|
||||
assert second_use.status == 404
|
||||
|
||||
|
||||
def test_private_wasm_page_uses_lnbits_shell_and_private_frame(
|
||||
authenticated_page: Page,
|
||||
lnbits_server: LiveLNbitsServer,
|
||||
) -> None:
|
||||
page = authenticated_page
|
||||
frame_url_part = f"/ext-frame/{lnbits_server.extension_id}/0"
|
||||
|
||||
with page.expect_response(lambda response: frame_url_part in response.url) as info:
|
||||
page.goto(f"{lnbits_server.base_url}/ext/{lnbits_server.extension_id}")
|
||||
|
||||
assert info.value.status == 200
|
||||
expect(page.locator("body")).to_contain_text("LNbits")
|
||||
frame = page.locator("iframe.wasm-extension-frame")
|
||||
expect(frame).to_have_attribute("sandbox", "allow-scripts")
|
||||
expect(frame).to_have_attribute("src", _frame_src_pattern(frame_url_part))
|
||||
expect(
|
||||
page.frame_locator("iframe.wasm-extension-frame").locator("body")
|
||||
).to_contain_text("WASM Test Admin")
|
||||
|
||||
|
||||
def _frame_src_pattern(frame_url_part: str) -> Pattern[str]:
|
||||
return re.compile(f"^{re.escape(frame_url_part)}\\?frame_token=[a-f0-9]{{32}}$")
|
||||
|
||||
|
||||
def _frame_config_url(lnbits_server: LiveLNbitsServer) -> str:
|
||||
return (
|
||||
f"{lnbits_server.base_url}/api/v1/ext/"
|
||||
f"{lnbits_server.extension_id}/_ui/frame"
|
||||
)
|
||||
|
||||
|
||||
def _fixture_permissions() -> list[dict[str, Any]]:
|
||||
config = json.loads(
|
||||
(REPO_ROOT / "tests/fixtures" / EXTENSION_ID / "config.json").read_text()
|
||||
)
|
||||
permissions = config["permissions"]
|
||||
assert isinstance(permissions, list)
|
||||
return permissions
|
||||
|
||||
|
||||
def _permission_translations() -> dict[str, str]:
|
||||
return {
|
||||
"extension_permission_access_read": "Read",
|
||||
"extension_permission_access_write": "Write",
|
||||
"extension_permission_ext_storage_read_public": (
|
||||
"Read public extension storage"
|
||||
),
|
||||
"extension_permission_ext_storage_read_write": (
|
||||
"Read & Write extension storage"
|
||||
),
|
||||
"extension_permission_extension_api_request": "Use other extensions",
|
||||
"extension_permission_risk_high": "High risk",
|
||||
"extension_permission_risk_low": "Low risk",
|
||||
"extension_permission_risk_medium": "Medium risk",
|
||||
"extension_permission_ui_camera_scan_qr": "Scan QR codes",
|
||||
"extension_permission_utils_basic": "Use basic LNbits utilities",
|
||||
"extension_permission_wallet_balance_read": "View wallet balances",
|
||||
"extension_permission_wallet_create_invoice": "Create invoices",
|
||||
"extension_permission_wallet_create_invoice_public": (
|
||||
"Create Lightning invoices from public pages"
|
||||
),
|
||||
"extension_permission_wallet_list": "List wallets",
|
||||
"extension_permission_wallet_pay_invoice": "Pay invoices",
|
||||
"extension_permission_warning_extension_api_request_write": (
|
||||
"This extension can write to another extension."
|
||||
),
|
||||
"extension_permission_warning_wallet_pay_invoice": (
|
||||
"This extension can spend from selected wallets."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _wasm_frame(page: Page, frame_url_part: str) -> Frame:
|
||||
frame = page.frame(url=lambda url: frame_url_part in url)
|
||||
assert frame is not None
|
||||
return frame
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user