Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85a5144d0 | ||
|
|
84bf855810 | ||
|
|
09c6e7239d | ||
|
|
53a1ae5e53 | ||
|
|
61ed636df0 | ||
|
|
43900dd6da | ||
|
|
06c553219a | ||
|
|
fc73d83bd9 | ||
|
|
4cb743d952 | ||
|
|
3b6e87b060 | ||
|
|
09e44f18e5 | ||
|
|
cabb58f8fe | ||
|
|
aac04efd0e | ||
|
|
fd9009f760 | ||
|
|
6ab413775a | ||
|
|
7eb9965205 | ||
|
|
4634ad5a5a | ||
|
|
648aaa17c5 | ||
|
|
7db5c986b3 | ||
|
|
26c31b626d | ||
|
|
e39cb20525 | ||
|
|
3cea9a90d1 | ||
|
|
3224e2e774 | ||
|
|
cfc9874517 | ||
|
|
62cd151fd6 | ||
|
|
a4d0aa5db8 | ||
|
|
77630781d7 | ||
|
|
59da350cc9 | ||
|
|
29e980dd67 | ||
|
|
9d9ce63c82 | ||
|
|
748f458b8b | ||
|
|
ce177a73b1 | ||
|
|
2885e71be2 | ||
|
|
4b6f43d274 | ||
|
|
86b5cf9421 | ||
|
|
8f5b7d85aa | ||
|
|
4d51e63924 | ||
|
|
0ce2501e1a | ||
|
|
f2351145f0 | ||
|
|
2eb7d67b2a | ||
|
|
a82093b7ec | ||
|
|
ee595eede1 | ||
|
|
dfdce54e57 | ||
|
|
1367480ec6 | ||
|
|
e2d83b516a | ||
|
|
83699289fc | ||
|
|
f04e88d8bf | ||
|
|
564edfc447 | ||
|
|
b98515df14 | ||
|
|
1e0fc84586 | ||
|
|
a1d94834ae | ||
|
|
5de4239f3c | ||
|
|
c404666d7f | ||
|
|
190a466c0a | ||
|
|
d01e3523d8 | ||
|
|
88672501d8 | ||
|
|
ce57d08163 | ||
|
|
52304e0730 | ||
|
|
6664eebf5a | ||
|
|
2a2af81827 | ||
|
|
9b47f6323f | ||
|
|
a61807a257 |
@@ -119,6 +119,8 @@ LNBITS_SITE_TAGLINE="Open Source Lightning Payments Platform"
|
||||
LNBITS_SITE_DESCRIPTION="The world's most powerful suite of bitcoin tools. Run for yourself, for others, or as part of a stack."
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic, cyber
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador, cyber"
|
||||
# Toggle the background styling on burger menus / drawers
|
||||
# LNBITS_DEFAULT_BURGER_MENU_BACKGROUND=true
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
######################################
|
||||
|
||||
@@ -7,10 +7,6 @@ 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:
|
||||
@@ -18,10 +14,6 @@ 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:
|
||||
@@ -113,11 +105,6 @@ 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
|
||||
|
||||
@@ -10,15 +10,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
ref: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref || github.event.pull_request.head.sha }}
|
||||
- uses: lnbits/lnbits/.github/actions/prepare@dev
|
||||
with:
|
||||
python-version: "3.10"
|
||||
node-version: "24.x"
|
||||
npm: true
|
||||
- run: make bundle
|
||||
- name: Commit and push bundle changes
|
||||
- name: Build and commit bundle (same-repo PR)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
run: |
|
||||
make bundle
|
||||
git config user.name "alan"
|
||||
git config user.email "alan@lnbits.com"
|
||||
git add lnbits/static
|
||||
@@ -27,3 +28,6 @@ jobs:
|
||||
fi
|
||||
git commit -m "chore: make bundle [skip ci]"
|
||||
git push
|
||||
- name: Check bundle is up-to-date (fork PR)
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
run: make checkbundle
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
BOLTZ_MNEMONIC: abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
|
||||
LNBITS_MAX_OUTGOING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_MAX_INCOMING_PAYMENT_AMOUNT_SATS: 1000000000
|
||||
LNBITS_FUNDING_SOURCE_PAY_INVOICE_WAIT_SECONDS: ${{ inputs.backend-wallet-class == 'CoreLightningRestWallet' && 60 || 5 }}
|
||||
ECLAIR_PASS: lnbits
|
||||
PYTHONUNBUFFERED: 1
|
||||
DEBUG: true
|
||||
|
||||
@@ -12,8 +12,6 @@ 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
|
||||
@@ -22,14 +20,6 @@ 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'
|
||||
@@ -74,4 +64,3 @@ jobs:
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
@@ -13,8 +13,6 @@ 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
|
||||
@@ -23,14 +21,6 @@ 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'
|
||||
@@ -85,4 +75,3 @@ jobs:
|
||||
uses: ./.github/workflows/appimage.yml
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
upload_url: ${{ needs.release.outputs.upload_url }}
|
||||
|
||||
+1
-1
@@ -43,4 +43,4 @@ ENV LNBITS_HOST="0.0.0.0"
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["sh", "-c", "uv --offline run lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
CMD ["sh", "-c", "uv --offline run --no-sync lnbits --port $LNBITS_PORT --host $LNBITS_HOST --forwarded-allow-ips='*'"]
|
||||
|
||||
@@ -51,7 +51,7 @@ nav_order: 1
|
||||
sudo apt-get install jq libfuse2
|
||||
wget $(curl -s https://api.github.com/repos/lnbits/lnbits/releases/latest | jq -r '.assets[] | select(.name | endswith(".AppImage")) | .browser_download_url') -O LNbits-latest.AppImage
|
||||
chmod +x LNbits-latest.AppImage
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
LNBITS_ADMIN_UI=true HOST=0.0.0.0 PORT=5000 AUTH_HTTPS_ONLY=false ./LNbits-latest.AppImage # most system settings are now in the admin UI, but pass additional .env variables here
|
||||
```
|
||||
|
||||
- LNbits will create a folder for DB and extension files **in the same directory** as the AppImage.
|
||||
@@ -285,10 +285,7 @@ but you can also set the env variables or pass command line arguments:
|
||||
|
||||
```sh
|
||||
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
|
||||
LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
|
||||
# Once you have created a user, you can set as the super_user
|
||||
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
|
||||
LNBITS_ADMIN_UI=true AUTH_HTTPS_ONLY=false ./result/bin/lnbits --port 9000 --host 0.0.0.0
|
||||
```
|
||||
|
||||
> 
|
||||
|
||||
+76
-32
@@ -23,29 +23,40 @@ 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.notifications import enqueue_admin_notification
|
||||
from lnbits.core.services.payments import check_pending_payments
|
||||
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.tasks import (
|
||||
audit_queue,
|
||||
collect_exchange_rates_data,
|
||||
purge_audit_data,
|
||||
run_by_the_minute_tasks,
|
||||
wait_for_audit_data,
|
||||
wait_for_paid_invoices,
|
||||
wait_notification_messages,
|
||||
notify_server_status,
|
||||
process_next_audit_entry,
|
||||
refresh_extension_cache,
|
||||
)
|
||||
from lnbits.core.wasm_ext.routes.register import register_wasm_extension
|
||||
from lnbits.core.wasm_ext.wasm.events import dispatch_wasm_invoice_paid
|
||||
from lnbits.core.wasm_ext.wasm.loader import (
|
||||
is_wasm_extension_id,
|
||||
)
|
||||
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,
|
||||
@@ -68,7 +79,7 @@ from .middleware import (
|
||||
add_profiler_middleware,
|
||||
add_ratelimit_middleware,
|
||||
)
|
||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||
from .task_manager import task_manager
|
||||
|
||||
|
||||
async def startup(app: FastAPI):
|
||||
@@ -102,6 +113,9 @@ async def startup(app: FastAPI):
|
||||
# register core routes
|
||||
init_core_routers(app)
|
||||
|
||||
# register llms.txt endpoint for AI agents
|
||||
create_llms_txt_route(app)
|
||||
|
||||
# initialize tasks
|
||||
register_async_tasks()
|
||||
|
||||
@@ -129,7 +143,7 @@ async def shutdown():
|
||||
settings.lnbits_running = False
|
||||
|
||||
# shutdown event
|
||||
cancel_all_tasks()
|
||||
task_manager.cancel_all_tasks()
|
||||
|
||||
# wait a bit to allow them to finish, so that cleanup can run without problems
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -162,6 +176,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
# Allow registering new extensions routes without direct access to the `app` object
|
||||
core_app_extra.register_new_ext_routes = register_new_ext_routes(app)
|
||||
core_app_extra.register_new_wasm_ext_routes = register_new_wasm_ext_routes(app)
|
||||
core_app_extra.register_new_ratelimiter = register_new_ratelimiter(app)
|
||||
|
||||
# register static files
|
||||
@@ -408,6 +423,13 @@ def register_new_ext_routes(app: FastAPI) -> Callable:
|
||||
return register_new_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_wasm_ext_routes(app: FastAPI) -> Callable:
|
||||
def register_new_wasm_ext_routes_fn(ext_id: str):
|
||||
register_wasm_extension(app, ext_id)
|
||||
|
||||
return register_new_wasm_ext_routes_fn
|
||||
|
||||
|
||||
def register_new_ratelimiter(app: FastAPI) -> Callable:
|
||||
def register_new_ratelimiter_fn():
|
||||
limiter = Limiter(
|
||||
@@ -461,37 +483,59 @@ async def check_and_register_extensions(app: FastAPI) -> None:
|
||||
await check_installed_extensions(app)
|
||||
for ext in await get_valid_extensions(False):
|
||||
try:
|
||||
if is_wasm_extension_id(ext.code):
|
||||
register_wasm_extension(app, ext.code)
|
||||
continue
|
||||
register_ext_routes(app, ext)
|
||||
register_ext_tasks(ext)
|
||||
except Exception as exc:
|
||||
logger.error(f"Could not load extension `{ext.code}`: {exc!s}")
|
||||
await update_installed_extension_state(ext_id=ext.code, active=False)
|
||||
|
||||
|
||||
def register_async_tasks() -> None:
|
||||
|
||||
create_permanent_task(wait_for_audit_data)
|
||||
create_permanent_task(wait_notification_messages)
|
||||
task_manager.init()
|
||||
|
||||
create_permanent_task(
|
||||
run_interval(
|
||||
settings.lnbits_funding_source_pending_interval_seconds,
|
||||
check_pending_payments,
|
||||
)
|
||||
# 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")
|
||||
|
||||
# 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,
|
||||
)
|
||||
create_permanent_task(invoice_listener)
|
||||
create_permanent_task(internal_invoice_listener)
|
||||
create_permanent_task(cache.invalidate_forever)
|
||||
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)
|
||||
|
||||
# core invoice listener
|
||||
invoice_queue: asyncio.Queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, "core")
|
||||
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue))
|
||||
# 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)
|
||||
|
||||
create_permanent_task(run_by_the_minute_tasks)
|
||||
create_permanent_task(purge_audit_data)
|
||||
create_permanent_task(collect_exchange_rates_data)
|
||||
async def dispatch_extension_invoice_paid(payment) -> None:
|
||||
await dispatch_wasm_invoice_paid(payment)
|
||||
|
||||
task_manager.register_invoice_listener(dispatch_extension_invoice_paid, "core_wasm")
|
||||
|
||||
# server logs for websocket
|
||||
if settings.lnbits_admin_ui:
|
||||
server_log_task = initialize_server_websocket_logger()
|
||||
create_permanent_task(server_log_task)
|
||||
task_manager.create_permanent_task(
|
||||
server_log_task, name="server_websocket_logger"
|
||||
)
|
||||
|
||||
@@ -18,13 +18,13 @@ 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 (
|
||||
DateTrunc,
|
||||
check_internal,
|
||||
create_payment,
|
||||
delete_expired_invoices,
|
||||
delete_wallet_payment,
|
||||
get_latest_payments_by_extension,
|
||||
get_payment,
|
||||
@@ -102,7 +102,6 @@ __all__ = [
|
||||
"delete_accounts_no_wallets",
|
||||
"delete_admin_settings",
|
||||
"delete_dbversion",
|
||||
"delete_expired_invoices",
|
||||
"delete_installed_extension",
|
||||
"delete_tinyurl",
|
||||
"delete_unused_wallets",
|
||||
@@ -156,6 +155,7 @@ __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,7 +1,12 @@
|
||||
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
|
||||
|
||||
@@ -11,6 +16,11 @@ 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(
|
||||
@@ -18,6 +28,11 @@ 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(
|
||||
@@ -31,6 +46,33 @@ 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:
|
||||
@@ -144,3 +186,158 @@ 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:
|
||||
database = conn or db
|
||||
where: list[str] = []
|
||||
values: dict = {}
|
||||
if extension_id:
|
||||
where.append("extension_id = :extension_id")
|
||||
values["extension_id"] = extension_id
|
||||
if since:
|
||||
where.append(f"started_at >= {database.timestamp_placeholder('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)
|
||||
database = conn or db
|
||||
result = await database.execute(
|
||||
f"""
|
||||
DELETE FROM wasm_invocations
|
||||
WHERE status != 'running'
|
||||
AND started_at < {database.timestamp_placeholder("cutoff")}
|
||||
""", # noqa: S608
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
|
||||
async def mark_stale_wasm_invocations(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
database = conn or db
|
||||
await database.execute(
|
||||
f"""
|
||||
UPDATE wasm_invocations
|
||||
SET status = 'abandoned',
|
||||
finished_at = {database.timestamp_placeholder("finished_at")},
|
||||
stop_reason = 'Server restarted before invocation finished.'
|
||||
WHERE status = 'running'
|
||||
""", # noqa: S608
|
||||
{"finished_at": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
@@ -238,32 +238,6 @@ async def get_payments_status_count() -> PaymentsStatusCount:
|
||||
)
|
||||
|
||||
|
||||
async def delete_expired_invoices(
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
# first we delete all invoices older than one month
|
||||
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND time < {db.timestamp_placeholder("delta")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "delta": int(time() - 2592000)},
|
||||
)
|
||||
# then we delete all invoices whose expiry date is in the past
|
||||
await (conn or db).execute(
|
||||
# Timestamp placeholder is safe from SQL injection (not user input)
|
||||
f"""
|
||||
DELETE FROM apipayments
|
||||
WHERE status = :status AND amount > 0
|
||||
AND expiry < {db.timestamp_placeholder("now")}
|
||||
""", # noqa: S608
|
||||
{"status": f"{PaymentState.PENDING}", "now": int(time())},
|
||||
)
|
||||
|
||||
|
||||
async def create_payment(
|
||||
checking_id: str,
|
||||
data: CreatePayment,
|
||||
@@ -290,8 +264,10 @@ async def create_payment(
|
||||
webhook=data.webhook,
|
||||
fee=-abs(data.fee),
|
||||
tag=extra.get("tag", None),
|
||||
extension=data.extension,
|
||||
extra=extra,
|
||||
labels=data.labels or [],
|
||||
external_id=data.external_id,
|
||||
)
|
||||
|
||||
await (conn or db).insert("apipayments", payment)
|
||||
@@ -305,7 +281,7 @@ async def update_payment_checking_id(
|
||||
await (conn or db).execute(
|
||||
f"""
|
||||
UPDATE apipayments
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder('now')}
|
||||
SET checking_id = :new_id, updated_at = {db.timestamp_placeholder("now")}
|
||||
WHERE checking_id = :old_id
|
||||
""", # noqa: S608
|
||||
{
|
||||
@@ -320,13 +296,15 @@ async def update_payment(
|
||||
payment: Payment,
|
||||
new_checking_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> None:
|
||||
) -> Payment:
|
||||
payment.updated_at = datetime.now(timezone.utc)
|
||||
await (conn or db).update(
|
||||
"apipayments", payment, "WHERE checking_id = :checking_id"
|
||||
)
|
||||
if new_checking_id and new_checking_id != payment.checking_id:
|
||||
await update_payment_checking_id(payment.checking_id, new_checking_id, conn)
|
||||
payment.checking_id = new_checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def get_payments_history(
|
||||
@@ -398,7 +376,6 @@ async def get_payment_count_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentCountStat]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
extra_stmts = []
|
||||
@@ -431,7 +408,6 @@ async def get_daily_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> tuple[list[PaymentDailyStats], list[PaymentDailyStats]]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
@@ -481,7 +457,6 @@ async def get_wallets_stats(
|
||||
user_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[PaymentWalletStats]:
|
||||
|
||||
if not filters:
|
||||
filters = Filters()
|
||||
|
||||
|
||||
@@ -8,11 +8,18 @@ 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")
|
||||
@@ -69,16 +76,14 @@ async def delete_admin_settings(tag: str | None = "core") -> None:
|
||||
|
||||
|
||||
async def reset_core_settings() -> None:
|
||||
await db.execute(
|
||||
"""
|
||||
DELETE FROM system_settings WHERE tag = 'core'
|
||||
AND id NOT IN (
|
||||
'super_user',
|
||||
'lnbits_webpush_pubkey',
|
||||
'lnbits_webpush_privkey'
|
||||
)
|
||||
""",
|
||||
)
|
||||
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])
|
||||
|
||||
|
||||
async def create_admin_settings(super_user: str, new_settings: dict) -> SuperSettings:
|
||||
|
||||
@@ -15,6 +15,8 @@ from lnbits.core.crud import (
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.core.wasm_ext.storage.crud import migrate_wasm_extension_database
|
||||
from lnbits.core.wasm_ext.wasm.loader import is_wasm_extension_id
|
||||
from lnbits.db import COCKROACH, POSTGRES, SQLITE, Connection
|
||||
from lnbits.settings import settings
|
||||
|
||||
@@ -22,7 +24,16 @@ from lnbits.settings import settings
|
||||
async def migrate_extension_database(
|
||||
ext: InstallableExtension, current_version: DbVersion | None = None
|
||||
):
|
||||
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
|
||||
|
||||
@@ -802,3 +802,91 @@ async def m044_add_activated_to_accounts(db: Connection):
|
||||
Used for account activation status.
|
||||
"""
|
||||
await db.execute("ALTER TABLE accounts ADD COLUMN activated BOOLEAN DEFAULT true")
|
||||
|
||||
|
||||
async def m045_add_external_id_to_payments(db: Connection):
|
||||
"""
|
||||
Adds external_id column to apipayments.
|
||||
Used for external payment references.
|
||||
"""
|
||||
await db.execute("ALTER TABLE apipayments ADD COLUMN external_id TEXT")
|
||||
logger.debug("Creating index idx_payments_external_id...")
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_payments_external_id
|
||||
ON apipayments (external_id);
|
||||
""")
|
||||
|
||||
|
||||
async def m046_add_permissions_to_installed_extensions(db: Connection):
|
||||
"""
|
||||
Adds granted permissions to installed extensions.
|
||||
"""
|
||||
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 '{}'"
|
||||
)
|
||||
|
||||
|
||||
async def m049_add_permissions_to_user_extensions(db: Connection):
|
||||
"""
|
||||
Adds user-level extension permission grants.
|
||||
"""
|
||||
await db.execute("ALTER TABLE extensions ADD COLUMN permissions TEXT DEFAULT '{}'")
|
||||
|
||||
@@ -25,6 +25,7 @@ from .payments import (
|
||||
PaymentState,
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from .tinyurl import TinyURL
|
||||
from .users import (
|
||||
@@ -90,6 +91,7 @@ __all__ = [
|
||||
"SimpleStatus",
|
||||
"TinyURL",
|
||||
"UpdateBalance",
|
||||
"UpdatePaymentExtra",
|
||||
"UpdateSuperuserPassword",
|
||||
"UpdateUser",
|
||||
"UpdateUserPassword",
|
||||
|
||||
@@ -6,13 +6,16 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import zipfile
|
||||
from asyncio.tasks import create_task
|
||||
from pathlib import Path
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, StrictStr
|
||||
|
||||
from lnbits.helpers import (
|
||||
download_url,
|
||||
@@ -21,6 +24,7 @@ from lnbits.helpers import (
|
||||
version_parse,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
|
||||
@@ -55,9 +59,10 @@ class GitHubRelease(BaseModel):
|
||||
|
||||
|
||||
class Manifest(BaseModel):
|
||||
featured: list[str] = []
|
||||
extensions: list[ExplicitRelease] = []
|
||||
repos: list[GitHubRelease] = []
|
||||
featured: list[str] = []
|
||||
categories: dict[str, list[str]] = {}
|
||||
|
||||
|
||||
class GitHubRepoRelease(BaseModel):
|
||||
@@ -76,6 +81,23 @@ class GitHubRepo(BaseModel):
|
||||
default_branch: str
|
||||
|
||||
|
||||
class ExtensionPermission(BaseModel):
|
||||
id: StrictStr
|
||||
description: StrictStr | None = None
|
||||
policies: list[Any] | None = None
|
||||
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
@staticmethod
|
||||
def list_from_config(config_json: Mapping[str, Any]) -> list[ExtensionPermission]:
|
||||
return [
|
||||
ExtensionPermission.parse_obj(permission)
|
||||
for permission in config_json.get("permissions") or []
|
||||
if isinstance(permission, dict) and permission.get("id")
|
||||
]
|
||||
|
||||
|
||||
class ExtensionConfig(BaseModel):
|
||||
name: str
|
||||
short_description: str
|
||||
@@ -83,6 +105,8 @@ class ExtensionConfig(BaseModel):
|
||||
warning: str | None = ""
|
||||
min_lnbits_version: str | None
|
||||
max_lnbits_version: str | None
|
||||
extension_type: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
def is_version_compatible(self) -> bool:
|
||||
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
|
||||
@@ -117,11 +141,83 @@ class UserExtensionInfo(BaseModel):
|
||||
payment_hash_to_enable: str | None = None
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentDestinationPolicy(str, Enum):
|
||||
OWN_WALLETS_ONLY = "own_wallets_only"
|
||||
EXTERNAL_ALLOWED = "external_allowed"
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
|
||||
class ExtensionBackgroundPaymentGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
max_amount: int = Field(..., gt=0)
|
||||
destination_policy: ExtensionBackgroundPaymentDestinationPolicy
|
||||
|
||||
def to_grant(self, grant_id: str | None = None) -> ExtensionBackgroundPaymentGrant:
|
||||
return ExtensionBackgroundPaymentGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
max_amount=self.max_amount,
|
||||
destination_policy=self.destination_policy,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrant(BaseModel):
|
||||
id: StrictStr = Field(..., min_length=1, max_length=128)
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ExtensionWalletPaymentsWatchGrantRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
def to_grant(
|
||||
self, grant_id: str | None = None
|
||||
) -> ExtensionWalletPaymentsWatchGrant:
|
||||
return ExtensionWalletPaymentsWatchGrant(
|
||||
id=grant_id or str(uuid4()),
|
||||
wallet_id=self.wallet_id,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckItem(BaseModel):
|
||||
id: StrictStr
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckRequest(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResult(BaseModel):
|
||||
id: StrictStr
|
||||
approved: bool
|
||||
grant: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExtensionPermissionCheckResponse(BaseModel):
|
||||
permissions: list[ExtensionPermissionCheckResult] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExtensionPermissionsResponse(BaseModel):
|
||||
extension_permissions: list[ExtensionPermission] = Field(default_factory=list)
|
||||
user_permissions: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserExtension(BaseModel):
|
||||
user: str
|
||||
extension: str
|
||||
active: bool
|
||||
extra: UserExtensionInfo | None = None
|
||||
permissions: dict = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
@@ -143,6 +239,7 @@ class UserExtension(BaseModel):
|
||||
class Extension(BaseModel):
|
||||
code: str
|
||||
is_valid: bool
|
||||
is_wasm: bool = False
|
||||
name: str | None = None
|
||||
short_description: str | None = None
|
||||
tile: str | None = None
|
||||
@@ -159,6 +256,8 @@ class Extension(BaseModel):
|
||||
|
||||
@property
|
||||
def is_upgrade_extension(self) -> bool:
|
||||
if self.is_wasm:
|
||||
return False
|
||||
return self.upgrade_hash != ""
|
||||
|
||||
@classmethod
|
||||
@@ -166,13 +265,71 @@ class Extension(BaseModel):
|
||||
return Extension(
|
||||
code=ext_info.id,
|
||||
is_valid=True,
|
||||
is_wasm=ext_info.is_wasm,
|
||||
name=ext_info.name,
|
||||
short_description=ext_info.short_description,
|
||||
tile=ext_info.icon,
|
||||
tile=_extension_tile(ext_info),
|
||||
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)
|
||||
|
||||
|
||||
class ExtensionRelease(BaseModel):
|
||||
name: str
|
||||
version: str
|
||||
@@ -308,7 +465,6 @@ class ExtensionRelease(BaseModel):
|
||||
|
||||
@classmethod
|
||||
async def fetch_release_details(cls, details_link: str) -> dict | None:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(details_link)
|
||||
@@ -333,6 +489,7 @@ class ExtensionMeta(BaseModel):
|
||||
dependencies: list[str] = []
|
||||
archive: str | None = None
|
||||
featured: bool = False
|
||||
categories: list[str] = []
|
||||
paid_features: str | None = None
|
||||
has_paid_release: bool = False
|
||||
has_free_release: bool = False
|
||||
@@ -347,6 +504,8 @@ class InstallableExtension(BaseModel):
|
||||
icon: str | None = None
|
||||
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:
|
||||
@@ -399,6 +558,18 @@ class InstallableExtension(BaseModel):
|
||||
return False
|
||||
return self.meta.pay_to_enable.required is True
|
||||
|
||||
@property
|
||||
def is_wasm(self) -> bool:
|
||||
config_path = Path(self.ext_dir, "config.json")
|
||||
if not config_path.is_file():
|
||||
return False
|
||||
try:
|
||||
with open(config_path, encoding="utf-8") as json_file:
|
||||
config_json = json.load(json_file)
|
||||
except Exception:
|
||||
return False
|
||||
return config_json.get("extension_type") == "wasm"
|
||||
|
||||
async def download_archive(self):
|
||||
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
|
||||
ext_zip_file = self.zip_path
|
||||
@@ -431,6 +602,22 @@ class InstallableExtension(BaseModel):
|
||||
os.remove(ext_zip_file)
|
||||
raise AssertionError("File hash missmatch. Will not install.")
|
||||
|
||||
def load_archive_config(self) -> dict[str, Any]:
|
||||
if not self.zip_path.is_file():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(self.zip_path, "r") as archive:
|
||||
config_name = _archive_config_name(archive.namelist())
|
||||
if not config_name:
|
||||
return {}
|
||||
with archive.open(config_name) as config_file:
|
||||
config = json.load(config_file)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Cannot read extension config for '{self.id}'.") from exc
|
||||
|
||||
return config if isinstance(config, dict) else {}
|
||||
|
||||
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)
|
||||
@@ -609,6 +796,7 @@ class InstallableExtension(BaseModel):
|
||||
version=version,
|
||||
short_description=config_json.get("short_description"),
|
||||
icon=config_json.get("tile"),
|
||||
permissions=ExtensionPermission.list_from_config(config_json),
|
||||
meta=ExtensionMeta(
|
||||
installed_release=ExtensionRelease(
|
||||
name=ext_id,
|
||||
@@ -641,7 +829,10 @@ 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
|
||||
create_task(cls._refresh_installable_extensions_cache())
|
||||
task_manager.create_task(
|
||||
cls._refresh_installable_extensions_cache(),
|
||||
"refresh_installable_extensions_cache",
|
||||
)
|
||||
|
||||
extension_list = cache_value.value # type: ignore
|
||||
return extension_list
|
||||
@@ -680,6 +871,11 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
|
||||
@@ -695,6 +891,11 @@ class InstallableExtension(BaseModel):
|
||||
ext.check_release_updates(release)
|
||||
meta = ext.meta or ExtensionMeta()
|
||||
meta.featured = ext.id in manifest.featured
|
||||
meta.categories = [
|
||||
category
|
||||
for category, ext_ids in manifest.categories.items()
|
||||
if ext.id in ext_ids
|
||||
]
|
||||
ext.meta = meta
|
||||
extension_list += [ext]
|
||||
except Exception as e:
|
||||
@@ -789,6 +990,7 @@ class CreateExtension(BaseModel):
|
||||
version: str
|
||||
cost_sats: int | None = 0
|
||||
payment_hash: str | None = None
|
||||
permissions: list[ExtensionPermission] = []
|
||||
|
||||
|
||||
class ExtensionDetailsRequest(BaseModel):
|
||||
@@ -841,3 +1043,21 @@ 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,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -11,8 +13,41 @@ def _do_nothing(*_):
|
||||
|
||||
class CoreAppExtra:
|
||||
register_new_ext_routes: Callable = _do_nothing
|
||||
register_new_wasm_ext_routes: Callable = _do_nothing
|
||||
register_new_ratelimiter: Callable
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wasm_extension_registry = WasmExtensionRegistry()
|
||||
|
||||
|
||||
class WasmExtensionRegistry:
|
||||
def __init__(self) -> None:
|
||||
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)
|
||||
|
||||
def list(self) -> list[Any]:
|
||||
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"
|
||||
|
||||
@@ -13,6 +13,7 @@ from lnbits.db import FilterModel
|
||||
from lnbits.fiat.base import (
|
||||
FiatPaymentStatus,
|
||||
)
|
||||
from lnbits.helpers import is_valid_external_id
|
||||
from lnbits.utils.exchange_rates import allowed_currencies
|
||||
from lnbits.wallets.base import (
|
||||
PaymentStatus,
|
||||
@@ -34,6 +35,11 @@ class PaymentExtra(BaseModel):
|
||||
lnurl_response: str | None = None
|
||||
|
||||
|
||||
class UpdatePaymentExtra(BaseModel):
|
||||
payment_hash: str
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoice(BaseModel):
|
||||
payment_request: str
|
||||
description: str | None = None
|
||||
@@ -48,11 +54,17 @@ class CreatePayment(BaseModel):
|
||||
amount_msat: int
|
||||
memo: str
|
||||
extra: dict | None = {}
|
||||
extension: str | None = None
|
||||
preimage: str | None = None
|
||||
expiry: datetime | None = None
|
||||
webhook: str | None = None
|
||||
fee: int = 0
|
||||
labels: list[str] | None = None
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class Payment(BaseModel):
|
||||
@@ -77,6 +89,11 @@ class Payment(BaseModel):
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
labels: list[str] = []
|
||||
extra: dict = {}
|
||||
external_id: str | None = None
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
@@ -124,22 +141,18 @@ class Payment(BaseModel):
|
||||
)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_status(self) -> PaymentStatus:
|
||||
logger.warning("payment.check_status() is deprecated.")
|
||||
from lnbits.core.services.payments import check_payment_status
|
||||
|
||||
return await check_payment_status(self, skip_internal_payment_notifications)
|
||||
return await check_payment_status(self)
|
||||
|
||||
# DEPRECATED: in v1.5.0, use service check_payment_status instead
|
||||
async def check_fiat_status(
|
||||
self, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
async def check_fiat_status(self) -> FiatPaymentStatus:
|
||||
logger.warning("payment.check_fiat_status() is deprecated.")
|
||||
from lnbits.core.services.fiat_providers import check_fiat_status
|
||||
|
||||
return await check_fiat_status(self, skip_internal_payment_notifications)
|
||||
return await check_fiat_status(self)
|
||||
|
||||
|
||||
class PaymentFilters(FilterModel):
|
||||
@@ -151,6 +164,7 @@ class PaymentFilters(FilterModel):
|
||||
"status",
|
||||
"time",
|
||||
"labels",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
__sort_fields__ = [
|
||||
@@ -161,11 +175,13 @@ class PaymentFilters(FilterModel):
|
||||
"memo",
|
||||
"time",
|
||||
"tag",
|
||||
"external_id",
|
||||
]
|
||||
|
||||
status: str | None
|
||||
tag: str | None
|
||||
checking_id: str | None
|
||||
external_id: str | None
|
||||
amount: int
|
||||
fee: int
|
||||
memo: str | None
|
||||
@@ -244,11 +260,13 @@ class CreateInvoice(BaseModel):
|
||||
)
|
||||
expiry: int | None = None
|
||||
extra: dict | None = None
|
||||
extension: str | None = None
|
||||
webhook: str | None = None
|
||||
bolt11: str | None = None
|
||||
lnurl_withdraw: LnurlWithdrawResponse | None = None
|
||||
fiat_provider: str | None = None
|
||||
labels: list[str] = []
|
||||
external_id: str | None = Query(default=None, max_length=256)
|
||||
|
||||
@validator("payment_hash")
|
||||
def check_hex(cls, v):
|
||||
@@ -263,6 +281,10 @@ class CreateInvoice(BaseModel):
|
||||
raise ValueError("The provided unit is not supported")
|
||||
return v
|
||||
|
||||
@validator("external_id")
|
||||
def validate_external_id(cls, external_id):
|
||||
return _validate_external_id(external_id)
|
||||
|
||||
|
||||
class PaymentsStatusCount(BaseModel):
|
||||
incoming: int = 0
|
||||
@@ -301,3 +323,12 @@ class CancelInvoice(BaseModel):
|
||||
|
||||
class UpdatePaymentLabels(BaseModel):
|
||||
labels: list[str] = []
|
||||
|
||||
|
||||
def _validate_external_id(external_id: str | None) -> str | None:
|
||||
if external_id and not is_valid_external_id(external_id):
|
||||
raise ValueError(
|
||||
"Invalid external id. Max length is 256 characters. "
|
||||
"Space and newlines are not allowed."
|
||||
)
|
||||
return external_id
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
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
|
||||
|
||||
@@ -13,18 +20,173 @@ 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 settings
|
||||
from lnbits.settings import WasmRuntimeLimits, settings
|
||||
|
||||
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
|
||||
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, skip_download: bool | None = False
|
||||
ext_info: InstallableExtension,
|
||||
skip_download: bool | None = False,
|
||||
granted_permissions: list[ExtensionPermission] | None = None,
|
||||
) -> Extension:
|
||||
|
||||
ext_info.meta = ext_info.meta or ExtensionMeta()
|
||||
@@ -38,12 +200,19 @@ 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)
|
||||
|
||||
if not skip_download:
|
||||
await ext_info.download_archive()
|
||||
|
||||
extension_config = ext_info.load_archive_config()
|
||||
ext_info.permissions = validate_wasm_extension_permissions(
|
||||
ext_info, granted_permissions, extension_config
|
||||
)
|
||||
|
||||
ext_info.extract_archive()
|
||||
|
||||
db_version = await get_db_version(ext_info.id)
|
||||
@@ -61,7 +230,8 @@ async def install_extension(
|
||||
# call stop while the old routes are still active
|
||||
await stop_extension_background_work(ext_info.id)
|
||||
|
||||
await start_extension_background_work(ext_info.id)
|
||||
if not extension.is_wasm:
|
||||
await start_extension_background_work(ext_info.id)
|
||||
|
||||
return extension
|
||||
|
||||
@@ -75,6 +245,394 @@ 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)
|
||||
|
||||
@@ -87,12 +645,19 @@ async def uninstall_extension(ext_id: str):
|
||||
|
||||
|
||||
async def activate_extension(ext: Extension):
|
||||
if ext.is_wasm:
|
||||
core_app_extra.register_new_wasm_ext_routes(ext.code)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
return
|
||||
|
||||
core_app_extra.register_new_ext_routes(ext)
|
||||
await update_installed_extension_state(ext_id=ext.code, active=True)
|
||||
await start_extension_background_work(ext.code)
|
||||
|
||||
|
||||
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)
|
||||
@@ -103,6 +668,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
|
||||
Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
|
||||
Extension must expose a `myextension_stop()` function if it is starting tasks.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return True
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
@@ -135,6 +703,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
|
||||
Extension CAN expose a `myextension_start()` function if it is starting tasks.
|
||||
Extension MUST expose a `myextension_stop()` in that case.
|
||||
"""
|
||||
if is_wasm_extension_id(ext_id):
|
||||
return False
|
||||
|
||||
upgrade_hash = settings.extension_upgrade_hash(ext_id)
|
||||
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from base64 import b64encode
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import get_wallet
|
||||
from lnbits.core.crud.payments import create_payment
|
||||
from lnbits.core.crud.payments import create_payment, update_payment
|
||||
from lnbits.core.models import CreatePayment, Payment, PaymentState
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.db import Connection
|
||||
@@ -19,6 +20,7 @@ from lnbits.fiat.base import (
|
||||
FiatPaymentSuccessStatus,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
from lnbits.task_manager import task_manager
|
||||
|
||||
|
||||
async def handle_fiat_payment_confirmation(
|
||||
@@ -35,9 +37,7 @@ async def handle_fiat_payment_confirmation(
|
||||
logger.warning(e)
|
||||
|
||||
|
||||
async def check_fiat_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> FiatPaymentStatus:
|
||||
async def check_fiat_status(payment: Payment) -> FiatPaymentStatus:
|
||||
if not payment.is_internal:
|
||||
return FiatPaymentPendingStatus()
|
||||
if payment.success:
|
||||
@@ -57,14 +57,11 @@ async def check_fiat_status(
|
||||
return FiatPaymentPendingStatus()
|
||||
fiat_status = await fiat_provider.get_invoice_status(checking_id)
|
||||
|
||||
if skip_internal_payment_notifications:
|
||||
return fiat_status
|
||||
|
||||
if fiat_status.success:
|
||||
# notify receivers asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
|
||||
await internal_invoice_queue.put(payment.checking_id)
|
||||
payment.status = PaymentState.SUCCESS.value
|
||||
await update_payment(payment)
|
||||
await handle_fiat_payment_confirmation(payment)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
return fiat_status
|
||||
|
||||
@@ -169,6 +166,82 @@ async def verify_paypal_webhook(headers, payload: bytes):
|
||||
raise ValueError("PayPal webhook cannot be verified.") from exc
|
||||
|
||||
|
||||
def check_square_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
secret: str | None,
|
||||
notification_url: str | None,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Square signature header is missing.")
|
||||
raise ValueError("Square signature header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Square webhook signature key is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
if not notification_url:
|
||||
logger.warning("Square webhook notification URL is not set.")
|
||||
raise ValueError("Square webhook cannot be verified.")
|
||||
|
||||
signed_payload = notification_url.encode() + payload
|
||||
computed_signature = b64encode(
|
||||
hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
if hmac.compare_digest(computed_signature, sig_header) is not True:
|
||||
logger.warning("Square signature verification failed.")
|
||||
raise ValueError("Square signature verification failed.")
|
||||
|
||||
|
||||
def check_revolut_signature(
|
||||
payload: bytes,
|
||||
sig_header: str | None,
|
||||
timestamp_header: str | None,
|
||||
secret: str | None,
|
||||
tolerance_seconds=300,
|
||||
):
|
||||
if not sig_header:
|
||||
logger.warning("Revolut signature header is missing.")
|
||||
raise ValueError("Revolut signature header is missing.")
|
||||
|
||||
if not timestamp_header:
|
||||
logger.warning("Revolut timestamp header is missing.")
|
||||
raise ValueError("Revolut timestamp header is missing.")
|
||||
|
||||
if not secret:
|
||||
logger.warning("Revolut webhook signing secret is not set.")
|
||||
raise ValueError("Revolut webhook cannot be verified.")
|
||||
|
||||
try:
|
||||
timestamp = int(timestamp_header)
|
||||
except ValueError as exc:
|
||||
logger.warning("Invalid Revolut timestamp.")
|
||||
raise ValueError("Invalid Revolut timestamp.") from exc
|
||||
|
||||
timestamp_seconds = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
|
||||
if abs(time.time() - timestamp_seconds) > tolerance_seconds:
|
||||
logger.warning("Timestamp outside tolerance.")
|
||||
raise ValueError("Timestamp outside tolerance." f"Timestamp: {timestamp}")
|
||||
|
||||
signed_payload = b"v1." + timestamp_header.encode() + b"." + payload
|
||||
digest = hmac.new(
|
||||
key=secret.encode(), msg=signed_payload, digestmod=hashlib.sha256
|
||||
).hexdigest()
|
||||
expected_signature = f"v1={digest}"
|
||||
|
||||
provided_signatures = [sig.strip() for sig in sig_header.split(",") if sig.strip()]
|
||||
if not any(
|
||||
hmac.compare_digest(expected_signature, provided)
|
||||
for provided in provided_signatures
|
||||
):
|
||||
logger.warning("Revolut signature verification failed.")
|
||||
raise ValueError("Revolut signature verification failed.")
|
||||
|
||||
|
||||
async def test_connection(provider: str) -> SimpleStatus:
|
||||
"""
|
||||
Test the connection to Stripe by checking if the API key is valid.
|
||||
|
||||
@@ -66,6 +66,8 @@ 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
|
||||
|
||||
@@ -74,7 +74,7 @@ async def send_admin_notification(
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
) -> None:
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
settings.lnbits_telegram_notifications_chat_id,
|
||||
settings.lnbits_nostr_notifications_identifiers,
|
||||
settings.lnbits_email_notifications_to_emails,
|
||||
@@ -97,7 +97,7 @@ async def send_user_notification(
|
||||
if user_notifications.nostr_identifier
|
||||
else []
|
||||
)
|
||||
return await send_notification(
|
||||
return await send_notification_in_background(
|
||||
user_notifications.telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_address,
|
||||
@@ -222,12 +222,29 @@ async def send_email(
|
||||
msg["Subject"] = subject
|
||||
msg.attach(MIMEText(message, "plain"))
|
||||
username = username if len(username) > 0 else from_email
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
|
||||
def _send() -> bool:
|
||||
with smtplib.SMTP(server, port) as smtp_server:
|
||||
smtp_server.starttls()
|
||||
smtp_server.login(username, password)
|
||||
smtp_server.sendmail(from_email, to_emails, msg.as_string())
|
||||
return True
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_send)
|
||||
except Exception as e:
|
||||
logger.warning(f"Sending Email failed. {e!s}")
|
||||
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)
|
||||
|
||||
|
||||
async def dispatch_webhook(payment: Payment):
|
||||
"""
|
||||
@@ -294,6 +311,27 @@ def send_payment_notification_in_background(wallet: Wallet, payment: Payment):
|
||||
logger.warning(f"Error sending payment notification: {e}")
|
||||
|
||||
|
||||
async def send_notification_in_background(
|
||||
telegram_chat_id: str | None,
|
||||
nostr_identifiers: list[str] | None,
|
||||
email_addresses: list[str] | None,
|
||||
message: str,
|
||||
message_type: str | None = None,
|
||||
):
|
||||
try:
|
||||
create_task(
|
||||
send_notification(
|
||||
telegram_chat_id,
|
||||
nostr_identifiers,
|
||||
email_addresses,
|
||||
message,
|
||||
message_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error sending notification in background: {e}")
|
||||
|
||||
|
||||
async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
|
||||
# TODO: websocket message should be a clean payment model
|
||||
# await websocket_manager.send(wallet.inkey, payment.json())
|
||||
|
||||
@@ -13,13 +13,13 @@ from lnbits.core.crud.payments import get_daily_stats
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import PaymentDailyStats, PaymentFilters
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import handle_fiat_payment_confirmation
|
||||
from lnbits.db import Connection, Filters
|
||||
from lnbits.decorators import check_user_extension_access
|
||||
from lnbits.exceptions import InvoiceError, PaymentError, UnsupportedError
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.helpers import check_callback_url
|
||||
from lnbits.helpers import check_callback_url, daystart_timestamp
|
||||
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
|
||||
@@ -64,6 +64,7 @@ async def pay_invoice(
|
||||
description: str = "",
|
||||
tag: str = "",
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if settings.lnbits_only_allow_incoming_payments:
|
||||
@@ -97,6 +98,7 @@ async def pay_invoice(
|
||||
memo=description or invoice.description or "",
|
||||
extra=extra,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
@@ -169,15 +171,15 @@ async def create_fiat_invoice(
|
||||
|
||||
internal_payment.fiat_provider = fiat_provider_name
|
||||
internal_payment.extra["fiat_checking_id"] = fiat_invoice.checking_id
|
||||
# todo: move to payent
|
||||
# TODO: move to payment
|
||||
internal_payment.extra["fiat_payment_request"] = fiat_invoice.payment_request
|
||||
new_checking_id = (
|
||||
f"fiat_{fiat_provider_name}_"
|
||||
f"{fiat_invoice.checking_id or internal_payment.checking_id}"
|
||||
)
|
||||
await update_payment(internal_payment, new_checking_id, conn=conn)
|
||||
internal_payment.checking_id = new_checking_id
|
||||
|
||||
internal_payment = await update_payment(
|
||||
internal_payment, new_checking_id, conn=conn
|
||||
)
|
||||
return internal_payment
|
||||
|
||||
|
||||
@@ -213,10 +215,12 @@ async def create_wallet_invoice(wallet_id: str, data: CreateInvoice) -> Payment:
|
||||
unhashed_description=unhashed_description,
|
||||
expiry=data.expiry,
|
||||
extra=data.extra,
|
||||
extension=data.extension,
|
||||
webhook=data.webhook,
|
||||
internal=data.internal,
|
||||
payment_hash=data.payment_hash,
|
||||
labels=data.labels,
|
||||
external_id=data.external_id,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
@@ -257,7 +261,9 @@ async def create_invoice(
|
||||
webhook: str | None = None,
|
||||
internal: bool | None = False,
|
||||
payment_hash: str | None = None,
|
||||
extension: str | None = None,
|
||||
labels: list[str] | None = None,
|
||||
external_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Payment:
|
||||
if not amount > 0:
|
||||
@@ -339,9 +345,11 @@ async def create_invoice(
|
||||
expiry=invoice.expiry_date,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
extension=extension,
|
||||
webhook=webhook,
|
||||
fee=invoice_response.fee_msat or 0,
|
||||
labels=labels,
|
||||
external_id=external_id,
|
||||
)
|
||||
|
||||
payment = await create_payment(
|
||||
@@ -366,10 +374,17 @@ async def update_pending_payments(wallet_id: str):
|
||||
async def update_pending_payment(
|
||||
payment: Payment, conn: Connection | None = None
|
||||
) -> Payment:
|
||||
if payment.is_in and payment.is_expired:
|
||||
payment.status = PaymentState.FAILED
|
||||
payment.labels.append("expired")
|
||||
await update_payment(payment, conn=conn)
|
||||
logger.info(f"invoice {payment.checking_id} expired, marked as failed")
|
||||
return payment
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
if status.failed:
|
||||
payment.status = PaymentState.FAILED
|
||||
await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
elif status.success:
|
||||
payment = await update_payment_success_status(payment, status, conn=conn)
|
||||
return payment
|
||||
@@ -509,9 +524,7 @@ async def update_wallet_balance(
|
||||
)
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment, conn=conn)
|
||||
from lnbits.tasks import internal_invoice_queue_put
|
||||
|
||||
await internal_invoice_queue_put(payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(payment)
|
||||
|
||||
|
||||
async def check_wallet_limits(
|
||||
@@ -551,10 +564,9 @@ async def check_wallet_daily_withdraw_limit(
|
||||
raise ValueError("It is not allowed to spend funds from this server.")
|
||||
|
||||
payments = await get_payments(
|
||||
since=int(time.time()) - 60 * 60 * 24,
|
||||
since=daystart_timestamp(),
|
||||
outgoing=True,
|
||||
wallet_id=wallet_id,
|
||||
limit=1,
|
||||
conn=conn,
|
||||
)
|
||||
if len(payments) == 0:
|
||||
@@ -625,18 +637,14 @@ async def check_transaction_status(
|
||||
return await check_payment_status(payment)
|
||||
|
||||
|
||||
async def check_payment_status(
|
||||
payment: Payment, skip_internal_payment_notifications: bool | None = False
|
||||
) -> PaymentStatus:
|
||||
async def check_payment_status(payment: Payment) -> PaymentStatus:
|
||||
if payment.is_internal:
|
||||
if payment.success:
|
||||
return PaymentSuccessStatus()
|
||||
if payment.failed:
|
||||
return PaymentFailedStatus()
|
||||
if payment.is_in and payment.fiat_provider:
|
||||
fiat_status = await check_fiat_status(
|
||||
payment, skip_internal_payment_notifications
|
||||
)
|
||||
fiat_status = await check_fiat_status(payment)
|
||||
return PaymentStatus(paid=fiat_status.paid)
|
||||
return PaymentPendingStatus()
|
||||
funding_source = get_funding_source()
|
||||
@@ -778,13 +786,16 @@ async def _pay_internal_invoice(
|
||||
await update_payment(internal_payment, conn=conn)
|
||||
logger.success(f"internal payment successful {internal_payment.checking_id}")
|
||||
|
||||
await _send_payment_notification_in_background(wallet.id, payment, conn=conn)
|
||||
|
||||
# notify receiver asynchronously
|
||||
from lnbits.tasks import internal_invoice_queue
|
||||
await _send_payment_notification_in_background(
|
||||
wallet.id, payment, conn=conn
|
||||
) # notify the sender
|
||||
await _send_payment_notification_in_background(
|
||||
internal_payment.wallet_id, internal_payment, conn=conn
|
||||
) # notify the receiver
|
||||
|
||||
# notify receiver asynchronously (extension listeners)
|
||||
logger.debug(f"enqueuing internal invoice {internal_payment.checking_id}")
|
||||
await internal_invoice_queue.put(internal_payment.checking_id)
|
||||
task_manager.internal_invoice_queue.put_nowait(internal_payment)
|
||||
|
||||
return payment
|
||||
|
||||
@@ -821,16 +832,15 @@ async def _pay_external_invoice(
|
||||
|
||||
fee_reserve_msat = fee_reserve(amount_msat, internal=False)
|
||||
|
||||
from lnbits.tasks import create_task
|
||||
|
||||
task = create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat)
|
||||
task = task_manager.create_task(
|
||||
_fundingsource_pay_invoice(checking_id, payment.bolt11, fee_reserve_msat),
|
||||
f"fundingsource_pay_invoice_{checking_id}",
|
||||
)
|
||||
|
||||
# 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, timeout=wait_time)
|
||||
payment_response = await asyncio.wait_for(task.task, timeout=wait_time)
|
||||
except asyncio.TimeoutError:
|
||||
# return pending payment on timeout
|
||||
logger.debug(
|
||||
@@ -871,7 +881,7 @@ async def update_payment_success_status(
|
||||
payment.status = PaymentState.SUCCESS
|
||||
payment.fee = -(abs(status.fee_msat or 0) + abs(service_fee_msat))
|
||||
payment.preimage = payment.preimage or status.preimage
|
||||
await update_payment(payment, conn=conn)
|
||||
payment = await update_payment(payment, conn=conn)
|
||||
return payment
|
||||
|
||||
|
||||
@@ -1074,28 +1084,44 @@ async def _send_payment_notification_in_background(
|
||||
send_payment_notification_in_background(wallet, payment)
|
||||
|
||||
|
||||
async def update_invoice_callback(checking_id: str) -> Payment | None:
|
||||
async def update_invoice_from_paid_invoices_stream(checking_id: str) -> Payment | None:
|
||||
"""
|
||||
Takes a checking_id of an incoming payment, from either paid_invoices_stream()
|
||||
or internal_invoice_queue. Checks its status, updates and returns it.
|
||||
returns None if no payment was found or it not and incoming payment.
|
||||
Takes a checking_id of an incoming payment from paid_invoices_stream()
|
||||
Checks its status, updates its status and returns it.
|
||||
returns None if no incoming payment was found or the status is not successful
|
||||
"""
|
||||
payment = await get_standalone_payment(checking_id, incoming=True)
|
||||
if not payment:
|
||||
logger.warning(f"No payment found for '{checking_id}'.")
|
||||
logger.warning(f"No incoming payment found for '{checking_id}'.")
|
||||
return None
|
||||
if not payment.is_in:
|
||||
logger.warning(f"Payment '{checking_id}' is not incoming, skipping.")
|
||||
|
||||
status = await check_payment_status(payment)
|
||||
|
||||
if not status.success:
|
||||
logger.error(
|
||||
"Unexpected status response from paid_invoices_stream. Skipping update."
|
||||
)
|
||||
return None
|
||||
|
||||
status = await check_payment_status(
|
||||
payment, skip_internal_payment_notifications=True
|
||||
)
|
||||
payment.fee = status.fee_msat or payment.fee
|
||||
# only overwrite preimage if status.preimage provides it
|
||||
payment.preimage = status.preimage or payment.preimage
|
||||
payment.status = PaymentState.SUCCESS
|
||||
await update_payment(payment)
|
||||
if payment.fiat_provider:
|
||||
await handle_fiat_payment_confirmation(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)
|
||||
|
||||
+47
-118
@@ -2,76 +2,43 @@ import asyncio
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import (
|
||||
create_audit_entry,
|
||||
get_wallet,
|
||||
)
|
||||
from lnbits.core.crud.audit import delete_expired_audit_entries
|
||||
from lnbits.core.crud import create_audit_entry
|
||||
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.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 (
|
||||
check_balance_delta_changed,
|
||||
check_server_balance_against_node,
|
||||
get_balance_delta,
|
||||
)
|
||||
from lnbits.core.services.funding_source import 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.exchange_rates import btc_rates
|
||||
from lnbits.utils.cache import cache
|
||||
from lnbits.utils.exchange_rates import btc_price_from_aggregator, btc_rates
|
||||
|
||||
audit_queue: asyncio.Queue[AuditEntry] = asyncio.Queue()
|
||||
|
||||
|
||||
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 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 _notify_server_status() -> None:
|
||||
async def refresh_extension_cache() -> None:
|
||||
# only refreshes every 10 minutes
|
||||
await InstallableExtension.get_installable_extensions()
|
||||
|
||||
|
||||
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,
|
||||
@@ -88,76 +55,38 @@ 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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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))
|
||||
settings.append_exchange_rate_datapoint(dict(rates), max_history_size)
|
||||
except Exception as ex:
|
||||
logger.warning(ex)
|
||||
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
|
||||
)
|
||||
else:
|
||||
sleep_time = 60
|
||||
await asyncio.sleep(sleep_time)
|
||||
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)
|
||||
|
||||
@@ -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.tasks import invoice_listeners
|
||||
from lnbits.task_manager import PublicTask, task_manager
|
||||
|
||||
from .. import core_app_extra
|
||||
from ..crud import get_admin_settings, reset_core_settings, update_admin_settings
|
||||
@@ -44,11 +44,10 @@ 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():
|
||||
return {
|
||||
"invoice_listeners": list(invoice_listeners.keys()),
|
||||
}
|
||||
async def api_monitor() -> list[PublicTask]:
|
||||
return task_manager.get_public_tasks()
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
|
||||
@@ -36,6 +36,7 @@ from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
check_admin,
|
||||
check_user_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
from lnbits.helpers import (
|
||||
create_access_token,
|
||||
@@ -320,7 +321,10 @@ async def api_delete_user_api_token(
|
||||
|
||||
@auth_router.get("/{provider}", description="SSO Provider")
|
||||
async def login_with_sso_provider(
|
||||
request: Request, provider: str, user_id: str | None = None
|
||||
request: Request,
|
||||
provider: str,
|
||||
user_id: str | None,
|
||||
auth_user_id: str | None = Depends(optional_user_id),
|
||||
):
|
||||
provider_sso = _new_sso(provider)
|
||||
if not provider_sso:
|
||||
@@ -328,6 +332,8 @@ async def login_with_sso_provider(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Login by '{provider}' not allowed.",
|
||||
)
|
||||
if user_id and user_id != auth_user_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "User ID mismatch.")
|
||||
|
||||
provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
|
||||
with provider_sso:
|
||||
|
||||
@@ -4,17 +4,30 @@ from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.payments import (
|
||||
get_payments,
|
||||
get_standalone_payment,
|
||||
update_payment,
|
||||
)
|
||||
from lnbits.core.models import Payment, PaymentFilters
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.fiat_providers import (
|
||||
check_fiat_status,
|
||||
check_revolut_signature,
|
||||
check_square_signature,
|
||||
check_stripe_signature,
|
||||
verify_paypal_webhook,
|
||||
)
|
||||
from lnbits.core.services.payments import create_fiat_invoice
|
||||
from lnbits.core.services.payments import (
|
||||
create_fiat_invoice,
|
||||
create_wallet_invoice,
|
||||
service_fee_fiat,
|
||||
)
|
||||
from lnbits.db import Filter, Filters
|
||||
from lnbits.fiat import get_fiat_provider
|
||||
from lnbits.fiat.base import FiatSubscriptionPaymentOptions
|
||||
from lnbits.fiat.revolut import RevolutWallet
|
||||
from lnbits.fiat.square import SquareWallet
|
||||
from lnbits.settings import settings
|
||||
|
||||
callback_router = APIRouter(prefix="/api/v1/callback", tags=["callback"])
|
||||
@@ -50,6 +63,41 @@ async def api_generic_webhook_handler(
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "square":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("x-square-hmacsha256-signature")
|
||||
check_square_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
settings.square_webhook_signature_key,
|
||||
settings.square_payment_webhook_url,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_square_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
if provider_name.lower() == "revolut":
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("Revolut-Signature")
|
||||
timestamp_header = request.headers.get("Revolut-Request-Timestamp")
|
||||
check_revolut_signature(
|
||||
payload,
|
||||
sig_header,
|
||||
timestamp_header,
|
||||
settings.revolut_webhook_signing_secret,
|
||||
)
|
||||
event = await request.json()
|
||||
await handle_revolut_event(event)
|
||||
|
||||
return SimpleStatus(
|
||||
success=True,
|
||||
message=f"Callback received successfully from '{provider_name}'.",
|
||||
)
|
||||
|
||||
return SimpleStatus(
|
||||
success=False,
|
||||
message=f"Unknown fiat provider '{provider_name}'.",
|
||||
@@ -280,3 +328,382 @@ def _deserialize_paypal_metadata(custom_id: str) -> FiatSubscriptionPaymentOptio
|
||||
except (json.JSONDecodeError, IndexError) as e:
|
||||
logger.warning(f"Failed to deserialize PayPal metadata: {e}")
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
|
||||
|
||||
async def handle_square_event(event: dict):
|
||||
event_id = event.get("event_id") or event.get("id", "")
|
||||
event_type = event.get("type", "")
|
||||
logger.info(f"Handling Square event: '{event_id}'. Type: '{event_type}'.")
|
||||
|
||||
if event_type == "payment.updated":
|
||||
await _handle_square_payment_event(event)
|
||||
return
|
||||
|
||||
if event_type == "invoice.payment_made":
|
||||
await _handle_square_invoice_payment_made(event)
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Square event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def handle_revolut_event(event: dict):
|
||||
event_type = event.get("event", "")
|
||||
order_id = event.get("order_id")
|
||||
logger.info(f"Handling Revolut event: '{event_type}'. Order ID: '{order_id}'.")
|
||||
|
||||
if event_type in ["ORDER_AUTHORISED", "ORDER_COMPLETED"]:
|
||||
if not order_id:
|
||||
logger.warning("Revolut event missing order_id.")
|
||||
return
|
||||
|
||||
payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if payment:
|
||||
await check_fiat_status(payment)
|
||||
return
|
||||
|
||||
if event_type == "ORDER_COMPLETED":
|
||||
logger.warning(f"No payment found for Revolut order: '{order_id}'.")
|
||||
await _handle_revolut_subscription_order_paid(order_id)
|
||||
return
|
||||
|
||||
logger.info(f"Ignoring Revolut authorised order without payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
if event_type == "SUBSCRIPTION_INITIATED":
|
||||
logger.info("Revolut subscription initiated event received.")
|
||||
return
|
||||
|
||||
if event_type in [
|
||||
"SUBSCRIPTION_CANCELLED",
|
||||
"SUBSCRIPTION_FINISHED",
|
||||
"SUBSCRIPTION_OVERDUE",
|
||||
]:
|
||||
logger.info(f"Revolut subscription lifecycle event received: '{event_type}'.")
|
||||
return
|
||||
|
||||
logger.warning(f"Unhandled Revolut event type: '{event_type}'.")
|
||||
|
||||
|
||||
async def _get_revolut_provider() -> RevolutWallet | None:
|
||||
fiat_provider = await get_fiat_provider("revolut")
|
||||
if not isinstance(fiat_provider, RevolutWallet):
|
||||
logger.warning("Revolut fiat provider is not configured.")
|
||||
return None
|
||||
return fiat_provider
|
||||
|
||||
|
||||
async def _handle_revolut_subscription(
|
||||
subscription: dict,
|
||||
fiat_provider: RevolutWallet,
|
||||
order_id: str | None = None,
|
||||
order: dict | None = None,
|
||||
):
|
||||
subscription_id = subscription.get("id")
|
||||
if not subscription_id:
|
||||
logger.warning("Revolut subscription missing id.")
|
||||
return
|
||||
|
||||
reference = fiat_provider.deserialize_subscription_reference(
|
||||
subscription.get("external_reference")
|
||||
)
|
||||
if not reference:
|
||||
logger.warning("Revolut subscription event missing LNbits metadata.")
|
||||
return
|
||||
|
||||
if not order_id:
|
||||
cycle_id = subscription.get("current_cycle_id")
|
||||
if not cycle_id:
|
||||
logger.warning("Revolut subscription missing current_cycle_id.")
|
||||
return
|
||||
|
||||
cycle = await fiat_provider.get_subscription_cycle(subscription_id, cycle_id)
|
||||
order_id = cycle.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Revolut subscription cycle missing order_id.")
|
||||
return
|
||||
|
||||
existing_payment = await get_standalone_payment(f"fiat_revolut_order_{order_id}")
|
||||
if existing_payment:
|
||||
if existing_payment.external_id != subscription_id:
|
||||
existing_payment.external_id = subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
if not order:
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
amount_minor = order.get("amount")
|
||||
currency = (order.get("currency") or "").upper()
|
||||
if amount_minor is None or not currency:
|
||||
raise ValueError("Revolut subscription order missing amount or currency.")
|
||||
|
||||
extra = {
|
||||
**(reference.extra or {}),
|
||||
"subscription_request_id": subscription_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": reference.tag,
|
||||
"subscription": {
|
||||
"checking_id": f"order_{order_id}",
|
||||
"payment_request": order.get("checkout_url") or "",
|
||||
},
|
||||
}
|
||||
lnbits_payment = await _create_revolut_subscription_payment(
|
||||
wallet_id=reference.wallet_id,
|
||||
amount_minor=amount_minor,
|
||||
currency=currency,
|
||||
memo=reference.memo or "",
|
||||
extra=extra,
|
||||
order_id=order_id,
|
||||
payment_request=order.get("checkout_url") or "",
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_revolut_subscription_order_paid(order_id: str):
|
||||
fiat_provider = await _get_revolut_provider()
|
||||
if not fiat_provider:
|
||||
return
|
||||
|
||||
order = await fiat_provider.get_order(order_id)
|
||||
order_type = (order.get("type") or "").lower()
|
||||
order_state = (order.get("state") or "").upper()
|
||||
if order_type != "payment" or order_state != "COMPLETED":
|
||||
logger.warning(f"Revolut order is not a completed payment: '{order_id}'.")
|
||||
return
|
||||
|
||||
channel_data = order.get("channel_data") or {}
|
||||
subscription_id = channel_data.get("subscription_id")
|
||||
if not subscription_id:
|
||||
logger.warning(f"Revolut order missing subscription_id: '{order_id}'.")
|
||||
return
|
||||
|
||||
subscription = await fiat_provider.get_subscription(subscription_id)
|
||||
if subscription.get("state") != "active":
|
||||
logger.warning(f"Revolut subscription is not active: '{subscription_id}'.")
|
||||
return
|
||||
|
||||
await _handle_revolut_subscription(
|
||||
subscription, fiat_provider, order_id=order_id, order=order
|
||||
)
|
||||
|
||||
|
||||
async def _create_revolut_subscription_payment(
|
||||
wallet_id: str,
|
||||
amount_minor: int,
|
||||
currency: str,
|
||||
memo: str,
|
||||
extra: dict,
|
||||
order_id: str,
|
||||
payment_request: str,
|
||||
subscription_id: str,
|
||||
) -> Payment:
|
||||
amount = RevolutWallet.minor_units_to_amount(amount_minor, currency)
|
||||
payment = await create_wallet_invoice(
|
||||
wallet_id,
|
||||
CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount,
|
||||
memo=memo,
|
||||
extra=extra,
|
||||
internal=True,
|
||||
external_id=subscription_id,
|
||||
),
|
||||
)
|
||||
payment.fee = -abs(service_fee_fiat(payment.msat, "revolut"))
|
||||
payment.fiat_provider = "revolut"
|
||||
payment.extra["fiat_checking_id"] = f"order_{order_id}"
|
||||
payment.extra["fiat_payment_request"] = payment_request
|
||||
checking_id = f"fiat_revolut_order_{order_id}"
|
||||
await update_payment(payment, checking_id)
|
||||
payment.checking_id = checking_id
|
||||
return payment
|
||||
|
||||
|
||||
async def _handle_square_payment_event(event: dict):
|
||||
payment = _square_extract_payment(event)
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if payment_options.wallet_id:
|
||||
if not _square_payment_is_completed(payment):
|
||||
logger.debug("Square subscription payment is not completed yet.")
|
||||
return
|
||||
await _handle_square_subscription_payment(payment, payment_options)
|
||||
return
|
||||
|
||||
order_id = payment.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square payment event missing order_id.")
|
||||
return
|
||||
|
||||
lnbits_payment = await get_standalone_payment(f"fiat_square_order_{order_id}")
|
||||
if not lnbits_payment:
|
||||
logger.warning(f"No payment found for Square order: '{order_id}'.")
|
||||
return
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
async def _handle_square_invoice_payment_made(event: dict):
|
||||
invoice = event.get("data", {}).get("object", {}).get("invoice") or {}
|
||||
order_id = invoice.get("order_id")
|
||||
if not order_id:
|
||||
logger.warning("Square invoice.payment_made event missing order_id.")
|
||||
return
|
||||
subscription_id = invoice.get("subscription_id")
|
||||
|
||||
fiat_provider = await get_fiat_provider("square")
|
||||
if not isinstance(fiat_provider, SquareWallet):
|
||||
logger.warning("Square fiat provider is not configured.")
|
||||
return
|
||||
|
||||
payment = await fiat_provider.get_payment_for_order(order_id)
|
||||
if not payment:
|
||||
logger.warning(f"No Square payment found for invoice order: '{order_id}'.")
|
||||
return
|
||||
|
||||
payment_options = _deserialize_square_metadata(_square_payment_note(payment))
|
||||
if not payment_options.wallet_id:
|
||||
payment_id = payment.get("id")
|
||||
stored_payment = (
|
||||
await get_standalone_payment(f"fiat_square_payment_{payment_id}")
|
||||
if payment_id
|
||||
else None
|
||||
)
|
||||
if not stored_payment and subscription_id:
|
||||
stored_payments = await get_payments(
|
||||
filters=Filters(
|
||||
filters=[
|
||||
Filter.parse_query(
|
||||
"external_id", [subscription_id], PaymentFilters
|
||||
)
|
||||
],
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
limit=1,
|
||||
)
|
||||
)
|
||||
stored_payment = stored_payments[0] if stored_payments else None
|
||||
if stored_payment:
|
||||
payment_options = _square_payment_options_from_payment(stored_payment)
|
||||
else:
|
||||
logger.warning("Square subscription payment missing LNbits metadata.")
|
||||
return
|
||||
|
||||
await _handle_square_subscription_payment(
|
||||
payment,
|
||||
payment_options,
|
||||
invoice.get("public_url") or "",
|
||||
square_subscription_id=subscription_id,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_square_subscription_payment(
|
||||
payment: dict,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
payment_request: str = "",
|
||||
square_subscription_id: str | None = None,
|
||||
):
|
||||
amount_money = payment.get("amount_money") or {}
|
||||
amount = amount_money.get("amount")
|
||||
currency = (amount_money.get("currency") or "").upper()
|
||||
payment_id = payment.get("id")
|
||||
if amount is None or not currency or not payment_id:
|
||||
raise ValueError("Square subscription payment event missing payment amount.")
|
||||
wallet_id = payment_options.wallet_id
|
||||
if not wallet_id:
|
||||
raise ValueError("Square subscription payment event missing wallet_id.")
|
||||
|
||||
checking_id = f"payment_{payment_id}"
|
||||
existing_payment = await get_standalone_payment(f"fiat_square_{checking_id}")
|
||||
if existing_payment:
|
||||
if (
|
||||
square_subscription_id
|
||||
and existing_payment.external_id != square_subscription_id
|
||||
):
|
||||
existing_payment.external_id = square_subscription_id
|
||||
await update_payment(existing_payment)
|
||||
await check_fiat_status(existing_payment)
|
||||
return
|
||||
|
||||
square_subscription_id = square_subscription_id or (
|
||||
payment_options.extra or {}
|
||||
).get("square_subscription_id")
|
||||
extra = {
|
||||
**(payment_options.extra or {}),
|
||||
"subscription_request_id": payment_options.subscription_request_id,
|
||||
"fiat_method": "subscription",
|
||||
"tag": payment_options.tag,
|
||||
"subscription": {
|
||||
"checking_id": checking_id,
|
||||
"payment_request": payment_request,
|
||||
},
|
||||
}
|
||||
|
||||
lnbits_payment = await create_fiat_invoice(
|
||||
wallet_id=wallet_id,
|
||||
invoice_data=CreateInvoice(
|
||||
unit=currency,
|
||||
amount=amount / 100,
|
||||
memo=payment_options.memo or "",
|
||||
extra=extra,
|
||||
fiat_provider="square",
|
||||
external_id=square_subscription_id,
|
||||
),
|
||||
)
|
||||
|
||||
await check_fiat_status(lnbits_payment)
|
||||
|
||||
|
||||
def _square_payment_options_from_payment(
|
||||
payment: Payment,
|
||||
) -> FiatSubscriptionPaymentOptions:
|
||||
extra = payment.extra or {}
|
||||
return FiatSubscriptionPaymentOptions(
|
||||
wallet_id=payment.wallet_id,
|
||||
tag=extra.get("tag") or payment.tag,
|
||||
subscription_request_id=extra.get("subscription_request_id"),
|
||||
extra=extra,
|
||||
memo=payment.memo,
|
||||
)
|
||||
|
||||
|
||||
def _square_extract_payment(event: dict) -> dict:
|
||||
event_object = event.get("data", {}).get("object", {})
|
||||
return event_object.get("payment") or event_object
|
||||
|
||||
|
||||
def _square_payment_is_completed(payment: dict) -> bool:
|
||||
return (payment.get("status") or "").upper() == "COMPLETED"
|
||||
|
||||
|
||||
def _square_payment_note(payment: dict) -> str:
|
||||
return payment.get("note") or payment.get("payment_note") or ""
|
||||
|
||||
|
||||
def _deserialize_square_metadata(custom_id: str) -> FiatSubscriptionPaymentOptions:
|
||||
try:
|
||||
meta = json.loads(custom_id)
|
||||
if not isinstance(meta, list):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
wallet_id = meta[0] if len(meta) > 0 else None
|
||||
tag = meta[1] if len(meta) > 1 else None
|
||||
subscription_request_id = meta[2] if len(meta) > 2 else None
|
||||
extra_link = meta[3] if len(meta) > 3 else None
|
||||
memo = meta[4] if len(meta) > 4 else None
|
||||
|
||||
extra = {
|
||||
"link": extra_link,
|
||||
"subscription_request_id": subscription_request_id,
|
||||
}
|
||||
|
||||
return FiatSubscriptionPaymentOptions(
|
||||
wallet_id=wallet_id,
|
||||
tag=tag,
|
||||
subscription_request_id=subscription_request_id,
|
||||
extra=extra,
|
||||
memo=memo,
|
||||
)
|
||||
except (json.JSONDecodeError, IndexError, TypeError):
|
||||
return FiatSubscriptionPaymentOptions()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import sys
|
||||
import traceback
|
||||
from http import HTTPStatus
|
||||
@@ -9,7 +10,7 @@ from fastapi.requests import Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallets_ids
|
||||
from lnbits.core.crud.wallets import get_wallet, get_wallets_ids
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models import (
|
||||
SimpleStatus,
|
||||
@@ -18,28 +19,50 @@ from lnbits.core.models.extensions import (
|
||||
CreateExtension,
|
||||
CreateExtensionReview,
|
||||
Extension,
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionBackgroundPaymentGrant,
|
||||
ExtensionBackgroundPaymentGrantRequest,
|
||||
ExtensionConfig,
|
||||
ExtensionMeta,
|
||||
ExtensionPermissionCheckRequest,
|
||||
ExtensionPermissionCheckResponse,
|
||||
ExtensionPermissionCheckResult,
|
||||
ExtensionPermissionsResponse,
|
||||
ExtensionRelease,
|
||||
ExtensionReview,
|
||||
ExtensionReviewPaymentRequest,
|
||||
ExtensionReviewsStatus,
|
||||
ExtensionWalletPaymentsWatchGrant,
|
||||
ExtensionWalletPaymentsWatchGrantRequest,
|
||||
InstallableExtension,
|
||||
PayToEnableInfo,
|
||||
ReleasePaymentInfo,
|
||||
UserExtension,
|
||||
UserExtensionInfo,
|
||||
WasmInvocation,
|
||||
WasmInvocationStats,
|
||||
WasmRuntimeLimitsInfo,
|
||||
WasmRuntimeLimitsUpdate,
|
||||
wasm_extension_icon_url,
|
||||
)
|
||||
from lnbits.core.models.users import Account, AccountId
|
||||
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
|
||||
from lnbits.decorators import (
|
||||
check_account_exists,
|
||||
@@ -66,6 +89,9 @@ extension_router = APIRouter(
|
||||
prefix="/api/v1/extension",
|
||||
)
|
||||
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
|
||||
|
||||
|
||||
@extension_router.post("", dependencies=[Depends(check_admin)])
|
||||
async def api_install_extension(data: CreateExtension):
|
||||
@@ -89,7 +115,9 @@ async def api_install_extension(data: CreateExtension):
|
||||
)
|
||||
|
||||
try:
|
||||
extension = await install_extension(ext_info)
|
||||
extension = await install_extension(
|
||||
ext_info, granted_permissions=data.permissions
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
@@ -128,6 +156,106 @@ 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,
|
||||
@@ -251,6 +379,213 @@ async def api_disable_extension(
|
||||
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/background-payment")
|
||||
async def api_grant_background_payment_permission(
|
||||
ext_id: str,
|
||||
data: ExtensionBackgroundPaymentGrantRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
if WALLET_PAY_INVOICE_BACKGROUND_PERMISSION not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request background payments.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Background payments are not allowed from shared wallets.",
|
||||
)
|
||||
if not wallet.can_send_payments:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This wallet cannot send payments.",
|
||||
)
|
||||
|
||||
permissions = user_ext.permissions or {}
|
||||
grant = data.to_grant(
|
||||
_user_permission_grant_id_for_wallet(
|
||||
permissions,
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
data.wallet_id,
|
||||
)
|
||||
)
|
||||
background_grants = [
|
||||
existing
|
||||
for existing in permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, [])
|
||||
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
|
||||
]
|
||||
background_grants.append(json.loads(grant.json()))
|
||||
permissions[WALLET_PAY_INVOICE_BACKGROUND_PERMISSION] = background_grants
|
||||
user_ext.permissions = permissions
|
||||
await update_user_extension(user_ext)
|
||||
return {"permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION, "grant": grant}
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/wallet-payments-watch")
|
||||
async def api_grant_wallet_payments_watch_permission(
|
||||
ext_id: str,
|
||||
data: ExtensionWalletPaymentsWatchGrantRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> dict:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
if WALLET_PAYMENTS_WATCH_PERMISSION not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request wallet payment watch access.",
|
||||
)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id.id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
permissions = user_ext.permissions or {}
|
||||
grant = data.to_grant(
|
||||
_user_permission_grant_id_for_wallet(
|
||||
permissions,
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
data.wallet_id,
|
||||
)
|
||||
)
|
||||
watch_grants = [
|
||||
existing
|
||||
for existing in permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION, [])
|
||||
if isinstance(existing, dict) and existing.get("wallet_id") != grant.wallet_id
|
||||
]
|
||||
watch_grants.append(json.loads(grant.json()))
|
||||
permissions[WALLET_PAYMENTS_WATCH_PERMISSION] = watch_grants
|
||||
user_ext.permissions = permissions
|
||||
await update_user_extension(user_ext)
|
||||
return {"permission": WALLET_PAYMENTS_WATCH_PERMISSION, "grant": grant}
|
||||
|
||||
|
||||
@extension_router.get("/{ext_id}/permissions")
|
||||
async def api_get_extension_permissions(
|
||||
ext_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> ExtensionPermissionsResponse:
|
||||
installed_ext = await _require_active_wasm_extension(ext_id)
|
||||
extension_permissions = validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
return ExtensionPermissionsResponse(
|
||||
extension_permissions=extension_permissions,
|
||||
user_permissions=_safe_user_extension_permissions(
|
||||
user_ext.permissions if user_ext else {}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@extension_router.delete("/{ext_id}/permissions/user/{grant_id}")
|
||||
async def api_delete_user_extension_permission(
|
||||
ext_id: str,
|
||||
grant_id: str,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> SimpleStatus:
|
||||
await _require_active_wasm_extension(ext_id)
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext:
|
||||
return SimpleStatus(success=True, message="Permission grant removed.")
|
||||
|
||||
user_ext.permissions = _remove_user_permission_grant(
|
||||
user_ext.permissions or {}, grant_id
|
||||
)
|
||||
await update_user_extension(user_ext)
|
||||
return SimpleStatus(success=True, message="Permission grant removed.")
|
||||
|
||||
|
||||
@extension_router.post("/{ext_id}/permissions/check")
|
||||
async def api_check_extension_permissions(
|
||||
ext_id: str,
|
||||
data: ExtensionPermissionCheckRequest,
|
||||
account_id: AccountId = Depends(check_account_id_exists),
|
||||
) -> ExtensionPermissionCheckResponse:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
|
||||
installed_permission_ids = {
|
||||
permission.id for permission in installed_ext.permissions or []
|
||||
}
|
||||
|
||||
user_ext = await get_user_extension(account_id.id, ext_id)
|
||||
if not user_ext or not user_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' is not enabled for this user.",
|
||||
)
|
||||
|
||||
results: list[ExtensionPermissionCheckResult] = []
|
||||
for permission in data.permissions:
|
||||
if permission.id not in installed_permission_ids:
|
||||
raise HTTPException(
|
||||
HTTPStatus.FORBIDDEN,
|
||||
f"Extension '{ext_id}' cannot request '{permission.id}'.",
|
||||
)
|
||||
if permission.id == WALLET_PAY_INVOICE_BACKGROUND_PERMISSION:
|
||||
results.append(
|
||||
await _check_background_payment_permission(
|
||||
account_id.id,
|
||||
user_ext.permissions or {},
|
||||
permission.grant,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if permission.id == WALLET_PAYMENTS_WATCH_PERMISSION:
|
||||
results.append(
|
||||
await _check_wallet_payments_watch_permission(
|
||||
account_id.id,
|
||||
user_ext.permissions or {},
|
||||
permission.grant,
|
||||
)
|
||||
)
|
||||
continue
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Unsupported permission check '{permission.id}'.",
|
||||
)
|
||||
|
||||
return ExtensionPermissionCheckResponse(permissions=results)
|
||||
|
||||
|
||||
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
||||
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
||||
try:
|
||||
@@ -292,7 +627,6 @@ async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
@extension_router.delete("/{ext_id}", dependencies=[Depends(check_admin)])
|
||||
async def api_uninstall_extension(ext_id: str) -> SimpleStatus:
|
||||
|
||||
extension = await get_installed_extension(ext_id)
|
||||
if not extension:
|
||||
raise HTTPException(
|
||||
@@ -460,11 +794,19 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
|
||||
if not config:
|
||||
return {}
|
||||
|
||||
permissions = validate_extension_permissions(config.name, config.permissions)
|
||||
|
||||
return {
|
||||
"min_lnbits_version": config.min_lnbits_version,
|
||||
"is_version_compatible": config.is_version_compatible(),
|
||||
"warning": config.warning,
|
||||
"extension_type": config.extension_type,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
||||
@@ -536,9 +878,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
)
|
||||
installable_exts_ids = [e.id for e in installable_exts]
|
||||
installable_exts += [e for e in installed_exts if e.id not in installable_exts_ids]
|
||||
installed_exts_by_id = {e.id: e for e in installed_exts}
|
||||
|
||||
for e in installable_exts:
|
||||
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
|
||||
installed_ext = installed_exts_by_id.get(e.id)
|
||||
if installed_ext and installed_ext.meta:
|
||||
installed_release = installed_ext.meta.installed_release
|
||||
if installed_ext.meta.pay_to_enable and not account_id.is_admin_id:
|
||||
@@ -559,46 +902,60 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
|
||||
e.short_description = installed_ext.short_description
|
||||
e.icon = installed_ext.icon
|
||||
|
||||
extension_data = [
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": ext.icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
for ext in installable_exts
|
||||
]
|
||||
extension_data = []
|
||||
for ext in installable_exts:
|
||||
installed_ext = installed_exts_by_id.get(ext.id)
|
||||
is_wasm = installed_ext.is_wasm if installed_ext else ext.is_wasm
|
||||
icon = wasm_extension_icon_url(ext.id) if is_wasm else ext.icon
|
||||
permissions = (
|
||||
validate_extension_permissions(
|
||||
installed_ext.id, installed_ext.permissions, strict=False
|
||||
)
|
||||
if installed_ext
|
||||
else []
|
||||
)
|
||||
extension_data.append(
|
||||
{
|
||||
"id": ext.id,
|
||||
"name": ext.name,
|
||||
"icon": icon,
|
||||
"shortDescription": ext.short_description,
|
||||
"stars": ext.stars,
|
||||
"isFeatured": ext.meta.featured if ext.meta else False,
|
||||
"categories": ext.meta.categories if ext.meta else [],
|
||||
"dependencies": ext.meta.dependencies if ext.meta else "",
|
||||
"isInstalled": ext.id in installed_exts_ids,
|
||||
"hasDatabaseTables": next(
|
||||
(True for version in db_versions if version.db == ext.id), False
|
||||
),
|
||||
"isAvailable": ext.id in all_ext_ids,
|
||||
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
|
||||
"isActive": ext.id not in inactive_extensions,
|
||||
"latestRelease": (
|
||||
dict(ext.meta.latest_release)
|
||||
if ext.meta and ext.meta.latest_release
|
||||
else None
|
||||
),
|
||||
"hasPaidRelease": ext.meta.has_paid_release if ext.meta else False,
|
||||
"hasFreeRelease": ext.meta.has_free_release if ext.meta else False,
|
||||
"paidFeatures": ext.meta.paid_features if ext.meta else False,
|
||||
"installedRelease": (
|
||||
dict(ext.meta.installed_release)
|
||||
if ext.meta and ext.meta.installed_release
|
||||
else None
|
||||
),
|
||||
"payToEnable": (
|
||||
dict(ext.meta.pay_to_enable)
|
||||
if ext.meta and ext.meta.pay_to_enable
|
||||
else {}
|
||||
),
|
||||
"isPaymentRequired": ext.requires_payment,
|
||||
"isWasm": is_wasm,
|
||||
"permissions": [dict(permission) for permission in permissions],
|
||||
"inProgress": False,
|
||||
"selectedForUpdate": False,
|
||||
}
|
||||
)
|
||||
return extension_data
|
||||
|
||||
|
||||
@@ -646,3 +1003,178 @@ async def create_extension_review(
|
||||
resp.raise_for_status()
|
||||
payment_request = resp.json()
|
||||
return ExtensionReviewPaymentRequest(**payment_request)
|
||||
|
||||
|
||||
async def _require_active_wasm_extension(ext_id: str) -> InstallableExtension:
|
||||
installed_ext = await get_installed_extension(ext_id)
|
||||
if not installed_ext or not installed_ext.active:
|
||||
raise HTTPException(
|
||||
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' is not active."
|
||||
)
|
||||
if not installed_ext.is_wasm:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' is not a WASM extension."
|
||||
)
|
||||
return installed_ext
|
||||
|
||||
|
||||
def _safe_user_extension_permissions(permissions: dict | None) -> dict:
|
||||
safe_permissions: dict[str, list[dict]] = {}
|
||||
for permission_id, grants in (permissions or {}).items():
|
||||
if not isinstance(permission_id, str) or not isinstance(grants, list):
|
||||
continue
|
||||
safe_grants = [
|
||||
grant
|
||||
for grant in grants
|
||||
if isinstance(grant, dict) and isinstance(grant.get("id"), str)
|
||||
]
|
||||
if safe_grants:
|
||||
safe_permissions[permission_id] = safe_grants
|
||||
return safe_permissions
|
||||
|
||||
|
||||
def _user_permission_grant_id_for_wallet(
|
||||
permissions: dict, permission_id: str, wallet_id: str
|
||||
) -> str | None:
|
||||
grants = permissions.get(permission_id)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
|
||||
for grant in grants:
|
||||
if not isinstance(grant, dict) or grant.get("wallet_id") != wallet_id:
|
||||
continue
|
||||
grant_id = grant.get("id")
|
||||
return grant_id if isinstance(grant_id, str) and grant_id else None
|
||||
return None
|
||||
|
||||
|
||||
def _remove_user_permission_grant(permissions: dict, grant_id: str) -> dict:
|
||||
updated_permissions = dict(permissions or {})
|
||||
for permission_id, grants in list(updated_permissions.items()):
|
||||
if not isinstance(grants, list):
|
||||
continue
|
||||
|
||||
remaining_grants = [
|
||||
grant
|
||||
for grant in grants
|
||||
if not isinstance(grant, dict) or grant.get("id") != grant_id
|
||||
]
|
||||
if remaining_grants:
|
||||
updated_permissions[permission_id] = remaining_grants
|
||||
else:
|
||||
updated_permissions.pop(permission_id, None)
|
||||
return updated_permissions
|
||||
|
||||
|
||||
async def _check_background_payment_permission(
|
||||
account_id: str,
|
||||
permissions: dict,
|
||||
grant_data: dict,
|
||||
) -> ExtensionPermissionCheckResult:
|
||||
try:
|
||||
data = ExtensionBackgroundPaymentGrantRequest.parse_obj(grant_data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Background payments are not allowed from shared wallets.",
|
||||
)
|
||||
if not wallet.can_send_payments:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"This wallet cannot send payments.",
|
||||
)
|
||||
|
||||
requested_grant = data.to_grant()
|
||||
existing_grant = _find_background_payment_grant(
|
||||
permissions, requested_grant.wallet_id
|
||||
)
|
||||
covered = (
|
||||
existing_grant is not None
|
||||
and existing_grant.enabled
|
||||
and existing_grant.max_amount >= requested_grant.max_amount
|
||||
and _background_destination_policy_covers(
|
||||
existing_grant.destination_policy, requested_grant.destination_policy
|
||||
)
|
||||
)
|
||||
grant = existing_grant if covered and existing_grant else requested_grant
|
||||
return ExtensionPermissionCheckResult(
|
||||
id=WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
approved=covered,
|
||||
grant=json.loads(grant.json()),
|
||||
)
|
||||
|
||||
|
||||
async def _check_wallet_payments_watch_permission(
|
||||
account_id: str,
|
||||
permissions: dict,
|
||||
grant_data: dict,
|
||||
) -> ExtensionPermissionCheckResult:
|
||||
try:
|
||||
data = ExtensionWalletPaymentsWatchGrantRequest.parse_obj(grant_data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
||||
|
||||
wallet = await get_wallet(data.wallet_id)
|
||||
if not wallet or wallet.user != account_id:
|
||||
raise HTTPException(HTTPStatus.FORBIDDEN, "Not your wallet.")
|
||||
|
||||
existing_grant = _find_wallet_payments_watch_grant(permissions, data.wallet_id)
|
||||
covered = bool(existing_grant and existing_grant.enabled)
|
||||
grant = existing_grant if covered and existing_grant else data.to_grant()
|
||||
return ExtensionPermissionCheckResult(
|
||||
id=WALLET_PAYMENTS_WATCH_PERMISSION,
|
||||
approved=covered,
|
||||
grant=json.loads(grant.json()),
|
||||
)
|
||||
|
||||
|
||||
def _find_background_payment_grant(
|
||||
permissions: dict, wallet_id: str
|
||||
) -> ExtensionBackgroundPaymentGrant | None:
|
||||
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
def _find_wallet_payments_watch_grant(
|
||||
permissions: dict, wallet_id: str
|
||||
) -> ExtensionWalletPaymentsWatchGrant | None:
|
||||
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
return None
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
def _background_destination_policy_covers(
|
||||
existing: ExtensionBackgroundPaymentDestinationPolicy,
|
||||
requested: ExtensionBackgroundPaymentDestinationPolicy,
|
||||
) -> bool:
|
||||
return (
|
||||
existing == requested
|
||||
or existing == ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
|
||||
)
|
||||
|
||||
@@ -2,17 +2,35 @@ from http import HTTPStatus
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.crud.settings import set_settings_field
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.wallets import WalletTypeInfo
|
||||
from lnbits.core.services import update_cached_settings
|
||||
from lnbits.core.services.fiat_providers import test_connection
|
||||
from lnbits.decorators import check_admin, require_admin_key
|
||||
from lnbits.fiat import StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat import RevolutWallet, StripeWallet, get_fiat_provider
|
||||
from lnbits.fiat.base import CreateFiatSubscription, FiatSubscriptionResponse
|
||||
|
||||
fiat_router = APIRouter(tags=["Fiat API"], prefix="/api/v1/fiat")
|
||||
|
||||
|
||||
class RevolutCreateWebhook(BaseModel):
|
||||
url: str
|
||||
endpoint: str | None = None
|
||||
api_secret_key: str | None = None
|
||||
api_version: str | None = None
|
||||
|
||||
|
||||
class RevolutCreateWebhookResponse(BaseModel):
|
||||
id: str | None = None
|
||||
url: str
|
||||
events: list[str] = []
|
||||
signing_secret: str
|
||||
already_exists: bool = False
|
||||
|
||||
|
||||
@fiat_router.put(
|
||||
"/check/{provider}",
|
||||
status_code=HTTPStatus.OK,
|
||||
@@ -22,6 +40,54 @@ async def api_test_fiat_provider(provider: str) -> SimpleStatus:
|
||||
return await test_connection(provider)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/revolut/webhook",
|
||||
status_code=HTTPStatus.OK,
|
||||
dependencies=[Depends(check_admin)],
|
||||
)
|
||||
async def api_create_revolut_webhook(
|
||||
data: RevolutCreateWebhook,
|
||||
) -> RevolutCreateWebhookResponse:
|
||||
try:
|
||||
webhook = await RevolutWallet.create_webhook(
|
||||
url=data.url,
|
||||
endpoint=data.endpoint,
|
||||
api_secret_key=data.api_secret_key,
|
||||
api_version=data.api_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to create Revolut webhook."
|
||||
) from exc
|
||||
|
||||
signing_secret = webhook.get("signing_secret")
|
||||
webhook_url = webhook.get("url") or data.url
|
||||
if not signing_secret:
|
||||
raise HTTPException(
|
||||
status_code=502, detail="Revolut returned no webhook signing secret."
|
||||
)
|
||||
|
||||
updated_settings = {
|
||||
"revolut_payment_webhook_url": webhook_url,
|
||||
"revolut_webhook_signing_secret": signing_secret,
|
||||
}
|
||||
for key, value in updated_settings.items():
|
||||
await set_settings_field(key, value)
|
||||
update_cached_settings(updated_settings)
|
||||
|
||||
return RevolutCreateWebhookResponse(
|
||||
id=webhook.get("id"),
|
||||
url=webhook_url,
|
||||
events=webhook.get("events") or [],
|
||||
signing_secret=signing_secret,
|
||||
already_exists=webhook.get("already_exists", False),
|
||||
)
|
||||
|
||||
|
||||
@fiat_router.post(
|
||||
"/{provider}/subscription",
|
||||
status_code=HTTPStatus.OK,
|
||||
|
||||
@@ -189,6 +189,9 @@ 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)]
|
||||
)
|
||||
@@ -196,7 +199,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
||||
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||
)
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists)
|
||||
request: Request,
|
||||
ext_id: str | None = None,
|
||||
user: User = Depends(check_user_exists),
|
||||
) -> HTMLResponse:
|
||||
return template_renderer().TemplateResponse(
|
||||
request,
|
||||
|
||||
@@ -34,6 +34,7 @@ from lnbits.core.models import (
|
||||
PaymentWalletStats,
|
||||
SettleInvoice,
|
||||
SimpleStatus,
|
||||
UpdatePaymentExtra,
|
||||
)
|
||||
from lnbits.core.models.payments import UpdatePaymentLabels
|
||||
from lnbits.core.models.users import AccountId
|
||||
@@ -263,6 +264,7 @@ async def api_payments_create(
|
||||
payment_request=invoice_data.bolt11,
|
||||
extra=invoice_data.extra,
|
||||
labels=invoice_data.labels,
|
||||
external_id=invoice_data.external_id,
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -296,6 +298,38 @@ async def api_update_payment_labels(
|
||||
return SimpleStatus(success=True, message="Payment labels updated.")
|
||||
|
||||
|
||||
@payment_router.patch(
|
||||
"/extra",
|
||||
name="Update payment extra",
|
||||
description="Append new extra metadata to a payment.",
|
||||
response_model=Payment,
|
||||
)
|
||||
async def api_update_payment_extra(
|
||||
data: UpdatePaymentExtra,
|
||||
key_type: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Payment:
|
||||
payment = await get_standalone_payment(
|
||||
data.payment_hash, wallet_id=key_type.wallet.id
|
||||
)
|
||||
if payment is None:
|
||||
raise HTTPException(HTTPStatus.NOT_FOUND, "Payment does not exist.")
|
||||
if not payment.success:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST, "Payment extra can only be updated after success."
|
||||
)
|
||||
|
||||
duplicate_keys = sorted(set(payment.extra).intersection(data.extra))
|
||||
if duplicate_keys:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Extra keys already exist: {', '.join(duplicate_keys)}.",
|
||||
)
|
||||
|
||||
payment.extra.update(data.extra)
|
||||
await update_payment(payment)
|
||||
return payment
|
||||
|
||||
|
||||
@payment_router.get("/fee-reserve")
|
||||
async def api_payments_fee_reserve(invoice: str = Query("invoice")) -> JSONResponse:
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
|
||||
@@ -158,10 +158,6 @@ async def api_update_user(
|
||||
async def api_users_delete_user(
|
||||
user_id: str, account: Account = Depends(check_admin)
|
||||
) -> SimpleStatus:
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
|
||||
if user_id == settings.super_user:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
@@ -173,6 +169,11 @@ async def api_users_delete_user(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Only super_user can delete admin user.",
|
||||
)
|
||||
|
||||
wallets = await get_wallets(user_id, deleted=False)
|
||||
for wallet in wallets:
|
||||
await delete_wallet_by_id(wallet.id)
|
||||
|
||||
await delete_account(user_id)
|
||||
return SimpleStatus(success=True, message="User deleted.")
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from .api.host import ExtensionHostAPI
|
||||
from .api.models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .api.registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .api.runtime import ExtensionAPIHost
|
||||
from .wasm.loader import WasmExtension
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"WasmExtension",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
from .registry import (
|
||||
extension_api_contract,
|
||||
extension_api_method,
|
||||
extension_api_permission_ids,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
from .runtime import ExtensionAPIHost
|
||||
|
||||
__all__ = [
|
||||
"ExtensionAPIHost",
|
||||
"ExtensionAPIMethod",
|
||||
"ExtensionAPIMethodExport",
|
||||
"ExtensionHostAPI",
|
||||
"extension_api_contract",
|
||||
"extension_api_method",
|
||||
"extension_api_permission_ids",
|
||||
"get_extension_api_method",
|
||||
"list_extension_api_methods",
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from bolt11 import decode as bolt11_decode
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extension
|
||||
from lnbits.core.crud.payments import check_internal
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models.extensions import (
|
||||
ExtensionBackgroundPaymentDestinationPolicy,
|
||||
ExtensionBackgroundPaymentGrant,
|
||||
)
|
||||
from lnbits.core.models.wallets import Wallet
|
||||
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION = "wallet.pay_invoice_background"
|
||||
|
||||
|
||||
async def background_payment_extra(
|
||||
*,
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
payment_request: str,
|
||||
amount_msat: int,
|
||||
) -> dict[str, Any]:
|
||||
grant = await _background_payment_grant(extension_id, wallet, amount_msat)
|
||||
await _check_destination_policy(extension_id, wallet, grant, payment_request)
|
||||
|
||||
return {
|
||||
"tag": extension_id,
|
||||
"extension": extension_id,
|
||||
"background_payment": True,
|
||||
"background_permission": WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
"background_wallet_id": wallet.source_wallet_id,
|
||||
"background_destination_policy": grant.destination_policy.value,
|
||||
}
|
||||
|
||||
|
||||
def invoice_amount_msat(payment_request: str) -> int:
|
||||
invoice = bolt11_decode(payment_request)
|
||||
amount_msat = int(invoice.amount_msat or 0)
|
||||
if amount_msat <= 0:
|
||||
raise PermissionError("Background payments require an invoice amount.")
|
||||
return amount_msat
|
||||
|
||||
|
||||
async def _background_payment_grant(
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
amount_msat: int,
|
||||
) -> ExtensionBackgroundPaymentGrant:
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
_deny(extension_id, wallet, amount_msat, "shared wallet")
|
||||
if not wallet.can_send_payments:
|
||||
_deny(extension_id, wallet, amount_msat, "wallet cannot send payments")
|
||||
|
||||
user_extension = await get_user_extension(wallet.user, extension_id)
|
||||
if not user_extension or not user_extension.active:
|
||||
_deny(extension_id, wallet, amount_msat, "extension disabled for user")
|
||||
|
||||
permissions = user_extension.permissions or {}
|
||||
grants = permissions.get(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
_deny(extension_id, wallet, amount_msat, "missing background payment grant")
|
||||
|
||||
grant = _find_wallet_grant(grants, wallet.id)
|
||||
if not grant:
|
||||
_deny(extension_id, wallet, amount_msat, "missing wallet background grant")
|
||||
if not grant.enabled:
|
||||
_deny(extension_id, wallet, amount_msat, "background grant disabled")
|
||||
if amount_msat > grant.max_amount * 1000:
|
||||
_deny(extension_id, wallet, amount_msat, "payment exceeds max amount")
|
||||
return grant
|
||||
|
||||
|
||||
def _find_wallet_grant(
|
||||
grants: list[Any], wallet_id: str
|
||||
) -> ExtensionBackgroundPaymentGrant | None:
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionBackgroundPaymentGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.wallet_id == wallet_id:
|
||||
return grant
|
||||
return None
|
||||
|
||||
|
||||
async def _check_destination_policy(
|
||||
extension_id: str,
|
||||
wallet: Wallet,
|
||||
grant: ExtensionBackgroundPaymentGrant,
|
||||
payment_request: str,
|
||||
) -> None:
|
||||
if (
|
||||
grant.destination_policy
|
||||
== ExtensionBackgroundPaymentDestinationPolicy.EXTERNAL_ALLOWED
|
||||
):
|
||||
return
|
||||
|
||||
payment_hash = bolt11_decode(payment_request).payment_hash
|
||||
internal_payment = await check_internal(payment_hash)
|
||||
if not internal_payment:
|
||||
_deny(extension_id, wallet, 0, "external destination not allowed")
|
||||
|
||||
destination_wallet = await get_wallet(internal_payment.wallet_id)
|
||||
if not destination_wallet or destination_wallet.user != wallet.user:
|
||||
_deny(extension_id, wallet, 0, "destination wallet is not owned by user")
|
||||
|
||||
|
||||
def _deny(extension_id: str, wallet: Wallet, amount_msat: int, reason: str) -> NoReturn:
|
||||
logger.warning(
|
||||
"WASM extension '{}' denied background payment from wallet '{}', "
|
||||
"user '{}', amount_msat '{}': {}.",
|
||||
extension_id,
|
||||
wallet.id,
|
||||
wallet.user,
|
||||
amount_msat,
|
||||
reason,
|
||||
)
|
||||
raise PermissionError(reason)
|
||||
@@ -0,0 +1,718 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Iterable, Mapping
|
||||
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,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_set_row,
|
||||
)
|
||||
from .background_payments import (
|
||||
WALLET_PAY_INVOICE_BACKGROUND_PERMISSION,
|
||||
background_payment_extra,
|
||||
invoice_amount_msat,
|
||||
)
|
||||
from .models import (
|
||||
CreateInvoicePublicRequest,
|
||||
CreateInvoiceRequest,
|
||||
CreateInvoiceResponse,
|
||||
EmptyRequest,
|
||||
ExtensionApiRequest,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
ListUserWalletsResponse,
|
||||
LogRequest,
|
||||
LogResponse,
|
||||
NowResponse,
|
||||
PayInvoiceRequest,
|
||||
PayInvoiceResponse,
|
||||
PayLnurlRequest,
|
||||
RandomIdRequest,
|
||||
RandomIdResponse,
|
||||
StorageDeleteRequest,
|
||||
StorageDeleteResponse,
|
||||
StorageGetRequest,
|
||||
StorageGetResponse,
|
||||
StoragePaginatedRequest,
|
||||
StoragePaginatedResponse,
|
||||
StorageSetRequest,
|
||||
StorageSetResponse,
|
||||
UserWalletSummary,
|
||||
WalletBalanceRequest,
|
||||
WalletBalanceResponse,
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
logger = logging.getLogger("lnbits.extensions")
|
||||
|
||||
|
||||
class ExtensionHostAPI:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[Any],
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
access_token: str | None = None,
|
||||
context: str = "user",
|
||||
owner_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)
|
||||
self.user_id = user_id
|
||||
self.access_token = access_token
|
||||
self.context = context
|
||||
self.owner_id = sha256s(user_id) if user_id else owner_id
|
||||
self.invocation_id = invocation_id
|
||||
self.runtime_limits = runtime_limits or {}
|
||||
from .utils import ExtensionAPIUtils
|
||||
|
||||
self.utils = ExtensionAPIUtils(
|
||||
self.extension_id,
|
||||
self.permissions,
|
||||
authenticated=self.has_authenticated_context(),
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get",
|
||||
namespace="storage",
|
||||
name="Get storage row",
|
||||
host_name="storage_get",
|
||||
sdk_name="get",
|
||||
description="Read one row from an extension storage table.",
|
||||
required_permission="ext.storage.read",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_get(self, request: StorageGetRequest) -> StorageGetResponse:
|
||||
row = await storage_get_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.id,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageGetResponse(data_json=json.dumps(row) if row else None)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get_public",
|
||||
namespace="storage",
|
||||
name="Get public storage row",
|
||||
host_name="storage_get_public",
|
||||
sdk_name="getPublic",
|
||||
description="Read one public row from an extension storage table.",
|
||||
required_permission="ext.storage.read_public",
|
||||
require_auth=False,
|
||||
)
|
||||
async def storage_get_public(
|
||||
self, request: StorageGetRequest
|
||||
) -> StorageGetResponse:
|
||||
public_fields = self._public_storage_fields(request.table)
|
||||
row = await storage_get_public_row(self.extension_id, request.table, request.id)
|
||||
if not row:
|
||||
return StorageGetResponse()
|
||||
public_row = {
|
||||
field_name: value
|
||||
for field_name, value in row.items()
|
||||
if field_name in public_fields
|
||||
}
|
||||
# todo: check public fields filtering
|
||||
return StorageGetResponse(data_json=json.dumps(public_row))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.set",
|
||||
namespace="storage",
|
||||
name="Set storage row",
|
||||
host_name="storage_set",
|
||||
sdk_name="set",
|
||||
description="Create or update one row in an extension storage table.",
|
||||
required_permission="ext.storage.write",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_set(self, request: StorageSetRequest) -> StorageSetResponse:
|
||||
await storage_set_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.data,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageSetResponse()
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.get_paginated",
|
||||
namespace="storage",
|
||||
name="Get paginated storage rows",
|
||||
host_name="storage_get_paginated",
|
||||
sdk_name="getPaginated",
|
||||
description="Get filtered, searched, sorted, paginated storage rows.",
|
||||
required_permission="ext.storage.read",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_get_paginated(
|
||||
self, request: StoragePaginatedRequest
|
||||
) -> StoragePaginatedResponse:
|
||||
page = await storage_get_paginated_rows(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.filters,
|
||||
owner_id=self._require_owner_id(),
|
||||
search=request.search,
|
||||
search_fields=request.search_fields,
|
||||
sort_by=request.sort_by,
|
||||
descending=request.descending,
|
||||
limit=request.limit,
|
||||
offset=request.offset,
|
||||
)
|
||||
return StoragePaginatedResponse(
|
||||
rows_json=json.dumps(page["data"]),
|
||||
total=page["total"],
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="storage.delete",
|
||||
namespace="storage",
|
||||
name="Delete storage row",
|
||||
host_name="storage_delete",
|
||||
sdk_name="delete",
|
||||
description="Delete one row from an extension storage table.",
|
||||
required_permission="ext.storage.write",
|
||||
require_auth=True,
|
||||
)
|
||||
async def storage_delete(
|
||||
self, request: StorageDeleteRequest
|
||||
) -> StorageDeleteResponse:
|
||||
await storage_delete_row(
|
||||
self.extension_id,
|
||||
request.table,
|
||||
request.id,
|
||||
self._require_owner_id(),
|
||||
)
|
||||
return StorageDeleteResponse()
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.create_invoice",
|
||||
namespace="wallet",
|
||||
name="Create invoice",
|
||||
host_name="create_invoice",
|
||||
sdk_name="createInvoice",
|
||||
description="Create an incoming Lightning invoice for an allowed wallet.",
|
||||
required_permission="wallet.create_invoice",
|
||||
require_auth=True,
|
||||
)
|
||||
async def wallet_create_invoice(
|
||||
self, request: CreateInvoiceRequest
|
||||
) -> CreateInvoiceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.payments import create_payment_request
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Creating an invoice for this wallet requires an "
|
||||
"authenticated user context."
|
||||
)
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Not your wallet.")
|
||||
|
||||
payment = await create_payment_request(
|
||||
request.wallet_id,
|
||||
CreateInvoice(
|
||||
amount=request.amount,
|
||||
unit=request.currency,
|
||||
memo=request.memo,
|
||||
extra=request.extra,
|
||||
extension=self.extension_id,
|
||||
),
|
||||
)
|
||||
return CreateInvoiceResponse(
|
||||
payment_hash=payment.payment_hash,
|
||||
payment_request=payment.payment_request or payment.bolt11,
|
||||
checking_id=payment.checking_id,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.create_invoice_public",
|
||||
namespace="wallet",
|
||||
name="Create public invoice",
|
||||
host_name="create_invoice_public",
|
||||
sdk_name="createInvoicePublic",
|
||||
description="Create a public incoming Lightning invoice.",
|
||||
required_permission="wallet.create_invoice_public",
|
||||
require_auth=False,
|
||||
)
|
||||
async def wallet_create_invoice_public(
|
||||
self, request: CreateInvoicePublicRequest
|
||||
) -> CreateInvoiceResponse:
|
||||
from lnbits.core.models.payments import CreateInvoice
|
||||
from lnbits.core.services.payments import create_payment_request
|
||||
|
||||
row: dict[str, Any] | None = None
|
||||
wallet_field = ""
|
||||
for policy in self._public_invoice_wallet_sources():
|
||||
row = await storage_get_public_row(
|
||||
self.extension_id,
|
||||
policy["table"],
|
||||
request.source_id,
|
||||
)
|
||||
if row:
|
||||
wallet_field = policy["wallet_field"]
|
||||
break
|
||||
|
||||
if not row:
|
||||
raise PermissionError("Public invoice source was not found.")
|
||||
|
||||
wallet_id = row.get(wallet_field)
|
||||
if not isinstance(wallet_id, str) or not wallet_id:
|
||||
raise PermissionError("Public invoice source has no valid wallet.")
|
||||
|
||||
payment = await create_payment_request(
|
||||
wallet_id,
|
||||
CreateInvoice(
|
||||
amount=request.amount,
|
||||
unit=request.currency,
|
||||
memo=request.memo,
|
||||
extra={
|
||||
"tag": self.extension_id,
|
||||
"source_id": request.source_id,
|
||||
f"extra_{self.extension_id}": request.extra,
|
||||
},
|
||||
extension=self.extension_id,
|
||||
),
|
||||
)
|
||||
return CreateInvoiceResponse(
|
||||
payment_hash=payment.payment_hash,
|
||||
payment_request=payment.payment_request or payment.bolt11,
|
||||
checking_id=payment.checking_id,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.list_user_wallets",
|
||||
namespace="wallet",
|
||||
name="List user wallets",
|
||||
host_name="list_user_wallets",
|
||||
sdk_name="listUserWallets",
|
||||
description="List wallets available to the authenticated extension user.",
|
||||
required_permission="wallet.list",
|
||||
)
|
||||
async def wallet_list_user_wallets(
|
||||
self, request: EmptyRequest
|
||||
) -> ListUserWalletsResponse:
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Listing user wallets requires an authenticated user context."
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets
|
||||
|
||||
user_wallets = await get_wallets(self.user_id)
|
||||
if user_wallets is None:
|
||||
raise PermissionError(
|
||||
"Listing user wallets requires an authenticated user context."
|
||||
)
|
||||
return ListUserWalletsResponse(
|
||||
wallets=[
|
||||
UserWalletSummary(id=w.id, name=w.name, currency=w.currency)
|
||||
for w in user_wallets
|
||||
]
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.balance",
|
||||
namespace="wallet",
|
||||
name="Read wallet balance",
|
||||
host_name="wallet_balance",
|
||||
sdk_name="balance",
|
||||
description="Read the balance of a wallet available to the user.",
|
||||
required_permission="wallet.balance.read",
|
||||
)
|
||||
async def wallet_balance(
|
||||
self, request: WalletBalanceRequest
|
||||
) -> WalletBalanceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
|
||||
if not self.user_id:
|
||||
raise PermissionError(
|
||||
"Reading a wallet balance requires an authenticated user context."
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None or wallet.user != self.user_id:
|
||||
raise PermissionError("Reading this wallet balance is not allowed.")
|
||||
|
||||
withdrawable_msat = max(wallet.withdrawable_balance, 0)
|
||||
fee_reserve_msat = max(wallet.balance_msat - withdrawable_msat, 0)
|
||||
return WalletBalanceResponse(
|
||||
wallet_id=wallet.id,
|
||||
name=wallet.name,
|
||||
currency=wallet.currency,
|
||||
balance_msat=wallet.balance_msat,
|
||||
balance_sat=wallet.balance,
|
||||
withdrawable_msat=withdrawable_msat,
|
||||
withdrawable_sat=withdrawable_msat // 1000,
|
||||
fee_reserve_msat=fee_reserve_msat,
|
||||
fee_reserve_sat=fee_reserve_msat // 1000,
|
||||
can_send_payments=wallet.can_send_payments,
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.pay_invoice",
|
||||
namespace="wallet",
|
||||
name="Pay invoice",
|
||||
host_name="pay_invoice",
|
||||
sdk_name="payInvoice",
|
||||
description="Pay a Lightning invoice from a wallet available to the user.",
|
||||
)
|
||||
async def wallet_pay_invoice(
|
||||
self, request: PayInvoiceRequest
|
||||
) -> PayInvoiceResponse:
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
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.")
|
||||
|
||||
try:
|
||||
if self.user_id:
|
||||
self.require_permission("wallet.pay_invoice")
|
||||
if wallet.user != self.user_id:
|
||||
raise PermissionError(
|
||||
"Paying invoices from this wallet is not allowed."
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=request.wallet_id,
|
||||
payment_request=request.payment_request,
|
||||
max_sat=request.max_sat,
|
||||
extra={"tag": self.extension_id, **request.extra},
|
||||
description=request.description,
|
||||
tag=self.extension_id,
|
||||
)
|
||||
else:
|
||||
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
amount_msat = invoice_amount_msat(request.payment_request)
|
||||
extra = await background_payment_extra(
|
||||
extension_id=self.extension_id,
|
||||
wallet=wallet,
|
||||
payment_request=request.payment_request,
|
||||
amount_msat=amount_msat,
|
||||
)
|
||||
payment = await pay_invoice(
|
||||
wallet_id=request.wallet_id,
|
||||
payment_request=request.payment_request,
|
||||
max_sat=request.max_sat,
|
||||
extra={**request.extra, **extra},
|
||||
description=request.description,
|
||||
tag=self.extension_id,
|
||||
)
|
||||
except (PaymentError, PermissionError, ValueError) as exc:
|
||||
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||
|
||||
return _pay_invoice_response(payment)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="wallet.pay_lnurl",
|
||||
namespace="wallet",
|
||||
name="Pay LNURL",
|
||||
host_name="pay_lnurl",
|
||||
sdk_name="payLnurl",
|
||||
description="Pay a Lightning Address or LNURL-pay request from a wallet.",
|
||||
)
|
||||
async def wallet_pay_lnurl(self, request: PayLnurlRequest) -> PayInvoiceResponse:
|
||||
from lnurl import LnAddressError, LnurlResponseException
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models.lnurl import CreateLnurlPayment
|
||||
from lnbits.core.services.lnurl import fetch_lnurl_pay_request
|
||||
from lnbits.core.services.payments import pay_invoice
|
||||
from lnbits.exceptions import PaymentError
|
||||
|
||||
from .lnurl import (
|
||||
lnurl_for_core,
|
||||
lnurl_pay_response_text,
|
||||
lnurl_payment_amount_for_core,
|
||||
lnurl_payment_unit_for_core,
|
||||
)
|
||||
|
||||
wallet = await get_wallet(request.wallet_id)
|
||||
if wallet is None:
|
||||
raise PermissionError("Paying from this wallet is not allowed.")
|
||||
|
||||
try:
|
||||
if self.user_id:
|
||||
self.require_permission("wallet.pay_invoice")
|
||||
if wallet.user != self.user_id:
|
||||
raise PermissionError("Paying from this wallet is not allowed.")
|
||||
else:
|
||||
self.require_permission(WALLET_PAY_INVOICE_BACKGROUND_PERMISSION)
|
||||
|
||||
unit = lnurl_payment_unit_for_core(request.currency)
|
||||
res, action = await fetch_lnurl_pay_request(
|
||||
data=CreateLnurlPayment(
|
||||
lnurl=lnurl_for_core(request.lnurl),
|
||||
amount=lnurl_payment_amount_for_core(request.amount),
|
||||
unit=unit,
|
||||
comment=request.comment,
|
||||
internal_memo=request.description or None,
|
||||
),
|
||||
wallet=None,
|
||||
)
|
||||
extra = {"tag": self.extension_id, **request.extra}
|
||||
if action.successAction:
|
||||
extra["success_action"] = action.successAction.json()
|
||||
if request.comment:
|
||||
extra["comment"] = request.comment
|
||||
if unit != "sat":
|
||||
extra["fiat_currency"] = unit
|
||||
extra["fiat_amount"] = str(request.amount)
|
||||
|
||||
if not self.user_id:
|
||||
amount_msat = invoice_amount_msat(str(action.pr))
|
||||
extra = {
|
||||
**extra,
|
||||
**(
|
||||
await background_payment_extra(
|
||||
extension_id=self.extension_id,
|
||||
wallet=wallet,
|
||||
payment_request=str(action.pr),
|
||||
amount_msat=amount_msat,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
payment = await pay_invoice(
|
||||
wallet_id=request.wallet_id,
|
||||
payment_request=str(action.pr),
|
||||
max_sat=request.max_sat,
|
||||
extra=extra,
|
||||
description=request.description or lnurl_pay_response_text(res),
|
||||
tag=self.extension_id,
|
||||
)
|
||||
except (
|
||||
LnAddressError,
|
||||
LnurlResponseException,
|
||||
PaymentError,
|
||||
PermissionError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
return PayInvoiceResponse(ok=False, error=str(exc))
|
||||
|
||||
return _pay_invoice_response(payment)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="http.request",
|
||||
namespace="http",
|
||||
name="HTTP request",
|
||||
host_name="http_request",
|
||||
sdk_name="request",
|
||||
description="Make an outbound HTTP request to an allowed host.",
|
||||
required_permission="http.request",
|
||||
require_auth=True,
|
||||
)
|
||||
async def http_request(self, request: HttpRequest) -> HttpResponse:
|
||||
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"
|
||||
),
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="extension.api.request",
|
||||
namespace="extension",
|
||||
name="Extension API request",
|
||||
host_name="extension_api_request",
|
||||
sdk_name="request",
|
||||
description="Call an allowed installed extension API.",
|
||||
required_permission="extension.api.request",
|
||||
require_auth=True,
|
||||
)
|
||||
async def extension_api_request(self, request: ExtensionApiRequest) -> HttpResponse:
|
||||
|
||||
policies = self.permission_policies.get("extension.api.request") or []
|
||||
return await send_extension_api_request(
|
||||
self.extension_id,
|
||||
policies,
|
||||
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(
|
||||
method_id="system.random_id",
|
||||
namespace="system",
|
||||
name="Random ID",
|
||||
host_name="random_id",
|
||||
sdk_name="id",
|
||||
description="Create a random extension-local identifier.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_random_id(self, request: RandomIdRequest) -> RandomIdResponse:
|
||||
return RandomIdResponse(
|
||||
id=f"{request.prefix}_{secrets.token_urlsafe(12).replace('-', '_')}"
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="system.now",
|
||||
namespace="system",
|
||||
name="Current timestamp",
|
||||
host_name="now",
|
||||
sdk_name="now",
|
||||
description="Return the current Unix timestamp.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_now(self, request: EmptyRequest) -> NowResponse:
|
||||
return NowResponse(timestamp=int(time.time()))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="system.log",
|
||||
namespace="system",
|
||||
name="Log message",
|
||||
host_name="log",
|
||||
sdk_name="log",
|
||||
description="Write a bounded message to the extension log.",
|
||||
require_auth=False,
|
||||
)
|
||||
async def system_log(self, request: LogRequest) -> LogResponse:
|
||||
log = getattr(logger, request.level)
|
||||
log("extension:%s %s", self.extension_id, request.message)
|
||||
return LogResponse()
|
||||
|
||||
@staticmethod
|
||||
def _permission_data(
|
||||
permissions: Iterable[Any],
|
||||
) -> tuple[set[str], dict[str, list[Any]]]:
|
||||
permission_ids: set[str] = set()
|
||||
policies: dict[str, list[Any]] = {}
|
||||
|
||||
for permission in permissions:
|
||||
if isinstance(permission, str):
|
||||
permission_ids.add(permission)
|
||||
continue
|
||||
|
||||
permission_id: str | None = None
|
||||
permission_policies: Any = None
|
||||
if isinstance(permission, Mapping):
|
||||
permission_id = permission.get("id") # type: ignore[assignment]
|
||||
permission_policies = permission.get("policies")
|
||||
else:
|
||||
permission_id = getattr(permission, "id", None)
|
||||
permission_policies = getattr(permission, "policies", None)
|
||||
|
||||
if not permission_id:
|
||||
continue
|
||||
permission_ids.add(permission_id)
|
||||
if isinstance(permission_policies, list):
|
||||
policies[permission_id] = permission_policies
|
||||
|
||||
return permission_ids, policies
|
||||
|
||||
def _public_storage_fields(self, table: str) -> set[str]:
|
||||
tables = self.permission_policies.get("ext.storage.read_public")
|
||||
if not isinstance(tables, list) or not tables:
|
||||
raise PermissionError(
|
||||
"Public storage reads require policies for "
|
||||
"'ext.storage.read_public'."
|
||||
)
|
||||
|
||||
for table_policy in tables:
|
||||
if not isinstance(table_policy, dict):
|
||||
continue
|
||||
if table_policy.get("table_name") != table:
|
||||
continue
|
||||
public_fields = table_policy.get("public_fields")
|
||||
if not isinstance(public_fields, list) or not all(
|
||||
isinstance(field, str) and field for field in public_fields
|
||||
):
|
||||
raise PermissionError(
|
||||
f"Public storage table '{table}' has no valid public fields."
|
||||
)
|
||||
return set(public_fields)
|
||||
|
||||
raise PermissionError(f"Storage table '{table}' is not publicly readable.")
|
||||
|
||||
def _public_invoice_wallet_sources(self) -> list[dict[str, str]]:
|
||||
policies = self.permission_policies.get("wallet.create_invoice_public")
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError("Public invoice creation requires a policies list.")
|
||||
|
||||
sources: list[dict[str, str]] = []
|
||||
for source_policy in policies:
|
||||
if not isinstance(source_policy, dict):
|
||||
raise PermissionError(
|
||||
"Public invoice creation policies must be objects."
|
||||
)
|
||||
table = source_policy.get("table")
|
||||
wallet_field = source_policy.get("wallet_field")
|
||||
if not isinstance(table, str) or not table:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires a storage table policy."
|
||||
)
|
||||
if not isinstance(wallet_field, str) or not wallet_field:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires a wallet field policy."
|
||||
)
|
||||
sources.append({"table": table, "wallet_field": wallet_field})
|
||||
|
||||
if not sources:
|
||||
raise PermissionError(
|
||||
"Public invoice creation requires at least one valid policy."
|
||||
)
|
||||
return sources
|
||||
|
||||
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 bool(self.user_id) or self.context == "event"
|
||||
|
||||
def _require_owner_id(self) -> str:
|
||||
if not self.owner_id:
|
||||
raise PermissionError("Extension API method requires an owner context.")
|
||||
return self.owner_id
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
"ExtensionHostAPI("
|
||||
f"extension_id={self.extension_id!r}, "
|
||||
f"context={self.context!r}, "
|
||||
f"owner_id={self.owner_id!r}"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def _pay_invoice_response(payment: Any) -> PayInvoiceResponse:
|
||||
return PayInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=payment.checking_id,
|
||||
payment_hash=payment.payment_hash,
|
||||
status=payment.status,
|
||||
amount_msat=abs(payment.amount),
|
||||
fee_msat=abs(payment.fee),
|
||||
pending=payment.pending,
|
||||
success=payment.success,
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from lnurl import LnAddress, Lnurl
|
||||
|
||||
|
||||
def normalize_lnurl(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
if normalized.lower().startswith("lightning:"):
|
||||
normalized = normalized[len("lightning:") :]
|
||||
if "@" in normalized:
|
||||
normalized = normalized.lower()
|
||||
if not normalized:
|
||||
raise ValueError("LNURL is required.")
|
||||
return normalized
|
||||
|
||||
|
||||
def lnurl_for_core(value: str) -> Lnurl | LnAddress:
|
||||
normalized = normalize_lnurl(value)
|
||||
if "@" in normalized:
|
||||
return LnAddress(normalized)
|
||||
return Lnurl(normalized)
|
||||
|
||||
|
||||
def lnurl_payment_amount_for_core(amount: float) -> int:
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount must be greater than zero.")
|
||||
return round(amount * 1000)
|
||||
|
||||
|
||||
def lnurl_payment_unit_for_core(currency: str) -> str:
|
||||
unit = currency.strip().lower()
|
||||
if not unit:
|
||||
raise ValueError("Currency is required.")
|
||||
if unit in {"sat", "sats"}:
|
||||
return "sat"
|
||||
return unit.upper()
|
||||
|
||||
|
||||
def lnurl_pay_response_metadata_json(response: Any) -> str:
|
||||
metadata = getattr(response, "metadata", None)
|
||||
if metadata is None:
|
||||
return "[]"
|
||||
|
||||
metadata_list = getattr(metadata, "list", None)
|
||||
try:
|
||||
if callable(metadata_list):
|
||||
return json.dumps(metadata_list())
|
||||
return json.dumps(metadata)
|
||||
except TypeError:
|
||||
return json.dumps(str(metadata))
|
||||
|
||||
|
||||
def lnurl_pay_response_text(response: Any) -> str:
|
||||
description = getattr(response, "description", None)
|
||||
if description is not None:
|
||||
return str(description)
|
||||
|
||||
metadata = getattr(response, "metadata", None)
|
||||
text = getattr(metadata, "text", None)
|
||||
return str(text) if text is not None else ""
|
||||
|
||||
|
||||
def lnurl_pay_response_int(response: Any, snake_name: str, camel_name: str) -> int:
|
||||
value = getattr(response, snake_name, None)
|
||||
if value is None:
|
||||
value = getattr(response, camel_name, 0)
|
||||
return int(value or 0)
|
||||
@@ -0,0 +1,387 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, root_validator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethodExport:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExtensionAPIMethod:
|
||||
method_id: str
|
||||
namespace: str
|
||||
name: str
|
||||
python_name: str
|
||||
host_interface: str
|
||||
host_name: str
|
||||
sdk_name: str
|
||||
description: str
|
||||
request_model: type[BaseModel]
|
||||
response_model: type[BaseModel]
|
||||
required_permission: str | None = None
|
||||
require_auth: bool = True
|
||||
|
||||
@property
|
||||
def sdk_qualified_name(self) -> str:
|
||||
return f"{self.namespace}.{self.sdk_name}"
|
||||
|
||||
|
||||
class EmptyRequest(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
class StorageGetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageGetResponse(BaseModel):
|
||||
data_json: str | None = None
|
||||
|
||||
|
||||
class StorageSetRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_data_json(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
data_json = values.get("data_json")
|
||||
if data_json is not None and "data" not in values:
|
||||
values["data"] = json.loads(data_json)
|
||||
return values
|
||||
|
||||
|
||||
class StorageSetResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class StoragePaginatedRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
filters: dict[str, Any] = Field(default_factory=dict)
|
||||
search: str | None = Field(None, max_length=256)
|
||||
search_fields: list[str] = Field(default_factory=list)
|
||||
sort_by: str | None = Field(None, min_length=1, max_length=128)
|
||||
descending: bool = False
|
||||
limit: int = Field(25, ge=1, le=1000)
|
||||
offset: int = Field(0, ge=0)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def parse_json_fields(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
filters_json = values.get("filters_json")
|
||||
if filters_json is not None and "filters" not in values:
|
||||
values["filters"] = json.loads(filters_json)
|
||||
|
||||
search_fields_json = values.get("search_fields_json")
|
||||
if search_fields_json is not None and "search_fields" not in values:
|
||||
values["search_fields"] = json.loads(search_fields_json)
|
||||
|
||||
if values.get("sort_by") == "":
|
||||
values["sort_by"] = None
|
||||
return values
|
||||
|
||||
|
||||
class StoragePaginatedResponse(BaseModel):
|
||||
rows_json: str = "[]"
|
||||
total: int = 0
|
||||
|
||||
|
||||
class StorageDeleteRequest(BaseModel):
|
||||
table: str = Field(..., min_length=1, max_length=128)
|
||||
id: str = Field(..., min_length=1, max_length=512)
|
||||
|
||||
|
||||
class StorageDeleteResponse(BaseModel):
|
||||
ok: bool = True
|
||||
|
||||
|
||||
class CreateInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field("sat", min_length=1, max_length=8)
|
||||
memo: str = Field(..., max_length=512)
|
||||
tag: str = Field(..., min_length=1, max_length=64)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CreateInvoicePublicRequest(BaseModel):
|
||||
source_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=512,
|
||||
description="The source ID (entry id) of the wallet to create the invoice for.",
|
||||
)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
memo: str = Field("", max_length=512)
|
||||
extra: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@root_validator
|
||||
def validate_extra_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
extra = values.get("extra") or {}
|
||||
try:
|
||||
encoded = json.dumps(extra, separators=(",", ":"))
|
||||
except TypeError as exc:
|
||||
raise ValueError("extra must be JSON serializable.") from exc
|
||||
if len(encoded.encode()) > 4096:
|
||||
raise ValueError("extra must not exceed 4096 bytes.")
|
||||
values["extra"] = extra
|
||||
return values
|
||||
|
||||
|
||||
class CreateInvoiceResponse(BaseModel):
|
||||
payment_hash: str
|
||||
payment_request: str
|
||||
checking_id: str
|
||||
|
||||
|
||||
class UserWalletSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
|
||||
|
||||
class ListUserWalletsResponse(BaseModel):
|
||||
wallets: list[UserWalletSummary] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WalletBalanceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class WalletBalanceResponse(BaseModel):
|
||||
wallet_id: str
|
||||
name: str
|
||||
currency: str | None = None
|
||||
balance_msat: int
|
||||
balance_sat: int
|
||||
withdrawable_msat: int
|
||||
withdrawable_sat: int
|
||||
fee_reserve_msat: int
|
||||
fee_reserve_sat: int
|
||||
can_send_payments: bool
|
||||
|
||||
|
||||
class PayInvoiceRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
payment_request: str = Field(..., min_length=1, max_length=8192)
|
||||
max_sat: int | None = Field(None, gt=0)
|
||||
description: str = Field("", max_length=512)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayLnurlRequest(BaseModel):
|
||||
wallet_id: str = Field(..., min_length=1, max_length=128)
|
||||
lnurl: str = Field(..., min_length=1, max_length=2048)
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field("sat", min_length=1, max_length=8)
|
||||
comment: str | None = Field(None, max_length=512)
|
||||
description: str = Field("", max_length=512)
|
||||
max_sat: int | None = Field(None, gt=0)
|
||||
extra: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PayInvoiceResponse(BaseModel):
|
||||
ok: bool = True
|
||||
error: str | None = None
|
||||
checking_id: str | None = None
|
||||
payment_hash: str | None = None
|
||||
status: str | None = None
|
||||
amount_msat: int = 0
|
||||
fee_msat: int = 0
|
||||
pending: bool = False
|
||||
success: bool = False
|
||||
|
||||
|
||||
class HttpRequest(BaseModel):
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
url: str = Field(..., min_length=1, max_length=2048)
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
@root_validator
|
||||
def validate_headers_size(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = values.get("headers") or {}
|
||||
if len(headers) > 32:
|
||||
raise ValueError("headers must not contain more than 32 entries.")
|
||||
for key, value in headers.items():
|
||||
if len(key) > 128 or len(value) > 4096:
|
||||
raise ValueError("headers are too large.")
|
||||
values["headers"] = headers
|
||||
return values
|
||||
|
||||
|
||||
class HttpResponse(BaseModel):
|
||||
status_code: int
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
body: str = ""
|
||||
|
||||
|
||||
class ExtensionApiRequest(BaseModel):
|
||||
extension_id: str = Field(..., min_length=1, max_length=128)
|
||||
method: Literal["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"] = "GET"
|
||||
path: str = Field(..., min_length=1, max_length=2048)
|
||||
body: str | None = Field(None, max_length=65536)
|
||||
|
||||
@root_validator(pre=True)
|
||||
def normalize_method(cls, values: dict[str, Any]) -> dict[str, Any]:
|
||||
method = values.get("method")
|
||||
if isinstance(method, str):
|
||||
values["method"] = method.upper()
|
||||
return values
|
||||
|
||||
|
||||
class CurrencyListResponse(BaseModel):
|
||||
currencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CurrencyRateRequest(BaseModel):
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class CurrencyRateResponse(BaseModel):
|
||||
rate: float
|
||||
price: float
|
||||
|
||||
|
||||
class CurrencyConvertRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
from_currency: str = Field(..., alias="from", min_length=1, max_length=8)
|
||||
to: str = Field(..., min_length=1, max_length=256)
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
|
||||
|
||||
class CurrencyConvertResponse(BaseModel):
|
||||
amounts: list[tuple[str, float]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FiatToSatsRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class FiatToSatsResponse(BaseModel):
|
||||
amount_sat: int
|
||||
|
||||
|
||||
class SatsToFiatRequest(BaseModel):
|
||||
amount: float = Field(..., gt=0)
|
||||
currency: str = Field(..., min_length=1, max_length=8)
|
||||
|
||||
|
||||
class SatsToFiatResponse(BaseModel):
|
||||
amount: float
|
||||
|
||||
|
||||
class LnurlResolveRequest(BaseModel):
|
||||
lnurl: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LnurlResolveResponse(BaseModel):
|
||||
lnurl: str
|
||||
domain: str | None = None
|
||||
description: str = ""
|
||||
min_sendable_msat: int
|
||||
max_sendable_msat: int
|
||||
comment_allowed: int = 0
|
||||
fixed: bool = False
|
||||
image: str | None = None
|
||||
metadata_json: str = "[]"
|
||||
|
||||
|
||||
class ServerHealthResponse(BaseModel):
|
||||
server_time: int
|
||||
up_time: str
|
||||
|
||||
|
||||
class Bolt11Request(BaseModel):
|
||||
bolt11: str = Field(..., min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class DecodeInvoiceResponse(BaseModel):
|
||||
valid: bool = True
|
||||
payment_hash: str | None = None
|
||||
amount_msat: int | None = None
|
||||
expiry: int | None = None
|
||||
expires_at: int | None = None
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class ValidateInvoiceResponse(BaseModel):
|
||||
valid: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class InvoicePaymentHashResponse(BaseModel):
|
||||
payment_hash: str
|
||||
|
||||
|
||||
class InvoiceAmountMsatResponse(BaseModel):
|
||||
amount_msat: int | None = None
|
||||
|
||||
|
||||
class InvoiceExpiryResponse(BaseModel):
|
||||
expires_at: int | None = None
|
||||
|
||||
|
||||
class InvoiceMemoResponse(BaseModel):
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
class VerifyPreimageRequest(BaseModel):
|
||||
preimage: str = Field(..., min_length=64, max_length=64)
|
||||
payment_hash: str = Field(..., min_length=64, max_length=64)
|
||||
|
||||
|
||||
class VerifyPreimageResponse(BaseModel):
|
||||
valid: bool
|
||||
|
||||
|
||||
class RandomSecretAndHashRequest(BaseModel):
|
||||
length: int = Field(32, ge=16, le=64)
|
||||
|
||||
|
||||
class RandomSecretAndHashResponse(BaseModel):
|
||||
secret: str
|
||||
hash: str
|
||||
|
||||
|
||||
class RandomIdRequest(BaseModel):
|
||||
prefix: str = Field(..., min_length=1, max_length=32)
|
||||
|
||||
|
||||
class RandomIdResponse(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class NowResponse(BaseModel):
|
||||
timestamp: int
|
||||
|
||||
|
||||
class LogRequest(BaseModel):
|
||||
level: Literal["debug", "info", "warning", "error"] = "info"
|
||||
message: str = Field(..., min_length=1, max_length=2048)
|
||||
|
||||
|
||||
class LogResponse(BaseModel):
|
||||
ok: bool = True
|
||||
@@ -0,0 +1,250 @@
|
||||
from collections.abc import Iterable
|
||||
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(
|
||||
ext_id: str,
|
||||
permissions: Iterable[ExtensionPermission],
|
||||
*,
|
||||
strict: bool = True,
|
||||
) -> list[ExtensionPermission]:
|
||||
known_permission_ids = extension_api_permission_ids()
|
||||
normalized_permissions: list[ExtensionPermission] = []
|
||||
unknown_ids: list[str] = []
|
||||
|
||||
for permission in permissions:
|
||||
if permission.id not in known_permission_ids:
|
||||
unknown_ids.append(permission.id)
|
||||
if strict:
|
||||
continue
|
||||
normalized_permissions.append(permission.copy())
|
||||
|
||||
if unknown_ids and strict:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_id}' requests unknown permissions: "
|
||||
+ ", ".join(sorted(set(unknown_ids)))
|
||||
)
|
||||
|
||||
return normalized_permissions
|
||||
|
||||
|
||||
def validate_wasm_extension_permissions(
|
||||
ext_info: InstallableExtension,
|
||||
granted_permissions: list[ExtensionPermission] | None,
|
||||
extension_config: dict[str, Any] | WasmExtensionConfig,
|
||||
) -> list[ExtensionPermission]:
|
||||
if isinstance(extension_config, WasmExtensionConfig):
|
||||
config = extension_config
|
||||
elif 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
|
||||
)
|
||||
if not requested_permissions:
|
||||
return []
|
||||
|
||||
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:
|
||||
raise ValueError(
|
||||
f"Extension '{ext_info.id}' was granted unrequested permissions: "
|
||||
+ ", ".join(extra_granted_ids)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable
|
||||
from functools import wraps
|
||||
from typing import Any, TypeVar, cast, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .models import ExtensionAPIMethod, ExtensionAPIMethodExport
|
||||
|
||||
_EXTENSION_API_METHOD_ATTR = "__lnbits_extension_api_method__"
|
||||
_EXTENSION_RUNTIME_PERMISSION_IDS = {
|
||||
"ui.camera.scan_qr",
|
||||
"wallet.pay_invoice",
|
||||
"wallet.pay_invoice_background",
|
||||
"wallet.payments.watch",
|
||||
}
|
||||
_RequestModel = TypeVar("_RequestModel", bound=BaseModel)
|
||||
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)
|
||||
|
||||
|
||||
def extension_api_method(
|
||||
*,
|
||||
method_id: str,
|
||||
namespace: str,
|
||||
name: str,
|
||||
host_name: str,
|
||||
sdk_name: str,
|
||||
description: str,
|
||||
host_interface: str = "host",
|
||||
required_permission: str | None = None,
|
||||
require_auth: bool = True,
|
||||
) -> Callable[
|
||||
[Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]],
|
||||
Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
]:
|
||||
export = ExtensionAPIMethodExport(
|
||||
method_id=method_id,
|
||||
namespace=namespace,
|
||||
name=name,
|
||||
host_interface=host_interface,
|
||||
host_name=host_name,
|
||||
sdk_name=sdk_name,
|
||||
description=description,
|
||||
required_permission=required_permission,
|
||||
require_auth=require_auth,
|
||||
)
|
||||
|
||||
def decorator(
|
||||
function: Callable[[Any, _RequestModel], Awaitable[_ResponseModel]],
|
||||
) -> Callable[[Any, _RequestModel], Awaitable[_ResponseModel]]:
|
||||
@wraps(function)
|
||||
async def wrapper(self: Any, request: _RequestModel) -> _ResponseModel:
|
||||
api = getattr(self, "api", self)
|
||||
if require_auth and not api.has_authenticated_context():
|
||||
raise PermissionError(
|
||||
f"Extension API method '{method_id}' requires authentication."
|
||||
)
|
||||
api.require_permission(required_permission)
|
||||
return await function(self, request)
|
||||
|
||||
setattr(wrapper, _EXTENSION_API_METHOD_ATTR, export)
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def list_extension_api_methods(
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> list[ExtensionAPIMethod]:
|
||||
api_cls = _default_api_cls(api_cls)
|
||||
methods: list[ExtensionAPIMethod] = []
|
||||
|
||||
for prefix, method_cls in _extension_api_method_sources(api_cls):
|
||||
for python_name, function in inspect.getmembers(method_cls, inspect.isfunction):
|
||||
export = getattr(function, _EXTENSION_API_METHOD_ATTR, None)
|
||||
if not export:
|
||||
continue
|
||||
|
||||
request_model, response_model = _get_method_models(function)
|
||||
methods.append(
|
||||
ExtensionAPIMethod(
|
||||
method_id=export.method_id,
|
||||
namespace=export.namespace,
|
||||
name=export.name,
|
||||
python_name=f"{prefix}.{python_name}" if prefix else python_name,
|
||||
host_interface=export.host_interface,
|
||||
host_name=export.host_name,
|
||||
sdk_name=export.sdk_name,
|
||||
description=export.description,
|
||||
request_model=request_model,
|
||||
response_model=response_model,
|
||||
required_permission=export.required_permission,
|
||||
require_auth=export.require_auth,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(methods, key=lambda method: method.method_id)
|
||||
|
||||
|
||||
def extension_api_permission_ids(api_cls: type[Any] | None = None) -> set[str]:
|
||||
permissions = {
|
||||
method.required_permission
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
if method.required_permission
|
||||
}
|
||||
permissions.update(_EXTENSION_RUNTIME_PERMISSION_IDS)
|
||||
return permissions
|
||||
|
||||
|
||||
def get_extension_api_method(
|
||||
method_id: str,
|
||||
api_cls: type[Any] | None = None,
|
||||
) -> ExtensionAPIMethod:
|
||||
for method in list_extension_api_methods(api_cls):
|
||||
if method.method_id == method_id:
|
||||
return method
|
||||
raise KeyError(f"Unknown extension API method '{method_id}'.")
|
||||
|
||||
|
||||
def extension_api_contract(api_cls: type[Any] | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"version": 1,
|
||||
"methods": [
|
||||
{
|
||||
"id": method.method_id,
|
||||
"namespace": method.namespace,
|
||||
"name": method.name,
|
||||
"python_name": method.python_name,
|
||||
"host_interface": method.host_interface,
|
||||
"host_name": method.host_name,
|
||||
"sdk_name": method.sdk_name,
|
||||
"sdk_qualified_name": method.sdk_qualified_name,
|
||||
"description": method.description,
|
||||
"required_permission": method.required_permission,
|
||||
"require_auth": method.require_auth,
|
||||
"request_schema": method.request_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
"response_schema": method.response_model.schema(
|
||||
ref_template="#/definitions/{model}"
|
||||
),
|
||||
}
|
||||
for method in list_extension_api_methods(api_cls)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _default_api_cls(api_cls: type[Any] | None) -> type[Any]:
|
||||
if api_cls is not None:
|
||||
return api_cls
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
return ExtensionHostAPI
|
||||
|
||||
|
||||
def _extension_api_method_sources(
|
||||
api_cls: type[Any],
|
||||
) -> list[tuple[str, type[Any]]]:
|
||||
sources: list[tuple[str, type[Any]]] = [("", api_cls)]
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
|
||||
if issubclass(api_cls, ExtensionHostAPI):
|
||||
from .utils import extension_api_utils_method_classes
|
||||
|
||||
sources.extend(extension_api_utils_method_classes().items())
|
||||
return sources
|
||||
|
||||
|
||||
def _get_method_models(
|
||||
function: Callable[..., object],
|
||||
) -> tuple[type[BaseModel], type[BaseModel]]:
|
||||
signature = inspect.signature(function)
|
||||
request_parameters = [
|
||||
parameter
|
||||
for parameter in signature.parameters.values()
|
||||
if parameter.name != "self"
|
||||
]
|
||||
if len(request_parameters) != 1:
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' must accept one request model."
|
||||
)
|
||||
|
||||
hints = get_type_hints(function)
|
||||
request_model = hints.get(request_parameters[0].name)
|
||||
response_model = hints.get("return")
|
||||
|
||||
if not _is_pydantic_model(request_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' request must be a BaseModel."
|
||||
)
|
||||
if not _is_pydantic_model(response_model):
|
||||
raise TypeError(
|
||||
f"Extension API method '{function.__name__}' response must be a BaseModel."
|
||||
)
|
||||
|
||||
return cast(type[BaseModel], request_model), cast(type[BaseModel], response_model)
|
||||
|
||||
|
||||
def _is_pydantic_model(value: object) -> bool:
|
||||
return isinstance(value, type) and issubclass(value, BaseModel)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .host import ExtensionHostAPI
|
||||
from .models import ExtensionAPIMethod
|
||||
from .registry import list_extension_api_methods
|
||||
|
||||
HostImport = Callable[..., Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class ExtensionAPIHost:
|
||||
def __init__(
|
||||
self,
|
||||
api: ExtensionHostAPI,
|
||||
*,
|
||||
api_cls: type[ExtensionHostAPI] = ExtensionHostAPI,
|
||||
) -> None:
|
||||
self.api = api
|
||||
self.methods = list_extension_api_methods(api_cls)
|
||||
self._methods_by_host_name = self._index_methods(self.methods)
|
||||
|
||||
async def invoke(
|
||||
self,
|
||||
host_name: str,
|
||||
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)
|
||||
if inspect.isawaitable(response):
|
||||
response = await response
|
||||
return self._response_payload(method, response)
|
||||
|
||||
def imports(self) -> dict[str, HostImport]:
|
||||
return self.imports_for_interface("host")
|
||||
|
||||
def import_object(self) -> dict[str, dict[str, HostImport]]:
|
||||
interfaces = sorted({method.host_interface for method in self.methods})
|
||||
return {
|
||||
f"lnbits:extension/{interface}": self.imports_for_interface(interface)
|
||||
for interface in interfaces
|
||||
}
|
||||
|
||||
def imports_for_interface(self, host_interface: str) -> dict[str, HostImport]:
|
||||
return {
|
||||
_snake_to_camel(method.host_name): self._make_import(method)
|
||||
for method in self.methods
|
||||
if method.host_interface == host_interface
|
||||
}
|
||||
|
||||
def _make_import(self, method: ExtensionAPIMethod) -> HostImport:
|
||||
async def host_import(
|
||||
payload: Mapping[str, Any] | BaseModel | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.invoke(method.method_id, payload)
|
||||
|
||||
return host_import
|
||||
|
||||
def _require_method(self, host_name: str) -> ExtensionAPIMethod:
|
||||
method = self._methods_by_host_name.get(host_name)
|
||||
if not method:
|
||||
raise KeyError(f"Unknown extension host function '{host_name}'.")
|
||||
return method
|
||||
|
||||
@staticmethod
|
||||
def _index_methods(
|
||||
methods: list[ExtensionAPIMethod],
|
||||
) -> dict[str, ExtensionAPIMethod]:
|
||||
index: dict[str, ExtensionAPIMethod] = {}
|
||||
for method in methods:
|
||||
for host_name in {
|
||||
method.method_id,
|
||||
f"{method.host_interface}:{method.host_name}",
|
||||
method.host_name,
|
||||
_snake_to_camel(method.host_name),
|
||||
method.host_name.replace("_", "-"),
|
||||
}:
|
||||
index[host_name] = method
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _request_model(
|
||||
method: ExtensionAPIMethod,
|
||||
payload: Mapping[str, Any] | BaseModel | None,
|
||||
) -> BaseModel:
|
||||
if isinstance(payload, method.request_model):
|
||||
return payload
|
||||
if isinstance(payload, BaseModel):
|
||||
payload = payload.dict()
|
||||
if payload is None:
|
||||
payload = {}
|
||||
if not isinstance(payload, Mapping):
|
||||
raise TypeError(
|
||||
f"Host function '{method.host_name}' expects an object payload."
|
||||
)
|
||||
data = {_to_snake(key): value for key, value in payload.items()}
|
||||
if isinstance(data.get("extra"), list):
|
||||
data["extra"] = dict(data["extra"])
|
||||
if isinstance(data.get("headers"), list):
|
||||
data["headers"] = dict(data["headers"])
|
||||
return method.request_model.parse_obj(data)
|
||||
|
||||
@staticmethod
|
||||
def _response_payload(
|
||||
method: ExtensionAPIMethod,
|
||||
response: Any,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(response, method.response_model):
|
||||
response = method.response_model.parse_obj(response)
|
||||
payload = response.dict()
|
||||
if method.method_id in {"http.request", "extension.api.request"} and isinstance(
|
||||
payload.get("headers"), Mapping
|
||||
):
|
||||
payload["headers"] = list(payload["headers"].items())
|
||||
return {_snake_to_camel(key): value for key, value in payload.items()}
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _to_snake(value: str) -> str:
|
||||
value = value.replace("-", "_")
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value).lower()
|
||||
|
||||
|
||||
def _resolve_attr_path(value: Any, path: str) -> Any:
|
||||
for part in path.split("."):
|
||||
value = getattr(value, part)
|
||||
return value
|
||||
@@ -0,0 +1,475 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from lnurl import LnurlErrorResponse, LnurlPayResponse, LnurlResponseException
|
||||
from lnurl import handle as lnurl_handle
|
||||
|
||||
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 .lnurl import (
|
||||
lnurl_pay_response_int,
|
||||
lnurl_pay_response_metadata_json,
|
||||
lnurl_pay_response_text,
|
||||
normalize_lnurl,
|
||||
)
|
||||
from .models import (
|
||||
Bolt11Request,
|
||||
CurrencyConvertRequest,
|
||||
CurrencyConvertResponse,
|
||||
CurrencyListResponse,
|
||||
CurrencyRateRequest,
|
||||
CurrencyRateResponse,
|
||||
DecodeInvoiceResponse,
|
||||
EmptyRequest,
|
||||
FiatToSatsRequest,
|
||||
FiatToSatsResponse,
|
||||
InvoiceAmountMsatResponse,
|
||||
InvoiceExpiryResponse,
|
||||
InvoiceMemoResponse,
|
||||
InvoicePaymentHashResponse,
|
||||
LnurlResolveRequest,
|
||||
LnurlResolveResponse,
|
||||
RandomSecretAndHashRequest,
|
||||
RandomSecretAndHashResponse,
|
||||
SatsToFiatRequest,
|
||||
SatsToFiatResponse,
|
||||
ServerHealthResponse,
|
||||
ValidateInvoiceResponse,
|
||||
VerifyPreimageRequest,
|
||||
VerifyPreimageResponse,
|
||||
)
|
||||
from .registry import extension_api_method
|
||||
|
||||
|
||||
class ExtensionAPIUtils:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
permission_set = set(permissions)
|
||||
self.currencies = ExtensionCurrencyUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.server = ExtensionServerUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.lightning = ExtensionLightningUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
self.lnurl = ExtensionLnurlUtils(
|
||||
extension_id, permission_set, authenticated=authenticated
|
||||
)
|
||||
|
||||
|
||||
class _ExtensionAPIUtilsGroup:
|
||||
def __init__(
|
||||
self,
|
||||
extension_id: str,
|
||||
permissions: Iterable[str],
|
||||
*,
|
||||
authenticated: bool = False,
|
||||
) -> None:
|
||||
self.extension_id = extension_id
|
||||
self.permissions = set(permissions)
|
||||
self.authenticated = authenticated
|
||||
|
||||
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 self.authenticated
|
||||
|
||||
|
||||
class ExtensionCurrencyUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.list",
|
||||
namespace="utils.currencies",
|
||||
name="List currencies",
|
||||
host_interface="utils-currencies",
|
||||
host_name="list_currencies",
|
||||
sdk_name="list",
|
||||
description="List currencies supported by LNbits exchange-rate conversion.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def list(self, request: EmptyRequest) -> CurrencyListResponse:
|
||||
|
||||
return CurrencyListResponse(currencies=allowed_currencies())
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.rate",
|
||||
namespace="utils.currencies",
|
||||
name="Get currency rate",
|
||||
host_interface="utils-currencies",
|
||||
host_name="rate",
|
||||
sdk_name="rate",
|
||||
description="Get sats-per-fiat and BTC price for a currency.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def rate(self, request: CurrencyRateRequest) -> CurrencyRateResponse:
|
||||
|
||||
rate, price = await get_fiat_rate_and_price_satoshis(request.currency)
|
||||
return CurrencyRateResponse(rate=rate, price=price)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.convert",
|
||||
namespace="utils.currencies",
|
||||
name="Convert currency amount",
|
||||
host_interface="utils-currencies",
|
||||
host_name="convert",
|
||||
sdk_name="convert",
|
||||
description="Convert between sats, BTC, and supported fiat currencies.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def convert(self, request: CurrencyConvertRequest) -> CurrencyConvertResponse:
|
||||
|
||||
from_currency = request.from_currency
|
||||
if from_currency == "sats":
|
||||
from_currency = "sat"
|
||||
|
||||
amounts: list[tuple[str, float]] = []
|
||||
if from_currency == "sat":
|
||||
sats = int(request.amount)
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
amounts.append(("sats", sats))
|
||||
for currency in request.to.split(","):
|
||||
currency = currency.strip()
|
||||
if currency:
|
||||
amounts.append(
|
||||
(
|
||||
currency.upper(),
|
||||
await satoshis_amount_as_fiat(sats, currency),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sats = await fiat_amount_as_satoshis(request.amount, from_currency)
|
||||
amounts.append((from_currency.upper(), request.amount))
|
||||
amounts.append(("sats", sats))
|
||||
amounts.append(("BTC", sats / 100_000_000))
|
||||
return CurrencyConvertResponse(amounts=amounts)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.fiat_to_sats",
|
||||
namespace="utils.currencies",
|
||||
name="Convert fiat to sats",
|
||||
host_interface="utils-currencies",
|
||||
host_name="fiat_to_sats",
|
||||
sdk_name="fiatToSats",
|
||||
description="Convert a fiat amount to sats.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def fiat_to_sats(self, request: FiatToSatsRequest) -> FiatToSatsResponse:
|
||||
|
||||
return FiatToSatsResponse(
|
||||
amount_sat=await fiat_amount_as_satoshis(
|
||||
request.amount,
|
||||
request.currency,
|
||||
)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.currencies.sats_to_fiat",
|
||||
namespace="utils.currencies",
|
||||
name="Convert sats to fiat",
|
||||
host_interface="utils-currencies",
|
||||
host_name="sats_to_fiat",
|
||||
sdk_name="satsToFiat",
|
||||
description="Convert a sats amount to fiat.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def sats_to_fiat(self, request: SatsToFiatRequest) -> SatsToFiatResponse:
|
||||
|
||||
return SatsToFiatResponse(
|
||||
amount=await satoshis_amount_as_fiat(request.amount, request.currency)
|
||||
)
|
||||
|
||||
|
||||
class ExtensionServerUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.server.health",
|
||||
namespace="utils.server",
|
||||
name="Server health",
|
||||
host_interface="utils-server",
|
||||
host_name="health",
|
||||
sdk_name="health",
|
||||
description="Return basic public LNbits server health data.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def health(self, request: EmptyRequest) -> ServerHealthResponse:
|
||||
|
||||
return ServerHealthResponse(
|
||||
server_time=int(time.time()),
|
||||
up_time=settings.lnbits_server_up_time,
|
||||
)
|
||||
|
||||
|
||||
class ExtensionLnurlUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.lnurl.resolve",
|
||||
namespace="utils.lnurl",
|
||||
name="Resolve LNURL-pay",
|
||||
host_interface="utils-lnurl",
|
||||
host_name="resolve",
|
||||
sdk_name="resolve",
|
||||
description="Resolve a Lightning Address or LNURL-pay request.",
|
||||
required_permission="wallet.pay_invoice",
|
||||
require_auth=True,
|
||||
)
|
||||
async def resolve(self, request: LnurlResolveRequest) -> LnurlResolveResponse:
|
||||
normalized_lnurl = normalize_lnurl(request.lnurl)
|
||||
response = await lnurl_handle(
|
||||
normalized_lnurl,
|
||||
user_agent=settings.user_agent,
|
||||
timeout=5,
|
||||
)
|
||||
if isinstance(response, LnurlErrorResponse):
|
||||
raise LnurlResponseException(response.reason)
|
||||
if not isinstance(response, LnurlPayResponse):
|
||||
raise LnurlResponseException(
|
||||
"Invalid LNURL response. Expected LnurlPayResponse."
|
||||
)
|
||||
|
||||
min_sendable_msat = lnurl_pay_response_int(
|
||||
response, "min_sendable", "minSendable"
|
||||
)
|
||||
max_sendable_msat = lnurl_pay_response_int(
|
||||
response, "max_sendable", "maxSendable"
|
||||
)
|
||||
image = getattr(response, "image", None)
|
||||
return LnurlResolveResponse(
|
||||
lnurl=normalized_lnurl,
|
||||
domain=getattr(response, "domain", None),
|
||||
description=lnurl_pay_response_text(response),
|
||||
min_sendable_msat=min_sendable_msat,
|
||||
max_sendable_msat=max_sendable_msat,
|
||||
comment_allowed=lnurl_pay_response_int(
|
||||
response, "comment_allowed", "commentAllowed"
|
||||
),
|
||||
fixed=bool(
|
||||
getattr(
|
||||
response,
|
||||
"fixed",
|
||||
min_sendable_msat == max_sendable_msat,
|
||||
)
|
||||
),
|
||||
image=str(image) if image is not None else None,
|
||||
metadata_json=lnurl_pay_response_metadata_json(response),
|
||||
)
|
||||
|
||||
|
||||
class ExtensionLightningUtils(_ExtensionAPIUtilsGroup):
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.decode_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Decode Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="decode_invoice",
|
||||
sdk_name="decodeInvoice",
|
||||
description="Decode a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def decode_invoice(self, request: Bolt11Request) -> DecodeInvoiceResponse:
|
||||
invoice = _decode_bolt11(request.bolt11)
|
||||
return _decoded_invoice_response(invoice)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.validate_invoice",
|
||||
namespace="utils.lightning",
|
||||
name="Validate Lightning invoice",
|
||||
host_interface="utils-lightning",
|
||||
host_name="validate_invoice",
|
||||
sdk_name="validateInvoice",
|
||||
description="Validate whether a string is a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def validate_invoice(self, request: Bolt11Request) -> ValidateInvoiceResponse:
|
||||
try:
|
||||
_decode_bolt11(request.bolt11)
|
||||
return ValidateInvoiceResponse(valid=True)
|
||||
except Exception as exc:
|
||||
return ValidateInvoiceResponse(valid=False, error=str(exc))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_payment_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice payment hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_payment_hash",
|
||||
sdk_name="invoicePaymentHash",
|
||||
description="Get the payment hash from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_payment_hash(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoicePaymentHashResponse:
|
||||
return InvoicePaymentHashResponse(
|
||||
payment_hash=str(_decode_bolt11(request.bolt11).payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_amount_msat",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice amount",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_amount_msat",
|
||||
sdk_name="invoiceAmountMsat",
|
||||
description="Get the amount in msat from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_amount_msat(
|
||||
self, request: Bolt11Request
|
||||
) -> InvoiceAmountMsatResponse:
|
||||
return InvoiceAmountMsatResponse(
|
||||
amount_msat=_invoice_amount_msat(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_expiry",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice expiry",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_expiry",
|
||||
sdk_name="invoiceExpiry",
|
||||
description="Get the expiry timestamp from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_expiry(self, request: Bolt11Request) -> InvoiceExpiryResponse:
|
||||
return InvoiceExpiryResponse(
|
||||
expires_at=_invoice_expires_at(_decode_bolt11(request.bolt11))
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.invoice_memo",
|
||||
namespace="utils.lightning",
|
||||
name="Get Lightning invoice memo",
|
||||
host_interface="utils-lightning",
|
||||
host_name="invoice_memo",
|
||||
sdk_name="invoiceMemo",
|
||||
description="Get the memo from a BOLT11 Lightning invoice.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def invoice_memo(self, request: Bolt11Request) -> InvoiceMemoResponse:
|
||||
return InvoiceMemoResponse(memo=_invoice_memo(_decode_bolt11(request.bolt11)))
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.verify_preimage",
|
||||
namespace="utils.lightning",
|
||||
name="Verify Lightning preimage",
|
||||
host_interface="utils-lightning",
|
||||
host_name="verify_preimage",
|
||||
sdk_name="verifyPreimage",
|
||||
description="Verify that a preimage matches a payment hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def verify_preimage(
|
||||
self, request: VerifyPreimageRequest
|
||||
) -> VerifyPreimageResponse:
|
||||
|
||||
return VerifyPreimageResponse(
|
||||
valid=verify_preimage(request.preimage, request.payment_hash)
|
||||
)
|
||||
|
||||
@extension_api_method(
|
||||
method_id="utils.lightning.random_secret_and_hash",
|
||||
namespace="utils.lightning",
|
||||
name="Random Lightning secret and hash",
|
||||
host_interface="utils-lightning",
|
||||
host_name="random_secret_and_hash",
|
||||
sdk_name="randomSecretAndHash",
|
||||
description="Create a random secret and matching SHA256 hash.",
|
||||
required_permission="utils.basic",
|
||||
require_auth=False,
|
||||
)
|
||||
async def random_secret_and_hash(
|
||||
self, request: RandomSecretAndHashRequest
|
||||
) -> RandomSecretAndHashResponse:
|
||||
|
||||
secret, payment_hash = random_secret_and_hash(request.length)
|
||||
return RandomSecretAndHashResponse(secret=secret, hash=payment_hash)
|
||||
|
||||
|
||||
def extension_api_utils_method_classes() -> dict[str, type[_ExtensionAPIUtilsGroup]]:
|
||||
return {
|
||||
"utils.currencies": ExtensionCurrencyUtils,
|
||||
"utils.server": ExtensionServerUtils,
|
||||
"utils.lnurl": ExtensionLnurlUtils,
|
||||
"utils.lightning": ExtensionLightningUtils,
|
||||
}
|
||||
|
||||
|
||||
def _decode_bolt11(payment_request: str) -> Any:
|
||||
|
||||
return bolt11.decode(payment_request)
|
||||
|
||||
|
||||
def _decoded_invoice_response(invoice: Any) -> DecodeInvoiceResponse:
|
||||
return DecodeInvoiceResponse(
|
||||
payment_hash=str(getattr(invoice, "payment_hash", "")) or None,
|
||||
amount_msat=_invoice_amount_msat(invoice),
|
||||
expiry=_invoice_expiry(invoice),
|
||||
expires_at=_invoice_expires_at(invoice),
|
||||
memo=_invoice_memo(invoice),
|
||||
)
|
||||
|
||||
|
||||
def _invoice_amount_msat(invoice: Any) -> int | None:
|
||||
amount_msat = getattr(invoice, "amount_msat", None)
|
||||
if amount_msat is None:
|
||||
return None
|
||||
return int(amount_msat)
|
||||
|
||||
|
||||
def _invoice_expiry(invoice: Any) -> int | None:
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if expiry is None:
|
||||
return None
|
||||
return int(expiry)
|
||||
|
||||
|
||||
def _invoice_expires_at(invoice: Any) -> int | None:
|
||||
expiry_date = getattr(invoice, "expiry_date", None)
|
||||
if isinstance(expiry_date, datetime):
|
||||
return int(expiry_date.timestamp())
|
||||
|
||||
date = getattr(invoice, "date", None)
|
||||
expiry = getattr(invoice, "expiry", None)
|
||||
if isinstance(date, datetime) and expiry is not None:
|
||||
return int(date.timestamp() + int(expiry))
|
||||
if isinstance(date, (int, float)) and expiry is not None:
|
||||
return int(date + int(expiry))
|
||||
return None
|
||||
|
||||
|
||||
def _invoice_memo(invoice: Any) -> str | None:
|
||||
memo = getattr(invoice, "description", None)
|
||||
return str(memo) if memo is not None else None
|
||||
@@ -0,0 +1,4 @@
|
||||
from .extensions import send_extension_api_request
|
||||
from .http import send_extension_http_request
|
||||
|
||||
__all__ = ["send_extension_api_request", "send_extension_http_request"]
|
||||
@@ -0,0 +1,219 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlsplit, urlunsplit
|
||||
|
||||
import httpx
|
||||
|
||||
from lnbits.core.crud.extensions import (
|
||||
get_installed_extension,
|
||||
get_user_active_extensions_ids,
|
||||
)
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..api.models import ExtensionApiRequest, HttpResponse
|
||||
|
||||
EXTENSION_API_TIMEOUT_SECONDS = 10.0
|
||||
EXTENSION_API_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_READ_METHODS = {"GET", "HEAD"}
|
||||
_WRITE_METHODS = {"DELETE", "PATCH", "POST", "PUT"}
|
||||
_EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
async def send_extension_api_request(
|
||||
caller_extension_id: str,
|
||||
policies: list[Any],
|
||||
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.")
|
||||
if not access_token:
|
||||
raise PermissionError("Extension API requests require an account access token.")
|
||||
|
||||
target_extension_id = _target_extension_id(request.extension_id)
|
||||
access = _target_extension_access(policies, target_extension_id)
|
||||
_require_method_access(caller_extension_id, target_extension_id, access, request)
|
||||
await _require_enabled_extension(target_extension_id, user_id)
|
||||
|
||||
path = _extension_api_path(request.path)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("Extension API request body is too large.")
|
||||
|
||||
url = f"http://{settings.host}:{settings.port}/{target_extension_id}{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, EXTENSION_API_TIMEOUT_SECONDS),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("Extension API request failed.") from exc
|
||||
|
||||
|
||||
def _target_extension_id(extension_id: str) -> str:
|
||||
target = extension_id.strip()
|
||||
if not target or not _EXTENSION_ID_RE.match(target):
|
||||
raise PermissionError("Extension API request has an invalid target extension.")
|
||||
return target
|
||||
|
||||
|
||||
def _target_extension_access(policies: list[Any], target_extension_id: str) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError(
|
||||
"Extension API requests require a non-empty extensions policy."
|
||||
)
|
||||
|
||||
for extension in policies:
|
||||
if isinstance(extension, str):
|
||||
extension_id = extension
|
||||
access = ["read"]
|
||||
elif isinstance(extension, dict):
|
||||
raw_extension_id = extension.get("id")
|
||||
raw_access = extension.get("access")
|
||||
if not isinstance(raw_extension_id, str):
|
||||
continue
|
||||
if not isinstance(raw_access, list):
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' "
|
||||
"has no access policy."
|
||||
)
|
||||
extension_id = raw_extension_id
|
||||
access = raw_access
|
||||
else:
|
||||
continue
|
||||
|
||||
if extension_id != target_extension_id:
|
||||
continue
|
||||
clean_access = {
|
||||
item
|
||||
for item in access
|
||||
if isinstance(item, str) and item in {"read", "write"}
|
||||
}
|
||||
if clean_access:
|
||||
return clean_access
|
||||
break
|
||||
|
||||
raise PermissionError(
|
||||
f"Extension API target '{target_extension_id}' is not allowed."
|
||||
)
|
||||
|
||||
|
||||
def _require_method_access(
|
||||
caller_extension_id: str,
|
||||
target_extension_id: str,
|
||||
access: set[str],
|
||||
request: ExtensionApiRequest,
|
||||
) -> None:
|
||||
if request.method in _READ_METHODS:
|
||||
required_access = "read"
|
||||
elif request.method in _WRITE_METHODS:
|
||||
required_access = "write"
|
||||
else:
|
||||
raise PermissionError("Extension API request method is not allowed.")
|
||||
|
||||
if required_access not in access:
|
||||
raise PermissionError(
|
||||
f"Extension '{caller_extension_id}' cannot {required_access} "
|
||||
f"extension '{target_extension_id}'."
|
||||
)
|
||||
|
||||
|
||||
async def _require_enabled_extension(target_extension_id: str, user_id: str) -> None:
|
||||
extension = await get_installed_extension(target_extension_id)
|
||||
if not extension or not extension.active:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not installed or enabled."
|
||||
)
|
||||
|
||||
active_extensions = await get_user_active_extensions_ids(user_id)
|
||||
if target_extension_id not in active_extensions:
|
||||
raise PermissionError(
|
||||
f"Target extension '{target_extension_id}' is not active for this user."
|
||||
)
|
||||
|
||||
|
||||
def _extension_api_path(path: str) -> str:
|
||||
parts = urlsplit(path)
|
||||
if parts.scheme or parts.netloc:
|
||||
raise PermissionError("Extension API request path must be relative.")
|
||||
if parts.fragment:
|
||||
raise PermissionError("Extension API request path cannot include a fragment.")
|
||||
if not parts.path.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path must start with '/api/'.")
|
||||
|
||||
decoded_path = unquote(parts.path)
|
||||
path_parts = decoded_path.split("/")
|
||||
if any(part == ".." for part in path_parts):
|
||||
raise PermissionError("Extension API request path cannot traverse directories.")
|
||||
|
||||
normalized = posixpath.normpath(decoded_path)
|
||||
if normalized != decoded_path.rstrip("/") or not normalized.startswith("/api/"):
|
||||
raise PermissionError("Extension API request path is invalid.")
|
||||
|
||||
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
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
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
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..api.models import HttpRequest, HttpResponse
|
||||
|
||||
HTTP_REQUEST_TIMEOUT_SECONDS = 10.0
|
||||
HTTP_MAX_RESPONSE_BYTES = 262_144
|
||||
|
||||
_FORBIDDEN_REQUEST_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"cookie",
|
||||
"host",
|
||||
"proxy-authorization",
|
||||
"transfer-encoding",
|
||||
}
|
||||
_FORBIDDEN_RESPONSE_HEADERS = {
|
||||
"connection",
|
||||
"content-length",
|
||||
"set-cookie",
|
||||
"transfer-encoding",
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
if origin not in allowed_origins:
|
||||
raise PermissionError(
|
||||
f"Extension '{extension_id}' is not allowed to request '{origin}'."
|
||||
)
|
||||
|
||||
await _reject_internal_host(request.url)
|
||||
headers = _request_headers(request.headers)
|
||||
body = request.body.encode() if request.body is not None else b""
|
||||
if len(body) > 65_536:
|
||||
raise ValueError("HTTP request body is too large.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=_timeout_seconds(timeout_ms, HTTP_REQUEST_TIMEOUT_SECONDS),
|
||||
trust_env=False,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
request.method,
|
||||
request.url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
) as response:
|
||||
response_body = await _read_limited_response(
|
||||
response,
|
||||
max_response_bytes=max_response_bytes,
|
||||
)
|
||||
return HttpResponse(
|
||||
status_code=response.status_code,
|
||||
headers=_response_headers(dict(response.headers)),
|
||||
body=response_body.decode(response.encoding or "utf-8", "replace"),
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError("HTTP request failed.") from exc
|
||||
|
||||
|
||||
def _allowed_origins(policies: list[Any]) -> set[str]:
|
||||
if not isinstance(policies, list) or not policies:
|
||||
raise PermissionError("HTTP requests require a non-empty hosts policy.")
|
||||
|
||||
origins: set[str] = set()
|
||||
for policy in policies:
|
||||
host = policy.get("host") if isinstance(policy, dict) else policy
|
||||
if not isinstance(host, str) or not host:
|
||||
continue
|
||||
origins.add(_request_origin(host))
|
||||
if not origins:
|
||||
raise PermissionError("HTTP requests require at least one valid host.")
|
||||
return origins
|
||||
|
||||
|
||||
def _request_origin(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise PermissionError("HTTP requests require https URLs.")
|
||||
if parsed.username or parsed.password:
|
||||
raise PermissionError("HTTP requests cannot include credentials in URLs.")
|
||||
if not parsed.hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
|
||||
hostname = parsed.hostname.lower()
|
||||
port = _url_port(parsed)
|
||||
if port is None or port == 443:
|
||||
return f"https://{hostname}"
|
||||
return f"https://{hostname}:{port}"
|
||||
|
||||
|
||||
def _url_port(parsed: Any) -> int | None:
|
||||
try:
|
||||
return parsed.port
|
||||
except ValueError as exc:
|
||||
raise PermissionError("HTTP request URL has an invalid port.") from exc
|
||||
|
||||
|
||||
async def _reject_internal_host(url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise PermissionError("HTTP requests require a hostname.")
|
||||
if hostname == "localhost" or hostname.endswith(".localhost"):
|
||||
raise PermissionError("HTTP requests cannot target localhost.")
|
||||
|
||||
try:
|
||||
address = ipaddress.ip_address(hostname)
|
||||
_reject_internal_address(address)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
for address in await _resolve_host(hostname):
|
||||
_reject_internal_address(address)
|
||||
|
||||
|
||||
async def _resolve_host(
|
||||
hostname: str,
|
||||
) -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
import asyncio
|
||||
|
||||
def resolve() -> list[ipaddress.IPv4Address | ipaddress.IPv6Address]:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise PermissionError("HTTP request host could not be resolved.") from exc
|
||||
|
||||
addresses: list[ipaddress.IPv4Address | ipaddress.IPv6Address] = []
|
||||
for info in infos:
|
||||
sockaddr = info[4]
|
||||
addresses.append(ipaddress.ip_address(sockaddr[0]))
|
||||
return addresses
|
||||
|
||||
return await asyncio.to_thread(resolve)
|
||||
|
||||
|
||||
def _reject_internal_address(
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
) -> None:
|
||||
if not address.is_global:
|
||||
raise PermissionError("HTTP requests cannot target internal network addresses.")
|
||||
|
||||
|
||||
def _request_headers(headers: dict[str, str]) -> dict[str, str]:
|
||||
clean: dict[str, str] = {}
|
||||
for key, value in headers.items():
|
||||
header = key.strip()
|
||||
if not header:
|
||||
continue
|
||||
if header.lower() in _FORBIDDEN_REQUEST_HEADERS:
|
||||
continue
|
||||
clean[header] = value
|
||||
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
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
size += len(chunk)
|
||||
if limit > 0 and size > limit:
|
||||
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
|
||||
for key, value in headers.items()
|
||||
if key.lower() not in _FORBIDDEN_RESPONSE_HEADERS
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .register import register_wasm_extension
|
||||
|
||||
__all__ = ["register_wasm_extension"]
|
||||
@@ -0,0 +1,261 @@
|
||||
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:
|
||||
_add_wasm_extension_api_route(app, extension, route_config)
|
||||
|
||||
|
||||
def _add_wasm_extension_api_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_config: WasmAPIRouteConfig,
|
||||
) -> 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)
|
||||
|
||||
if _has_route(app, route_path, method):
|
||||
return
|
||||
|
||||
async def invoke_wasm_api_request(
|
||||
request: Request,
|
||||
account: Account | None = None,
|
||||
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"],
|
||||
)
|
||||
return await invoke_wasm_extension_export(
|
||||
extension.id,
|
||||
export_name,
|
||||
payload.data,
|
||||
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:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
async def invoke_private_wasm_extension_export(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request, account, access_token)
|
||||
|
||||
async def invoke_public_wasm_extension_export(request: Request) -> dict[str, Any]:
|
||||
return await invoke_wasm_api_request(request)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
invoke_public_wasm_extension_export
|
||||
if auth == "public"
|
||||
else invoke_private_wasm_extension_export
|
||||
),
|
||||
methods=[method],
|
||||
name=f"{extension.id}:{method}:{route_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
async def _read_api_payload(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
*,
|
||||
max_body_bytes: int,
|
||||
) -> WasmRoutePayload:
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
if not body:
|
||||
return {}, 0
|
||||
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)
|
||||
|
||||
|
||||
def _read_api_path_params(
|
||||
request: Request,
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in request.path_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
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:
|
||||
continue
|
||||
if export.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}'.")
|
||||
|
||||
|
||||
def _wasm_extension_api_method(extension: WasmExtension, method: Any) -> str:
|
||||
if not isinstance(method, str):
|
||||
raise ValueError(f"Invalid API method for WASM extension '{extension.id}'.")
|
||||
method = method.upper()
|
||||
if method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
|
||||
raise ValueError(f"Unsupported API method for WASM extension '{extension.id}'.")
|
||||
return method
|
||||
|
||||
|
||||
def _wasm_extension_api_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid API path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return f"/api/v1/ext/{extension.id}"
|
||||
return f"/api/v1/ext/{extension.id}{path}"
|
||||
|
||||
|
||||
def _wasm_extension_route_auth(extension: WasmExtension, auth: Any) -> str:
|
||||
if auth in {"public", "user"}:
|
||||
return auth
|
||||
raise ValueError(f"Invalid route auth for WASM extension '{extension.id}'.")
|
||||
|
||||
|
||||
def _has_route(app: FastAPI, route_path: str, method: str) -> bool:
|
||||
for route in app.routes:
|
||||
if getattr(route, "path", None) != route_path:
|
||||
continue
|
||||
methods = getattr(route, "methods", set()) or set()
|
||||
if method in methods:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _snake_to_camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _path_template_pattern(path: str) -> str:
|
||||
pattern = re.sub(r"\\{[^/{}]+\\}", r"[^/]+", re.escape(path))
|
||||
return f"^{pattern}$"
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.staticfiles import PathLike as StaticFilesPathLike
|
||||
from starlette.types import Scope
|
||||
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_EXTENSION_CORE_ASSET_PREFIX = "_lnbits"
|
||||
WASM_EXTENSION_CORE_STATIC_ASSETS = {
|
||||
"bundle.min.css": ("static/bundle.min.css", "text/css; charset=utf-8"),
|
||||
"material-icons-v50.woff2": (
|
||||
"static/fonts/material-icons-v50.woff2",
|
||||
"font/woff2",
|
||||
),
|
||||
"quasar.css": ("static/vendor/quasar.css", "text/css; charset=utf-8"),
|
||||
"quasar.umd.prod.js": (
|
||||
"static/vendor/quasar.umd.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"qrcode.vue.browser.js": (
|
||||
"static/vendor/qrcode.vue.browser.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
"vue.global.prod.js": (
|
||||
"static/vendor/vue.global.prod.js",
|
||||
"text/javascript; charset=utf-8",
|
||||
),
|
||||
}
|
||||
WASM_EXTENSION_GENERATED_CORE_ASSETS = {
|
||||
"material-icons.css": (
|
||||
"""
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url('./material-icons-v50.woff2') format('woff2');
|
||||
}
|
||||
""",
|
||||
"text/css; charset=utf-8",
|
||||
)
|
||||
}
|
||||
WASM_EXTENSION_STATIC_MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
WASM_EXTENSION_TEXT_STATIC_EXTENSIONS = {".css", ".js"}
|
||||
WASM_EXTENSION_HTML_PREFIXES = (b"<!doctype", b"<html", b"<script")
|
||||
|
||||
|
||||
class GuardedWasmExtensionStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
if path.startswith(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/"):
|
||||
return _wasm_extension_core_asset_response(path)
|
||||
if Path(path).suffix.lower() not in WASM_EXTENSION_STATIC_MIME_TYPES:
|
||||
raise HTTPException(status_code=404)
|
||||
return await super().get_response(path, scope)
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path: StaticFilesPathLike,
|
||||
stat_result: os.stat_result,
|
||||
scope: Scope,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
suffix = Path(full_path).suffix.lower()
|
||||
if suffix in WASM_EXTENSION_TEXT_STATIC_EXTENSIONS:
|
||||
_reject_html_like_wasm_static_asset(Path(full_path))
|
||||
|
||||
response = super().file_response(full_path, stat_result, scope, status_code)
|
||||
response.headers["Content-Type"] = WASM_EXTENSION_STATIC_MIME_TYPES[suffix]
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def mount_wasm_extension_static(app: FastAPI, extension: WasmExtension) -> None:
|
||||
static_path = extension.root_path / "static"
|
||||
|
||||
mount_path = f"/ext-assets/{extension.id}"
|
||||
if any(getattr(route, "path", None) == mount_path for route in app.routes):
|
||||
return
|
||||
|
||||
app.mount(
|
||||
mount_path,
|
||||
GuardedWasmExtensionStaticFiles(directory=static_path, check_dir=False),
|
||||
name=f"{extension.id}-static",
|
||||
)
|
||||
|
||||
|
||||
def _reject_html_like_wasm_static_asset(path: Path) -> None:
|
||||
with path.open("rb") as asset_file:
|
||||
prefix = asset_file.read(512).lstrip().lower()
|
||||
if prefix.startswith(WASM_EXTENSION_HTML_PREFIXES):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
|
||||
def _wasm_extension_core_asset_response(path: str) -> Response:
|
||||
asset_name = path.removeprefix(f"{WASM_EXTENSION_CORE_ASSET_PREFIX}/")
|
||||
if not asset_name or "/" in asset_name or "\\" in asset_name:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
generated_asset = WASM_EXTENSION_GENERATED_CORE_ASSETS.get(asset_name)
|
||||
if generated_asset:
|
||||
content, content_type = generated_asset
|
||||
response = Response(content=content, media_type=content_type)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
asset_config = WASM_EXTENSION_CORE_STATIC_ASSETS.get(asset_name)
|
||||
if not asset_config:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
relative_path, content_type = asset_config
|
||||
asset_path = Path(settings.lnbits_path, relative_path)
|
||||
if not asset_path.is_file():
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
response = FileResponse(asset_path)
|
||||
response.headers["Content-Type"] = content_type
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..wasm.component import warm_wasm_extension
|
||||
from ..wasm.loader import WasmExtension, load_wasm_extension
|
||||
from .api import register_wasm_extension_api_routes
|
||||
from .assets import mount_wasm_extension_static
|
||||
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)
|
||||
register_wasm_extension_ui_routes(app, loaded)
|
||||
register_wasm_extension_api_routes(app, loaded)
|
||||
|
||||
core_app_extra.wasm_extension_registry.register(loaded)
|
||||
|
||||
settings.activate_extension_paths(ext_id, "", [])
|
||||
logger.info(
|
||||
f"Loaded WASM extension '{loaded.id}' "
|
||||
f"({loaded.module_path.stat().st_size} bytes)."
|
||||
)
|
||||
return loaded
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, NoReturn
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.utils.cache import cache
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
|
||||
WASM_FRAME_TOKEN_EXPIRY_SECONDS = 60
|
||||
|
||||
|
||||
def wasm_extension_wrapper_response(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
auth: str,
|
||||
user_json: str | None,
|
||||
) -> Any:
|
||||
public = auth == "public"
|
||||
response = template_renderer().TemplateResponse(
|
||||
request,
|
||||
"wasm_extension.html",
|
||||
{
|
||||
"extension": extension,
|
||||
"public": public,
|
||||
"user": user_json,
|
||||
},
|
||||
)
|
||||
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
|
||||
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
||||
return response
|
||||
|
||||
|
||||
def wasm_extension_frame_csp(request: Request, extension: WasmExtension) -> str:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
extension_assets = f"{origin}/ext-assets/{extension.id}/"
|
||||
return (
|
||||
"sandbox allow-scripts; "
|
||||
"default-src 'none'; "
|
||||
f"script-src {extension_assets}; "
|
||||
"script-src-attr 'none'; "
|
||||
f"style-src {extension_assets}; "
|
||||
"style-src-attr 'none'; "
|
||||
f"img-src {extension_assets} data:; "
|
||||
f"font-src {extension_assets}; "
|
||||
"connect-src 'none'; "
|
||||
"form-action 'none'; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'none'; "
|
||||
"frame-src 'none'; "
|
||||
"worker-src 'none'; "
|
||||
"media-src 'none'; "
|
||||
"manifest-src 'none'; "
|
||||
"frame-ancestors 'self'"
|
||||
)
|
||||
|
||||
|
||||
def wasm_extension_frame_url(
|
||||
extension: WasmExtension, frame_path: str, user_id: str | None
|
||||
) -> str:
|
||||
token = _create_wasm_extension_frame_token(extension, frame_path, user_id)
|
||||
return f"{frame_path}?frame_token={token}"
|
||||
|
||||
|
||||
def consume_wasm_extension_frame_token(
|
||||
request: Request,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
token = request.query_params.get("frame_token")
|
||||
if not token:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "missing")
|
||||
|
||||
cache_key = _wasm_extension_frame_token_cache_key(token)
|
||||
token_data = cache.get(cache_key)
|
||||
if (
|
||||
not isinstance(token_data, dict)
|
||||
or token_data.get("extension_id") != extension.id
|
||||
or token_data.get("frame_path") != frame_path
|
||||
):
|
||||
_raise_wasm_extension_frame_not_found(
|
||||
extension, frame_path, "unknown or expired"
|
||||
)
|
||||
|
||||
token_user_id = token_data.get("user_id")
|
||||
if token_user_id and token_user_id != user_id:
|
||||
_raise_wasm_extension_frame_not_found(extension, frame_path, "wrong user")
|
||||
|
||||
cache.pop(cache_key)
|
||||
|
||||
|
||||
def _create_wasm_extension_frame_token(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
user_id: str | None,
|
||||
) -> str:
|
||||
token = uuid4().hex
|
||||
cache.set(
|
||||
_wasm_extension_frame_token_cache_key(token),
|
||||
{
|
||||
"extension_id": extension.id,
|
||||
"frame_path": frame_path,
|
||||
"user_id": user_id,
|
||||
},
|
||||
expiry=WASM_FRAME_TOKEN_EXPIRY_SECONDS,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def _wasm_extension_frame_token_cache_key(token: str) -> str:
|
||||
return f"wasm-frame-token:{token}"
|
||||
|
||||
|
||||
def _raise_wasm_extension_frame_not_found(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
reason: str,
|
||||
) -> NoReturn:
|
||||
logger.warning(
|
||||
f"WASM frame token {reason} for extension '{extension.id}' at '{frame_path}'."
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import UUID4
|
||||
|
||||
from lnbits.core.crud import get_installed_extension, get_user_from_account
|
||||
from lnbits.core.models import Account
|
||||
from lnbits.decorators import (
|
||||
check_access_token,
|
||||
check_account_exists,
|
||||
optional_user_id,
|
||||
)
|
||||
|
||||
from ..wasm.loader import WasmExtension
|
||||
from .api import (
|
||||
WasmRequestBodyTooLargeError,
|
||||
_has_route,
|
||||
_path_template_pattern,
|
||||
_read_json_object,
|
||||
_snake_to_camel,
|
||||
_wasm_extension_api_export,
|
||||
_wasm_extension_api_method,
|
||||
_wasm_extension_api_path,
|
||||
_wasm_extension_route_auth,
|
||||
)
|
||||
from .security import (
|
||||
consume_wasm_extension_frame_token,
|
||||
wasm_extension_frame_csp,
|
||||
wasm_extension_frame_url,
|
||||
wasm_extension_wrapper_response,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
frame_path = f"/ext-frame/{extension.id}/{route_index}"
|
||||
auth = _wasm_extension_route_auth(extension, route_config.auth)
|
||||
_add_wasm_extension_frame_route(app, extension, frame_path, entrypoint)
|
||||
_add_wasm_extension_wrapper_route(
|
||||
app,
|
||||
extension,
|
||||
route_path,
|
||||
auth,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_config_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
) -> None:
|
||||
route_path = _wasm_extension_frame_config_path(extension)
|
||||
if _has_route(app, route_path, "POST"):
|
||||
return
|
||||
|
||||
async def create_wasm_extension_frame_config(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> 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
|
||||
|
||||
ui_route = _match_wasm_extension_ui_route(extension, body.get("path"))
|
||||
auth = ui_route["auth"]
|
||||
|
||||
if auth == "user":
|
||||
account = await check_account_exists(request, access_token, usr)
|
||||
user_id: str | None = account.id
|
||||
else:
|
||||
user_id = await _optional_wasm_user_id(request, access_token, usr)
|
||||
|
||||
granted_permission_ids = await _wasm_extension_granted_permission_ids(extension)
|
||||
|
||||
return _wasm_extension_frame_config(
|
||||
extension,
|
||||
ui_route["frame_path"],
|
||||
auth,
|
||||
ui_route["path_params"],
|
||||
ui_route["route_params"],
|
||||
_read_wasm_extension_route_query(body.get("query")),
|
||||
user_id,
|
||||
granted_permission_ids,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
create_wasm_extension_frame_config,
|
||||
methods=["POST"],
|
||||
name=f"{extension.id}:frame-config",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_wrapper_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
route_path: str,
|
||||
auth: str,
|
||||
) -> None:
|
||||
if _has_route(app, route_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_private_wasm_extension_page(
|
||||
request: Request,
|
||||
account: Account = Depends(check_account_exists),
|
||||
) -> Any:
|
||||
user = await get_user_from_account(account)
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
user.json() if user else None,
|
||||
)
|
||||
|
||||
async def serve_public_wasm_extension_page(request: Request) -> Any:
|
||||
return wasm_extension_wrapper_response(
|
||||
request,
|
||||
extension,
|
||||
auth,
|
||||
None,
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
route_path,
|
||||
(
|
||||
serve_public_wasm_extension_page
|
||||
if auth == "public"
|
||||
else serve_private_wasm_extension_page
|
||||
),
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:{route_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _add_wasm_extension_frame_route(
|
||||
app: FastAPI,
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
entrypoint: Path,
|
||||
) -> None:
|
||||
if _has_route(app, frame_path, "GET"):
|
||||
return
|
||||
|
||||
async def serve_wasm_extension_frame(
|
||||
request: Request,
|
||||
user_id: str | None = Depends(_optional_wasm_user_id),
|
||||
) -> FileResponse:
|
||||
consume_wasm_extension_frame_token(request, extension, frame_path, user_id)
|
||||
response = FileResponse(entrypoint)
|
||||
response.headers["Content-Security-Policy"] = wasm_extension_frame_csp(
|
||||
request, extension
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
|
||||
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
|
||||
# Extension access goes through the parent bridge.
|
||||
response.headers["Permissions-Policy"] = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), "
|
||||
"clipboard-read=(), usb=()"
|
||||
)
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
return response
|
||||
|
||||
app.add_api_route(
|
||||
frame_path,
|
||||
serve_wasm_extension_frame,
|
||||
methods=["GET"],
|
||||
name=f"{extension.id}:frame:{frame_path}",
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
def _wasm_extension_bridge_api_routes(
|
||||
extension: WasmExtension,
|
||||
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)
|
||||
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)
|
||||
routes.append(
|
||||
{
|
||||
"method": method,
|
||||
"path": path,
|
||||
"pattern": _path_template_pattern(path),
|
||||
}
|
||||
)
|
||||
return routes
|
||||
|
||||
|
||||
def _wasm_extension_frame_config_path(extension: WasmExtension) -> str:
|
||||
return f"/api/v1/ext/{extension.id}/_ui/frame"
|
||||
|
||||
|
||||
def _match_wasm_extension_ui_route(
|
||||
extension: WasmExtension,
|
||||
path: Any,
|
||||
) -> dict[str, Any]:
|
||||
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)
|
||||
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,
|
||||
"route_params": route_params,
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
|
||||
def _path_template_params(template: str, path: str) -> dict[str, str] | None:
|
||||
template_parts = _path_parts(template)
|
||||
path_parts = _path_parts(path)
|
||||
if len(template_parts) != len(path_parts):
|
||||
return None
|
||||
|
||||
params: dict[str, str] = {}
|
||||
for template_part, path_part in zip(template_parts, path_parts, strict=False):
|
||||
if template_part.startswith("{") and template_part.endswith("}"):
|
||||
param_name = template_part[1:-1]
|
||||
if not param_name:
|
||||
return None
|
||||
params[param_name] = path_part
|
||||
continue
|
||||
|
||||
if template_part != path_part:
|
||||
return None
|
||||
|
||||
return params
|
||||
|
||||
|
||||
def _path_parts(path: str) -> list[str]:
|
||||
return [part for part in path.strip("/").split("/") if part]
|
||||
|
||||
|
||||
def _wasm_extension_frame_config(
|
||||
extension: WasmExtension,
|
||||
frame_path: str,
|
||||
auth: str,
|
||||
path_params: dict[str, str],
|
||||
route_params: dict[str, str],
|
||||
query: dict[str, Any],
|
||||
user_id: str | None,
|
||||
permissions: set[str],
|
||||
) -> dict[str, Any]:
|
||||
public = auth == "public"
|
||||
return {
|
||||
"extension": {
|
||||
"id": extension.id,
|
||||
"name": extension.name,
|
||||
},
|
||||
"frameUrl": wasm_extension_frame_url(extension, frame_path, user_id),
|
||||
"bridge": {
|
||||
"extensionId": extension.id,
|
||||
"public": public,
|
||||
"routeParams": _map_wasm_extension_route_params(route_params, path_params),
|
||||
"query": query,
|
||||
"permissions": sorted(permissions),
|
||||
"apiRoutes": _wasm_extension_bridge_api_routes(extension, public),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _wasm_extension_granted_permission_ids(
|
||||
extension: WasmExtension,
|
||||
) -> set[str]:
|
||||
installed_extension = await get_installed_extension(extension.id)
|
||||
if not installed_extension:
|
||||
return set()
|
||||
return {permission.id for permission in installed_extension.permissions}
|
||||
|
||||
|
||||
def _map_wasm_extension_route_params(
|
||||
route_params: dict[str, str],
|
||||
path_params: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
payload: dict[str, str] = {}
|
||||
for key, value in route_params.items():
|
||||
target = path_params.get(key) or _snake_to_camel(key)
|
||||
payload[target] = value
|
||||
return payload
|
||||
|
||||
|
||||
def _read_wasm_extension_route_query(query: Any) -> dict[str, Any]:
|
||||
if not isinstance(query, dict):
|
||||
return {}
|
||||
|
||||
payload: dict[str, Any] = {}
|
||||
for key, value in query.items():
|
||||
if value is None:
|
||||
continue
|
||||
payload[_snake_to_camel(str(key))] = value
|
||||
return payload
|
||||
|
||||
|
||||
async def _optional_wasm_user_id(
|
||||
request: Request,
|
||||
access_token: Annotated[str | None, Depends(check_access_token)],
|
||||
usr: UUID4 | None = None,
|
||||
) -> str | None:
|
||||
try:
|
||||
return await optional_user_id(request, access_token, usr)
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
def _wasm_extension_ui_route_path(extension: WasmExtension, path: Any) -> str:
|
||||
if not isinstance(path, str) or not path.startswith("/"):
|
||||
raise ValueError(f"Invalid route path for WASM extension '{extension.id}'.")
|
||||
if path == "/":
|
||||
return "/ext"
|
||||
return f"/ext{path}"
|
||||
|
||||
|
||||
def _wasm_extension_entrypoint(extension: WasmExtension, entrypoint: Any) -> Path:
|
||||
if not isinstance(entrypoint, str) or not entrypoint:
|
||||
raise ValueError(
|
||||
f"Invalid route entrypoint for WASM extension '{extension.id}'."
|
||||
)
|
||||
if entrypoint.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be a "
|
||||
"relative extension path."
|
||||
)
|
||||
|
||||
path = (extension.root_path / entrypoint).resolve()
|
||||
root_path = extension.root_path.resolve()
|
||||
if path != root_path and root_path not in path.parents:
|
||||
raise ValueError(f"Route entrypoint escapes extension root: {entrypoint}")
|
||||
|
||||
static_path = (extension.root_path / "static").resolve()
|
||||
if path == static_path or static_path in path.parents:
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must not be "
|
||||
"inside the static asset directory."
|
||||
)
|
||||
if path.suffix.lower() != ".html":
|
||||
raise ValueError(
|
||||
f"Route entrypoint for WASM extension '{extension.id}' must be "
|
||||
"an HTML file."
|
||||
)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Route entrypoint not found: {path}")
|
||||
return path
|
||||
@@ -0,0 +1,19 @@
|
||||
from .crud import (
|
||||
migrate_wasm_extension_database,
|
||||
storage_delete_row,
|
||||
storage_get_paginated_rows,
|
||||
storage_get_public_row,
|
||||
storage_get_row,
|
||||
storage_get_row_owner_id,
|
||||
storage_set_row,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"migrate_wasm_extension_database",
|
||||
"storage_delete_row",
|
||||
"storage_get_paginated_rows",
|
||||
"storage_get_public_row",
|
||||
"storage_get_row",
|
||||
"storage_get_row_owner_id",
|
||||
"storage_set_row",
|
||||
]
|
||||
@@ -0,0 +1,648 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud import update_migration_version
|
||||
from lnbits.core.db import db as core_db
|
||||
from lnbits.core.models import DbVersion
|
||||
from lnbits.core.models.extensions import InstallableExtension
|
||||
from lnbits.db import POSTGRES, SQLITE, Compat, Connection, Database
|
||||
from lnbits.settings import settings
|
||||
|
||||
_MIGRATION_FILE_RE = re.compile(r"^(\d+)_.*\.json$")
|
||||
_SQL_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
OWNER_ID_FIELD = "__lnbits_owner_id__"
|
||||
|
||||
|
||||
async def storage_get_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
owner_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id, "owner_id": owner_id})
|
||||
return _row_from_db(table_schema, row) if row else None
|
||||
|
||||
|
||||
async def storage_get_public_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT * FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id})
|
||||
return _row_from_db(table_schema, row) if row else None
|
||||
|
||||
|
||||
async def storage_get_row_owner_id(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
) -> str | None:
|
||||
_load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
SELECT {OWNER_ID_FIELD} FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
row = await conn.fetchone(query, {"id": row_id})
|
||||
|
||||
owner_id = row[OWNER_ID_FIELD] if row else None
|
||||
return owner_id if isinstance(owner_id, str) and owner_id else None
|
||||
|
||||
|
||||
async def storage_set_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
data: dict[str, Any],
|
||||
owner_id: str,
|
||||
) -> None:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
clean_data = _data_to_db(table_schema, data, require_id=True)
|
||||
columns = list(clean_data.keys())
|
||||
database = Database(f"ext_{ext_id}")
|
||||
fields = _fields_by_name(table_schema)
|
||||
placeholders = [
|
||||
_value_placeholder(database, fields[column], column) for column in columns
|
||||
]
|
||||
|
||||
clean_data[OWNER_ID_FIELD] = owner_id
|
||||
columns.append(OWNER_ID_FIELD)
|
||||
placeholders.append(f":{OWNER_ID_FIELD}")
|
||||
updates = [
|
||||
f"{column} = excluded.{column}"
|
||||
for column in columns
|
||||
if column not in ("id", OWNER_ID_FIELD)
|
||||
]
|
||||
conflict_sql = (
|
||||
"DO UPDATE SET "
|
||||
+ ", ".join(updates)
|
||||
+ f" WHERE storage_row.{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||
if updates
|
||||
else "DO NOTHING"
|
||||
)
|
||||
query = f"""
|
||||
INSERT INTO {_table_ref_for_schema(ext_id, table)} AS storage_row
|
||||
({", ".join(columns)})
|
||||
VALUES
|
||||
({", ".join(placeholders)})
|
||||
ON CONFLICT (id) {conflict_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with database.connect() as conn:
|
||||
await conn.execute(query, clean_data)
|
||||
|
||||
|
||||
async def storage_get_paginated_rows(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
filters: dict[str, Any],
|
||||
*,
|
||||
owner_id: str,
|
||||
search: str | None,
|
||||
search_fields: list[str],
|
||||
sort_by: str | None,
|
||||
descending: bool,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
table_schema = _load_table_schema(ext_id, table)
|
||||
database = Database(f"ext_{ext_id}")
|
||||
where_sql, values = _where_sql(
|
||||
database, table_schema, filters, search, search_fields
|
||||
)
|
||||
where_sql = _append_owner_where_sql(where_sql)
|
||||
values[OWNER_ID_FIELD] = owner_id
|
||||
order_sql = _order_sql(table_schema, sort_by, descending)
|
||||
count_values = dict(values)
|
||||
values.update({"limit": min(limit, 1000), "offset": offset})
|
||||
|
||||
table_ref = _table_ref_for_schema(ext_id, table)
|
||||
rows_query = f"""
|
||||
SELECT * FROM {table_ref}
|
||||
{where_sql}
|
||||
{order_sql}
|
||||
LIMIT :limit
|
||||
OFFSET :offset
|
||||
""" # noqa: S608
|
||||
count_query = f"""
|
||||
SELECT COUNT(*) AS count FROM {table_ref}
|
||||
{where_sql}
|
||||
""" # noqa: S608
|
||||
|
||||
async with database.connect() as conn:
|
||||
rows = await conn.fetchall(rows_query, values)
|
||||
count_row = await conn.fetchone(count_query, count_values)
|
||||
|
||||
return {
|
||||
"data": [_row_from_db(table_schema, row) for row in rows],
|
||||
"total": int(count_row["count"]) if count_row else 0,
|
||||
}
|
||||
|
||||
|
||||
async def storage_delete_row(
|
||||
ext_id: str,
|
||||
table: str,
|
||||
row_id: str,
|
||||
owner_id: str,
|
||||
) -> None:
|
||||
_load_table_schema(ext_id, table)
|
||||
query = f"""
|
||||
DELETE FROM {_table_ref_for_schema(ext_id, table)}
|
||||
WHERE id = :id AND {OWNER_ID_FIELD} = :owner_id
|
||||
""" # noqa: S608
|
||||
async with Database(f"ext_{ext_id}").connect() as conn:
|
||||
await conn.execute(query, {"id": row_id, "owner_id": owner_id})
|
||||
|
||||
|
||||
async def migrate_wasm_extension_database(
|
||||
ext: InstallableExtension,
|
||||
current_version: DbVersion | None = None,
|
||||
) -> None:
|
||||
migrations_dir = ext.ext_dir / "storage" / "migrations"
|
||||
migration_files = _migration_files(migrations_dir)
|
||||
if not migration_files:
|
||||
logger.debug(f"No storage migrations for WASM extension '{ext.id}'.")
|
||||
return
|
||||
|
||||
ext_db = Database(f"ext_{ext.id}")
|
||||
async with ext_db.connect() as conn:
|
||||
for version, path in migration_files:
|
||||
if current_version and version <= current_version.version:
|
||||
continue
|
||||
logger.debug(f"running WASM storage migration {ext.id}.{version}")
|
||||
print(f"running migration {ext.id}.{version}")
|
||||
await _run_storage_migration(conn, path)
|
||||
await _update_wasm_migration_version(conn, ext.id, version)
|
||||
|
||||
|
||||
def _migration_files(migrations_dir: Path) -> list[tuple[int, Path]]:
|
||||
if not migrations_dir.is_dir():
|
||||
return []
|
||||
|
||||
files: list[tuple[int, Path]] = []
|
||||
for path in migrations_dir.glob("*.json"):
|
||||
match = _MIGRATION_FILE_RE.match(path.name)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid WASM storage migration filename: {path.name}")
|
||||
files.append((int(match.group(1)), path))
|
||||
return sorted(files)
|
||||
|
||||
|
||||
async def _run_storage_migration(db: Connection, path: Path) -> None:
|
||||
migration = _load_json(path)
|
||||
operations = migration.get("operations")
|
||||
if not isinstance(operations, list):
|
||||
raise ValueError(f"WASM storage migration '{path}' has no operations list.")
|
||||
|
||||
for operation in operations:
|
||||
if not isinstance(operation, dict):
|
||||
raise ValueError(f"WASM storage migration '{path}' has invalid operation.")
|
||||
sql = _operation_sql(db, operation)
|
||||
await db.execute(sql)
|
||||
|
||||
|
||||
def _operation_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
op = operation.get("op")
|
||||
if op == "create_table":
|
||||
return _create_table_sql(db, operation)
|
||||
if op == "add_field":
|
||||
return _add_field_sql(db, operation)
|
||||
if op == "create_index":
|
||||
return _create_index_sql(db, operation)
|
||||
raise ValueError(f"Unsupported WASM storage migration operation: {op}")
|
||||
|
||||
|
||||
def _create_table_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
fields = _require_fields(operation)
|
||||
if not any(field.get("name") == "id" for field in fields):
|
||||
raise ValueError(f"WASM storage table '{table}' must define an id field.")
|
||||
if any(field.get("name") == OWNER_ID_FIELD for field in fields):
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' defines reserved field '{OWNER_ID_FIELD}'."
|
||||
)
|
||||
|
||||
columns = [
|
||||
_column_sql(db, field, primary_key=field.get("name") == "id")
|
||||
for field in fields
|
||||
]
|
||||
columns.append(f"{OWNER_ID_FIELD} TEXT NOT NULL")
|
||||
return f"""
|
||||
CREATE TABLE IF NOT EXISTS {_table_ref(db, table)} (
|
||||
{", ".join(columns)}
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def _add_field_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
field = _field_from_add_field_operation(operation)
|
||||
if field["name"] == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' cannot add reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
return f"""
|
||||
ALTER TABLE {_table_ref(db, table)}
|
||||
ADD COLUMN {_column_sql(db, field)};
|
||||
"""
|
||||
|
||||
|
||||
def _create_index_sql(db: Connection, operation: dict[str, Any]) -> str:
|
||||
table = _require_identifier(operation, "table")
|
||||
name = _require_identifier(operation, "name")
|
||||
field = _require_identifier(operation, "field")
|
||||
if field == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' cannot index reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
|
||||
if db.type == SQLITE and db.schema:
|
||||
return f"""
|
||||
CREATE INDEX IF NOT EXISTS {_schema_ref(db, name)}
|
||||
ON {table} ({field});
|
||||
"""
|
||||
|
||||
return f"""
|
||||
CREATE INDEX IF NOT EXISTS {name}
|
||||
ON {_table_ref(db, table)} ({field});
|
||||
"""
|
||||
|
||||
|
||||
def _column_sql(
|
||||
db: Connection,
|
||||
field: dict[str, Any],
|
||||
*,
|
||||
primary_key: bool = False,
|
||||
) -> str:
|
||||
name = _require_identifier(field, "name")
|
||||
column_type = _field_type_sql(db, field)
|
||||
parts = [name, column_type]
|
||||
|
||||
if primary_key:
|
||||
parts.append("PRIMARY KEY")
|
||||
elif not field.get("nullable", False):
|
||||
parts.append("NOT NULL")
|
||||
|
||||
if "default" in field:
|
||||
parts.append(f"DEFAULT {_default_sql(field['default'])}")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _field_type_sql(db: Connection, field: dict[str, Any]) -> str:
|
||||
if field.get("list") is True:
|
||||
return "TEXT"
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "string":
|
||||
return "TEXT"
|
||||
if field_type == "integer":
|
||||
return db.big_int
|
||||
if field_type == "number":
|
||||
return "DOUBLE PRECISION" if db.type == POSTGRES else "REAL"
|
||||
if field_type == "boolean":
|
||||
return "BOOLEAN"
|
||||
if field_type == "datetime":
|
||||
return "TIMESTAMP"
|
||||
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
|
||||
|
||||
|
||||
def _load_table_schema(ext_id: str, table: str) -> dict[str, Any]:
|
||||
schema = _load_storage_schema(ext_id)
|
||||
tables = schema.get("tables")
|
||||
if not isinstance(tables, dict):
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage tables schema.")
|
||||
|
||||
_require_identifier({"table": table}, "table")
|
||||
table_schema = tables.get(table)
|
||||
if not isinstance(table_schema, dict):
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage table '{table}'.")
|
||||
|
||||
fields = table_schema.get("fields")
|
||||
if not isinstance(fields, list) or not fields:
|
||||
raise ValueError(f"WASM storage table '{table}' has no fields schema.")
|
||||
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
raise ValueError(f"WASM storage table '{table}' has invalid field schema.")
|
||||
_require_identifier(field, "name")
|
||||
if field["name"] == OWNER_ID_FIELD:
|
||||
raise ValueError(
|
||||
f"WASM storage table '{table}' defines reserved field "
|
||||
f"'{OWNER_ID_FIELD}'."
|
||||
)
|
||||
return table_schema
|
||||
|
||||
|
||||
def _load_storage_schema(ext_id: str) -> dict[str, Any]:
|
||||
schema_path = (
|
||||
Path(settings.lnbits_extensions_path)
|
||||
/ "extensions"
|
||||
/ ext_id
|
||||
/ "storage"
|
||||
/ "schema.json"
|
||||
)
|
||||
if not schema_path.is_file():
|
||||
raise ValueError(f"WASM extension '{ext_id}' has no storage schema.")
|
||||
return _load_json(schema_path)
|
||||
|
||||
|
||||
def _data_to_db(
|
||||
table_schema: dict[str, Any],
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
require_id: bool,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("WASM storage row data must be an object.")
|
||||
if require_id and not data.get("id"):
|
||||
raise ValueError("WASM storage row data must include an id.")
|
||||
_reject_reserved_owner_field(data, "row")
|
||||
|
||||
fields = _fields_by_name(table_schema)
|
||||
unknown_fields = sorted(set(data) - set(fields))
|
||||
if unknown_fields:
|
||||
raise ValueError(
|
||||
"WASM storage row has unknown fields: " + ", ".join(unknown_fields)
|
||||
)
|
||||
|
||||
return {
|
||||
field_name: _value_to_db(fields[field_name], value)
|
||||
for field_name, value in data.items()
|
||||
}
|
||||
|
||||
|
||||
def _filters_to_db(
|
||||
table_schema: dict[str, Any],
|
||||
filters: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(filters, dict):
|
||||
raise ValueError("WASM storage filters must be an object.")
|
||||
_reject_reserved_owner_field(filters, "filters")
|
||||
|
||||
fields = _fields_by_name(table_schema)
|
||||
unknown_fields = sorted(set(filters) - set(fields))
|
||||
if unknown_fields:
|
||||
raise ValueError(
|
||||
"WASM storage filters have unknown fields: " + ", ".join(unknown_fields)
|
||||
)
|
||||
|
||||
return {
|
||||
field_name: _value_to_db(fields[field_name], value)
|
||||
for field_name, value in filters.items()
|
||||
}
|
||||
|
||||
|
||||
def _value_placeholder(db: Compat, field: dict[str, Any], key: str) -> str:
|
||||
if field.get("type") == "datetime" and not field.get("list"):
|
||||
return db.timestamp_placeholder(key)
|
||||
return f":{key}"
|
||||
|
||||
|
||||
def _where_sql(
|
||||
db: Compat,
|
||||
table_schema: dict[str, Any],
|
||||
filters: dict[str, Any],
|
||||
search: str | None,
|
||||
search_fields: list[str],
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
clean_filters = _filters_to_db(table_schema, filters)
|
||||
fields = _fields_by_name(table_schema)
|
||||
clauses = [
|
||||
f"{field} = {_value_placeholder(db, fields[field], f'filter_{field}')}"
|
||||
for field in clean_filters
|
||||
]
|
||||
values = {f"filter_{field}": value for field, value in clean_filters.items()}
|
||||
|
||||
clean_search = search.strip().lower() if search else ""
|
||||
if clean_search:
|
||||
fields = _fields_by_name(table_schema)
|
||||
invalid_fields = sorted(set(search_fields) - set(fields))
|
||||
if invalid_fields:
|
||||
raise ValueError(
|
||||
"WASM storage search has unknown fields: " + ", ".join(invalid_fields)
|
||||
)
|
||||
if search_fields:
|
||||
search_clause = " OR ".join(
|
||||
f"LOWER(CAST({field} AS TEXT)) LIKE :search" for field in search_fields
|
||||
)
|
||||
clauses.append(f"({search_clause})")
|
||||
values["search"] = f"%{clean_search}%"
|
||||
|
||||
return ("WHERE " + " AND ".join(clauses), values) if clauses else ("", values)
|
||||
|
||||
|
||||
def _append_owner_where_sql(where_sql: str) -> str:
|
||||
owner_clause = f"{OWNER_ID_FIELD} = :{OWNER_ID_FIELD}"
|
||||
if where_sql:
|
||||
return f"{where_sql} AND {owner_clause}"
|
||||
return f"WHERE {owner_clause}"
|
||||
|
||||
|
||||
def _order_sql(
|
||||
table_schema: dict[str, Any],
|
||||
sort_by: str | None,
|
||||
descending: bool,
|
||||
) -> str:
|
||||
if not sort_by:
|
||||
return ""
|
||||
fields = _fields_by_name(table_schema)
|
||||
if sort_by not in fields:
|
||||
raise ValueError(f"WASM storage sort field is unknown: {sort_by}")
|
||||
direction = "DESC" if descending else "ASC"
|
||||
return f"ORDER BY {sort_by} {direction}"
|
||||
|
||||
|
||||
def _row_from_db(
|
||||
table_schema: dict[str, Any],
|
||||
row: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
fields = _fields_by_name(table_schema)
|
||||
return {
|
||||
field_name: _value_from_db(fields[field_name], value)
|
||||
for field_name, value in dict(row).items()
|
||||
if field_name in fields
|
||||
}
|
||||
|
||||
|
||||
def _fields_by_name(table_schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
fields = table_schema.get("fields")
|
||||
if not isinstance(fields, list):
|
||||
raise ValueError("WASM storage table schema fields must be a list.")
|
||||
return {field["name"]: field for field in fields}
|
||||
|
||||
|
||||
def _reject_reserved_owner_field(data: dict[str, Any], value_name: str) -> None:
|
||||
if OWNER_ID_FIELD in data:
|
||||
raise ValueError(f"WASM storage {value_name} includes a reserved owner field.")
|
||||
|
||||
|
||||
def _value_to_db(field: dict[str, Any], value: Any) -> Any: # noqa: C901
|
||||
if value is None:
|
||||
if field.get("nullable", False):
|
||||
return None
|
||||
raise ValueError(f"WASM storage field '{field['name']}' cannot be null.")
|
||||
|
||||
if field.get("list") is True:
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a list.")
|
||||
return json.dumps(value)
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "string":
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a string.")
|
||||
return value
|
||||
if field_type == "integer":
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ValueError(
|
||||
f"WASM storage field '{field['name']}' must be an integer."
|
||||
)
|
||||
return value
|
||||
if field_type == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a number.")
|
||||
return value
|
||||
if field_type == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError(f"WASM storage field '{field['name']}' must be a boolean.")
|
||||
return value
|
||||
if field_type == "datetime":
|
||||
if isinstance(value, int | float):
|
||||
return datetime.fromtimestamp(value, tz=timezone.utc)
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
raise ValueError(
|
||||
f"WASM storage field '{field['name']}' must be a Unix timestamp."
|
||||
)
|
||||
raise ValueError(f"Unsupported WASM storage field type: {field_type}")
|
||||
|
||||
|
||||
def _value_from_db(field: dict[str, Any], value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
if field.get("list") is True:
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
field_type = field.get("type")
|
||||
if field_type == "boolean":
|
||||
return bool(value)
|
||||
if field_type == "datetime":
|
||||
if isinstance(value, datetime):
|
||||
return int(value.replace(tzinfo=timezone.utc).timestamp())
|
||||
if isinstance(value, int | float):
|
||||
return int(value)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return int(datetime.fromisoformat(value).timestamp())
|
||||
except ValueError:
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
def _default_sql(value: Any) -> str:
|
||||
if value is None:
|
||||
return "NULL"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, int | float):
|
||||
return str(value)
|
||||
if isinstance(value, str):
|
||||
return _quote_sql_string(value)
|
||||
if isinstance(value, list | dict):
|
||||
return _quote_sql_string(json.dumps(value))
|
||||
raise ValueError(f"Unsupported WASM storage default value: {value}")
|
||||
|
||||
|
||||
def _field_from_add_field_operation(operation: dict[str, Any]) -> dict[str, Any]:
|
||||
field = {
|
||||
"name": operation.get("field"),
|
||||
"type": operation.get("type"),
|
||||
}
|
||||
for key in ("default", "list", "nullable"):
|
||||
if key in operation:
|
||||
field[key] = operation[key]
|
||||
return field
|
||||
|
||||
|
||||
def _require_fields(operation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
fields = operation.get("fields")
|
||||
if not isinstance(fields, list) or not fields:
|
||||
raise ValueError("WASM storage create_table operation requires fields.")
|
||||
if not all(isinstance(field, dict) for field in fields):
|
||||
raise ValueError("WASM storage fields must be objects.")
|
||||
return fields
|
||||
|
||||
|
||||
def _require_identifier(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not _SQL_IDENTIFIER_RE.match(value):
|
||||
raise ValueError(f"Invalid WASM storage SQL identifier for '{key}': {value}")
|
||||
return value
|
||||
|
||||
|
||||
def _table_ref(db: Connection, table: str) -> str:
|
||||
if db.schema:
|
||||
return f"{_schema_ref(db, table)}"
|
||||
return table
|
||||
|
||||
|
||||
def _table_ref_for_schema(ext_id: str, table: str) -> str:
|
||||
_require_identifier({"schema": ext_id}, "schema")
|
||||
_require_identifier({"table": table}, "table")
|
||||
return f"{ext_id}.{table}"
|
||||
|
||||
|
||||
def _schema_ref(db: Connection, name: str) -> str:
|
||||
if not db.schema:
|
||||
return name
|
||||
if not _SQL_IDENTIFIER_RE.match(db.schema):
|
||||
raise ValueError(f"Invalid WASM extension storage schema: {db.schema}")
|
||||
return f"{db.schema}.{name}"
|
||||
|
||||
|
||||
def _quote_sql_string(value: str) -> str:
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
with open(path, encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"WASM storage migration '{path}' must be a JSON object.")
|
||||
return data
|
||||
|
||||
|
||||
async def _update_wasm_migration_version(
|
||||
db: Connection,
|
||||
ext_id: str,
|
||||
version: int,
|
||||
) -> None:
|
||||
if db.schema is None:
|
||||
await update_migration_version(db, ext_id, version)
|
||||
else:
|
||||
async with core_db.connect() as conn:
|
||||
await update_migration_version(conn, ext_id, version)
|
||||
@@ -0,0 +1,13 @@
|
||||
from .component import warm_wasm_extension
|
||||
from .events import dispatch_wasm_invoice_paid
|
||||
from .invoke import invoke_wasm_extension_export
|
||||
from .loader import WasmExtension, is_wasm_extension_dir, is_wasm_extension_id
|
||||
|
||||
__all__ = [
|
||||
"WasmExtension",
|
||||
"dispatch_wasm_invoice_paid",
|
||||
"invoke_wasm_extension_export",
|
||||
"is_wasm_extension_dir",
|
||||
"is_wasm_extension_id",
|
||||
"warm_wasm_extension",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
@@ -0,0 +1,256 @@
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_installed_extension, get_user_extensions
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.db import core_app_extra
|
||||
from lnbits.core.models.extensions import ExtensionWalletPaymentsWatchGrant
|
||||
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
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
WALLET_PAYMENTS_WATCH_PERMISSION = "wallet.payments.watch"
|
||||
|
||||
|
||||
async def dispatch_wasm_invoice_paid(payment: Any) -> None:
|
||||
targets: dict[str, tuple[Any, str | None]] = {}
|
||||
extension_id = _payment_extension_id(payment)
|
||||
if extension_id:
|
||||
extension = core_app_extra.wasm_extension_registry.get(extension_id)
|
||||
if extension:
|
||||
targets[extension_id] = (
|
||||
extension,
|
||||
await _wasm_invoice_paid_owner_id(extension, payment),
|
||||
)
|
||||
|
||||
wallet = await _payment_wallet(payment)
|
||||
if wallet:
|
||||
wallet_owner_id = sha256s(wallet.user)
|
||||
for watch_extension_id in await _wallet_watch_extension_ids(
|
||||
wallet.user, wallet.id
|
||||
):
|
||||
extension = core_app_extra.wasm_extension_registry.get(watch_extension_id)
|
||||
if not extension:
|
||||
continue
|
||||
if watch_extension_id in targets:
|
||||
existing_extension, existing_owner_id = targets[watch_extension_id]
|
||||
targets[watch_extension_id] = (
|
||||
existing_extension,
|
||||
existing_owner_id or wallet_owner_id,
|
||||
)
|
||||
continue
|
||||
targets[watch_extension_id] = (extension, wallet_owner_id)
|
||||
|
||||
for extension, owner_id in targets.values():
|
||||
await _dispatch_wasm_invoice_paid_to_extension(extension, payment, owner_id)
|
||||
|
||||
|
||||
async def _dispatch_wasm_invoice_paid_to_extension(
|
||||
extension: Any, payment: Any, owner_id: str | None
|
||||
) -> None:
|
||||
export_name = _wasm_invoice_paid_export(extension.config)
|
||||
if not export_name:
|
||||
return
|
||||
|
||||
if not _is_wasm_event_export(extension, export_name):
|
||||
logger.warning(
|
||||
f"WASM extension '{extension.id}' declares invalid onInvoicePaid "
|
||||
f"export '{export_name}'."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"WASM extension '{extension.id}' failed to handle paid invoice "
|
||||
f"'{payment.payment_hash}': {exc!s}"
|
||||
)
|
||||
|
||||
|
||||
async def _payment_wallet(payment: Any) -> Any | None:
|
||||
wallet_id = getattr(payment, "wallet_id", None)
|
||||
if not isinstance(wallet_id, str) or not wallet_id:
|
||||
return None
|
||||
try:
|
||||
return await get_wallet(wallet_id)
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not fetch wallet '{wallet_id}' for WASM event: {exc!s}")
|
||||
return None
|
||||
|
||||
|
||||
async def _wallet_watch_extension_ids(user_id: str, wallet_id: str) -> list[str]:
|
||||
try:
|
||||
user_extensions = await get_user_extensions(user_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Could not fetch extensions for wallet payment watch user "
|
||||
f"'{user_id}': {exc!s}"
|
||||
)
|
||||
return []
|
||||
extension_ids: list[str] = []
|
||||
|
||||
for user_extension in user_extensions:
|
||||
if not user_extension.active:
|
||||
continue
|
||||
if not _has_wallet_watch_grant(user_extension, wallet_id):
|
||||
continue
|
||||
if not core_app_extra.wasm_extension_registry.get(user_extension.extension):
|
||||
continue
|
||||
try:
|
||||
installed_extension = await get_installed_extension(
|
||||
user_extension.extension
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
f"Could not fetch installed extension '{user_extension.extension}' "
|
||||
f"for wallet payment watch: {exc!s}"
|
||||
)
|
||||
continue
|
||||
if not installed_extension or not installed_extension.active:
|
||||
continue
|
||||
if not _extension_has_permission(
|
||||
installed_extension, WALLET_PAYMENTS_WATCH_PERMISSION
|
||||
):
|
||||
continue
|
||||
extension_ids.append(user_extension.extension)
|
||||
|
||||
return extension_ids
|
||||
|
||||
|
||||
def _has_wallet_watch_grant(user_extension: Any, wallet_id: str) -> bool:
|
||||
permissions = user_extension.permissions or {}
|
||||
grants = permissions.get(WALLET_PAYMENTS_WATCH_PERMISSION)
|
||||
if not isinstance(grants, list):
|
||||
return False
|
||||
|
||||
for grant_data in grants:
|
||||
if not isinstance(grant_data, dict):
|
||||
continue
|
||||
try:
|
||||
grant = ExtensionWalletPaymentsWatchGrant.parse_obj(grant_data)
|
||||
except ValueError:
|
||||
continue
|
||||
if grant.enabled and grant.wallet_id == wallet_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _extension_has_permission(extension: Any, permission_id: str) -> bool:
|
||||
return any(
|
||||
(
|
||||
permission.get("id")
|
||||
if isinstance(permission, dict)
|
||||
else getattr(permission, "id", None)
|
||||
)
|
||||
== permission_id
|
||||
for permission in (extension.permissions or [])
|
||||
)
|
||||
|
||||
|
||||
def _payment_extension_id(payment: Any) -> str | None:
|
||||
if isinstance(payment.extension, str) and payment.extension:
|
||||
return payment.extension
|
||||
|
||||
extra = payment.extra or {}
|
||||
tag = extra.get("tag") or payment.tag
|
||||
return tag if isinstance(tag, str) and tag else 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)
|
||||
if not source_id or not source_tables:
|
||||
return None
|
||||
|
||||
for source_table in source_tables:
|
||||
owner_id = await storage_get_row_owner_id(extension.id, source_table, source_id)
|
||||
if owner_id:
|
||||
return owner_id
|
||||
return None
|
||||
|
||||
|
||||
def _payment_source_id(payment: Any) -> str | None:
|
||||
extra = payment.extra or {}
|
||||
source_id = extra.get("source_id")
|
||||
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]:
|
||||
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":
|
||||
continue
|
||||
policies = (
|
||||
permission.get("policies")
|
||||
if isinstance(permission, dict)
|
||||
else getattr(permission, "policies", None)
|
||||
)
|
||||
if not isinstance(policies, list):
|
||||
return []
|
||||
return [
|
||||
source_policy["table"]
|
||||
for source_policy in policies
|
||||
if isinstance(source_policy, dict)
|
||||
and isinstance(source_policy.get("table"), str)
|
||||
and source_policy["table"]
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _wasm_invoice_paid_export(config: Any) -> str | None:
|
||||
return config.events.on_invoice_paid
|
||||
|
||||
|
||||
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"
|
||||
return False
|
||||
|
||||
|
||||
def _wasm_invoice_paid_payload(payment: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"checkingId": payment.checking_id,
|
||||
"paymentHash": payment.payment_hash,
|
||||
"walletId": payment.wallet_id,
|
||||
"amount": payment.amount,
|
||||
"fee": payment.fee,
|
||||
"bolt11": payment.bolt11,
|
||||
"memo": payment.memo,
|
||||
"pending": payment.pending,
|
||||
"status": payment.status,
|
||||
"tag": payment.tag,
|
||||
"extension": payment.extension,
|
||||
"extra": payment.extra or {},
|
||||
"payment": json.loads(payment.json()),
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
|
||||
|
||||
def add_extension_host_imports(
|
||||
linker: Any,
|
||||
api_host: ExtensionAPIHost,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
) -> None:
|
||||
with linker.root() as root:
|
||||
methods_by_interface: dict[str, list[Any]] = {}
|
||||
for method in list_extension_api_methods():
|
||||
methods_by_interface.setdefault(method.host_interface, []).append(method)
|
||||
|
||||
for host_interface, methods in methods_by_interface.items():
|
||||
with root.add_instance(f"lnbits:extension/{host_interface}") as host:
|
||||
for method in methods:
|
||||
host.add_func(
|
||||
method.host_name.replace("_", "-"),
|
||||
_make_host_import(
|
||||
api_host,
|
||||
method.method_id,
|
||||
method.request_model is EmptyRequest,
|
||||
event_loop,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _make_host_import(
|
||||
api_host: ExtensionAPIHost,
|
||||
host_name: str,
|
||||
empty_request: bool,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
) -> Any:
|
||||
if empty_request:
|
||||
|
||||
def empty_host_import(_store: Any) -> Any:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
api_host.invoke(host_name), event_loop
|
||||
)
|
||||
response = future.result()
|
||||
return _dict_to_component_record(response)
|
||||
|
||||
return empty_host_import
|
||||
|
||||
def host_import(_store: Any, request: Any = None) -> Any:
|
||||
payload = _component_payload_to_dict(request)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
api_host.invoke(host_name, payload), event_loop
|
||||
)
|
||||
response = future.result()
|
||||
return _dict_to_component_record(response)
|
||||
|
||||
return host_import
|
||||
|
||||
|
||||
def _component_payload_to_dict(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if hasattr(value, "__dict__"):
|
||||
return dict(value.__dict__)
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
raise TypeError("WASM host function payload must be a record.")
|
||||
|
||||
|
||||
def _dict_to_component_record(value: Mapping[str, Any]) -> Any:
|
||||
|
||||
record = component.Record()
|
||||
for key, item in value.items():
|
||||
setattr(record, _camel_to_kebab(key), _to_component_value(item))
|
||||
return record
|
||||
|
||||
|
||||
def _to_component_value(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return _dict_to_component_record(value)
|
||||
if isinstance(value, list):
|
||||
return [_to_component_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _camel_to_kebab(value: str) -> str:
|
||||
return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", value).replace("_", "-").lower()
|
||||
@@ -0,0 +1,279 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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
|
||||
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,
|
||||
export_name: str,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
user: Any | None = None,
|
||||
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,
|
||||
)
|
||||
api = ExtensionHostAPI(
|
||||
extension.id,
|
||||
permissions,
|
||||
user_id=_user_id(user),
|
||||
access_token=access_token,
|
||||
context=context,
|
||||
owner_id=owner_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,
|
||||
)
|
||||
)
|
||||
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(
|
||||
extension: WasmExtension,
|
||||
export_name: str,
|
||||
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
|
||||
|
||||
engine = _wasm_engine(limits["wasm_runtime_max_wasm_stack_bytes"])
|
||||
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)
|
||||
instance = linker.instantiate(store, wasm_component)
|
||||
function = instance.get_func(store, export_name)
|
||||
if not function:
|
||||
raise KeyError(
|
||||
f"WASM extension '{extension.id}' has no export '{export_name}'."
|
||||
)
|
||||
|
||||
result = function(store, json.dumps(payload))
|
||||
function.post_return(store)
|
||||
return _parse_wasm_export_result(result, limits)
|
||||
|
||||
|
||||
def _parse_wasm_export_result(value: Any, limits: dict[str, int]) -> 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:
|
||||
response_size = len(value.encode())
|
||||
if response_size > max_response_bytes:
|
||||
raise ValueError(
|
||||
f"WASM extension response is too large: {response_size} bytes."
|
||||
)
|
||||
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return {"ok": True, "data": parsed}
|
||||
|
||||
|
||||
def _get_registered_extension(ext_id: str) -> WasmExtension:
|
||||
extension = core_app_extra.wasm_extension_registry.get(ext_id)
|
||||
if extension:
|
||||
return extension
|
||||
raise RuntimeError(f"WASM extension '{ext_id}' is not registered.")
|
||||
|
||||
|
||||
async def _active_installed_extension(extension: WasmExtension) -> 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
|
||||
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WasmExtension:
|
||||
id: str
|
||||
name: str
|
||||
version: str
|
||||
root_path: Path
|
||||
module_path: Path
|
||||
wit_path: Path | None
|
||||
world: str
|
||||
exports: list[WasmExtensionExport]
|
||||
config: WasmExtensionConfig
|
||||
|
||||
|
||||
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")
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def is_wasm_extension_dir(ext_dir: Path) -> bool:
|
||||
config = _load_json(ext_dir / "config.json")
|
||||
return bool(config and config.get("extension_type") == "wasm")
|
||||
|
||||
|
||||
def load_wasm_extension_config(ext_id: str) -> WasmExtensionConfig | 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)
|
||||
|
||||
|
||||
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:
|
||||
raise FileNotFoundError(f"Missing WASM extension config for '{ext_id}'.")
|
||||
if raw_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)
|
||||
_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,
|
||||
root_path=ext_dir,
|
||||
module_path=module_path,
|
||||
wit_path=wit_path,
|
||||
world=config.wasm.world,
|
||||
exports=config.wasm.exports,
|
||||
config=config,
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any] | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
with path.open("r", encoding="utf-8") as config_file:
|
||||
value = json.load(config_file)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Expected JSON object in '{path}'.")
|
||||
return value
|
||||
|
||||
|
||||
def _extension_path(ext_dir: Path, value: Any) -> Path:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"Missing relative path for extension '{ext_dir.name}'.")
|
||||
path = (ext_dir / value).resolve()
|
||||
if ext_dir.resolve() not in path.parents:
|
||||
raise ValueError(f"Extension path escapes extension root: {value}")
|
||||
return path
|
||||
|
||||
|
||||
def _optional_extension_path(ext_dir: Path, value: Any) -> Path | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _extension_path(ext_dir, value)
|
||||
|
||||
|
||||
def _check_wasm_module(path: Path) -> None:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"WASM module not found: {path}")
|
||||
with path.open("rb") as wasm_file:
|
||||
magic = wasm_file.read(4)
|
||||
if magic != b"\0asm":
|
||||
raise ValueError(f"Invalid WASM module: {path}")
|
||||
+17
-9
@@ -497,7 +497,7 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
validated, errors = compare_field.validate(raw_value, {}, loc="none")
|
||||
if errors:
|
||||
raise ValidationError(errors=[errors], model=model)
|
||||
values[f"{field}__{index}"] = validated
|
||||
values[f"{field}__{i}_{index}"] = validated
|
||||
else:
|
||||
raise ValueError("Unknown filter field")
|
||||
|
||||
@@ -510,11 +510,12 @@ class Filter(BaseModel, Generic[TFilterModel]):
|
||||
for key in self.values.keys() if self.values else []:
|
||||
if self.model and self.model.__fields__[self.field].type_ == datetime:
|
||||
placeholder = compat_timestamp_placeholder(key)
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(f":{key}")
|
||||
else:
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} :{key}")
|
||||
placeholder = f":{key}"
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
stmt.append(placeholder)
|
||||
else:
|
||||
stmt.append(f"{prefix}{self.field} {self.op.as_sql} {placeholder}")
|
||||
|
||||
if self.op in {Operator.INCLUDE, Operator.EXCLUDE}:
|
||||
statement = f"{prefix}{self.field} {self.op.as_sql} ({', '.join(stmt)})"
|
||||
@@ -725,20 +726,20 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
|
||||
if get_origin(outertype_) is list:
|
||||
_items = _safe_load_json(value) if isinstance(value, str) else value
|
||||
_dict[key] = [
|
||||
dict_to_submodel(type_, v) if issubclass(type_, BaseModel) else v
|
||||
dict_to_submodel(type_, v) if _is_subclass(type_, BaseModel) else v
|
||||
for v in _items
|
||||
]
|
||||
continue
|
||||
if issubclass(type_, bool):
|
||||
if _is_subclass(type_, bool):
|
||||
_dict[key] = bool(value)
|
||||
continue
|
||||
if issubclass(type_, datetime):
|
||||
if _is_subclass(type_, datetime):
|
||||
if DB_TYPE == SQLITE:
|
||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||
else:
|
||||
_dict[key] = value.replace(tzinfo=timezone.utc)
|
||||
continue
|
||||
if issubclass(type_, BaseModel):
|
||||
if _is_subclass(type_, BaseModel):
|
||||
_dict[key] = dict_to_submodel(type_, value)
|
||||
continue
|
||||
# TODO: remove this when all sub models are migrated to Pydantic
|
||||
@@ -763,6 +764,13 @@ def _safe_load_json(value: str) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def _is_subclass(type_: Any, class_or_tuple: type | tuple[type, ...]) -> bool:
|
||||
try:
|
||||
return issubclass(type_, class_or_tuple)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def _valid_sql_name(name: str) -> bool:
|
||||
"""Check if a SQL name is valid (alphanumeric and underscores only)"""
|
||||
return (
|
||||
|
||||
+10
-1
@@ -448,7 +448,7 @@ async def _check_user_access(r: Request, user_id: str, conn: Connection | None =
|
||||
async def _check_user_extension_access(
|
||||
user_id: str, path: str, conn: Connection | None = None
|
||||
):
|
||||
ext_id = path_segments(path)[0]
|
||||
ext_id = _extension_id_from_request_path(path)
|
||||
status = await check_user_extension_access(user_id, ext_id, conn=conn)
|
||||
if not status.success:
|
||||
raise HTTPException(
|
||||
@@ -457,6 +457,15 @@ async def _check_user_extension_access(
|
||||
)
|
||||
|
||||
|
||||
def _extension_id_from_request_path(path: str) -> str:
|
||||
segments = path_segments(path)
|
||||
if len(segments) >= 2 and segments[0] == "ext":
|
||||
return segments[1]
|
||||
if len(segments) >= 4 and segments[:3] == ["api", "v1", "ext"]:
|
||||
return segments[3]
|
||||
return segments[0]
|
||||
|
||||
|
||||
async def _get_account_from_token(
|
||||
access_token: str, path: str, method: str, conn: Connection | None = None
|
||||
) -> Account | None:
|
||||
|
||||
@@ -9,6 +9,8 @@ from lnbits.fiat.base import FiatProvider
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .paypal import PayPalWallet
|
||||
from .revolut import RevolutWallet
|
||||
from .square import SquareWallet
|
||||
from .stripe import StripeWallet
|
||||
|
||||
fiat_module = importlib.import_module("lnbits.fiat")
|
||||
@@ -17,6 +19,8 @@ fiat_module = importlib.import_module("lnbits.fiat")
|
||||
class FiatProviderType(Enum):
|
||||
stripe = "StripeWallet"
|
||||
paypal = "PayPalWallet"
|
||||
square = "SquareWallet"
|
||||
revolut = "RevolutWallet"
|
||||
|
||||
|
||||
async def get_fiat_provider(name: str) -> FiatProvider | None:
|
||||
@@ -52,5 +56,7 @@ fiat_providers: dict[str, FiatProvider] = {}
|
||||
|
||||
__all__ = [
|
||||
"PayPalWallet",
|
||||
"RevolutWallet",
|
||||
"SquareWallet",
|
||||
"StripeWallet",
|
||||
]
|
||||
|
||||
+7
-3
@@ -95,6 +95,10 @@ class FiatSubscriptionPaymentOptions(BaseModel):
|
||||
description="Unique ID that can be used to identify the subscription request."
|
||||
"If not provided, one will be generated.",
|
||||
)
|
||||
customer_email: str | None = Field(
|
||||
default=None,
|
||||
description="The customer email to use for the subscription.",
|
||||
)
|
||||
tag: str | None = Field(
|
||||
default=None,
|
||||
description="Payments created by the recurring subscription"
|
||||
@@ -127,15 +131,15 @@ class FiatSubscriptionResponse(BaseModel):
|
||||
|
||||
|
||||
class FiatPaymentSuccessStatus(FiatPaymentStatus):
|
||||
paid = True
|
||||
paid = True # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatPaymentFailedStatus(FiatPaymentStatus):
|
||||
paid = False
|
||||
paid = False # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatPaymentPendingStatus(FiatPaymentStatus):
|
||||
paid = None
|
||||
paid = None # type: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class FiatProvider(ABC):
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
FiatInvoiceResponse,
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentResponse,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
FiatProvider,
|
||||
FiatStatusResponse,
|
||||
FiatSubscriptionPaymentOptions,
|
||||
FiatSubscriptionResponse,
|
||||
)
|
||||
|
||||
|
||||
class RevolutCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class RevolutCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checkout: RevolutCheckoutOptions | None = None
|
||||
|
||||
|
||||
class RevolutSubscriptionReference(BaseModel):
|
||||
wallet_id: str
|
||||
tag: str | None = None
|
||||
subscription_request_id: str | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
memo: str | None = None
|
||||
|
||||
|
||||
REVOLUT_WEBHOOK_EVENTS = [
|
||||
"ORDER_AUTHORISED",
|
||||
"ORDER_COMPLETED",
|
||||
"SUBSCRIPTION_INITIATED",
|
||||
]
|
||||
|
||||
ZERO_DECIMAL_CURRENCIES = {
|
||||
"BIF",
|
||||
"CLP",
|
||||
"DJF",
|
||||
"GNF",
|
||||
"ISK",
|
||||
"JPY",
|
||||
"KMF",
|
||||
"KRW",
|
||||
"PYG",
|
||||
"RWF",
|
||||
"UGX",
|
||||
"VND",
|
||||
"VUV",
|
||||
"XAF",
|
||||
"XOF",
|
||||
"XPF",
|
||||
}
|
||||
THREE_DECIMAL_CURRENCIES = {
|
||||
"BHD",
|
||||
"IQD",
|
||||
"JOD",
|
||||
"KWD",
|
||||
"LYD",
|
||||
"OMR",
|
||||
"TND",
|
||||
}
|
||||
REVOLUT_CUSTOMER_LIST_LIMIT = 500
|
||||
REVOLUT_CUSTOMER_LIST_MAX_PAGES = 20
|
||||
REVOLUT_REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
class RevolutWallet(FiatProvider):
|
||||
"""https://developer.revolut.com/docs/merchant"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing RevolutWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.revolut_api_endpoint:
|
||||
raise ValueError("Cannot initialize RevolutWallet: missing endpoint.")
|
||||
if not settings.revolut_api_secret_key:
|
||||
raise ValueError("Cannot initialize RevolutWallet: missing API secret key.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.revolut_api_endpoint)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.revolut_api_secret_key}",
|
||||
"Revolut-Api-Version": settings.revolut_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("RevolutWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing Revolut wallet connection: {e}")
|
||||
|
||||
async def status(
|
||||
self, only_check_settings: bool | None = False
|
||||
) -> FiatStatusResponse:
|
||||
if only_check_settings:
|
||||
if self._settings_fields != self._settings_connection_fields():
|
||||
return FiatStatusResponse("Connection settings have changed.", 0)
|
||||
return FiatStatusResponse(balance=0)
|
||||
|
||||
try:
|
||||
r = await self.client.get(
|
||||
"/api/orders",
|
||||
params={"limit": 1},
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
_ = r.json()
|
||||
return FiatStatusResponse(balance=0)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> FiatInvoiceResponse:
|
||||
opts = self._parse_create_opts(extra or {})
|
||||
if opts is None:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Invalid Revolut options"
|
||||
)
|
||||
|
||||
amount_minor = self.amount_to_minor_units(amount, currency)
|
||||
checkout = opts.checkout or RevolutCheckoutOptions()
|
||||
success_url = (
|
||||
checkout.success_url
|
||||
or settings.revolut_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
|
||||
payload = {
|
||||
"amount": amount_minor,
|
||||
"currency": currency.upper(),
|
||||
"description": checkout.description or memo or "LNbits Invoice",
|
||||
"redirect_url": success_url,
|
||||
"metadata": {
|
||||
**checkout.metadata,
|
||||
"payment_hash": payment_hash,
|
||||
"alan_action": "invoice",
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/api/orders", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
order_id = data.get("id")
|
||||
checkout_url = data.get("checkout_url")
|
||||
if not order_id or not checkout_url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing order id or url"
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"order_{order_id}",
|
||||
payment_request=checkout_url,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
quantity: int,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
if quantity != 1:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False,
|
||||
error_message="Revolut subscriptions do not support quantity.",
|
||||
)
|
||||
|
||||
wallet_id = payment_options.wallet_id
|
||||
if not wallet_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Wallet ID is required."
|
||||
)
|
||||
|
||||
extra = payment_options.extra or {}
|
||||
if not payment_options.subscription_request_id:
|
||||
payment_options.subscription_request_id = urlsafe_short_hash()
|
||||
|
||||
reference = RevolutSubscriptionReference(
|
||||
wallet_id=wallet_id,
|
||||
tag=payment_options.tag,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
extra=extra,
|
||||
memo=payment_options.memo,
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"plan_variation_id": subscription_id,
|
||||
"external_reference": self._serialize_subscription_reference(reference),
|
||||
"setup_order_redirect_url": (
|
||||
payment_options.success_url
|
||||
or settings.revolut_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
),
|
||||
}
|
||||
if extra.get("trial_duration"):
|
||||
payload["trial_duration"] = extra["trial_duration"]
|
||||
|
||||
headers = {
|
||||
**self.headers,
|
||||
"Idempotency-Key": payment_options.subscription_request_id,
|
||||
}
|
||||
|
||||
try:
|
||||
customer_id, customer_error = await self._get_subscription_customer_id(
|
||||
payment_options
|
||||
)
|
||||
if not customer_id:
|
||||
return FiatSubscriptionResponse(ok=False, error_message=customer_error)
|
||||
payload["customer_id"] = customer_id
|
||||
r = await self.client.post(
|
||||
"/api/subscriptions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
revolut_subscription_id = data.get("id")
|
||||
setup_order_id = data.get("setup_order_id")
|
||||
if not revolut_subscription_id or not setup_order_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False,
|
||||
error_message=(
|
||||
"Server error: missing subscription id or setup order id"
|
||||
),
|
||||
)
|
||||
|
||||
setup_order = await self.get_order(setup_order_id)
|
||||
checkout_url = setup_order.get("checkout_url")
|
||||
if not checkout_url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing setup checkout url"
|
||||
)
|
||||
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=checkout_url,
|
||||
subscription_request_id=revolut_subscription_id,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
correlation_id: str,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
try:
|
||||
subscription = await self.get_subscription(subscription_id)
|
||||
reference = self.deserialize_subscription_reference(
|
||||
subscription.get("external_reference")
|
||||
)
|
||||
if not reference or reference.wallet_id != correlation_id:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Subscription not found."
|
||||
)
|
||||
|
||||
r = await self.client.post(
|
||||
f"/api/subscriptions/{subscription_id}/cancel",
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return FiatSubscriptionResponse(ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Unable to cancel subscription."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||
raise NotImplementedError("Revolut does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
order_id = self._normalize_revolut_id(checking_id)
|
||||
return self._status_from_order(await self.get_order(order_id))
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting Revolut invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("Revolut does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"Revolut does not support paid invoices stream. Use webhooks instead."
|
||||
)
|
||||
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
value = await mock_queue.get()
|
||||
yield value
|
||||
|
||||
def _normalize_revolut_id(self, checking_id: str) -> str:
|
||||
value = (
|
||||
checking_id.replace("fiat_revolut_", "", 1)
|
||||
if checking_id.startswith("fiat_revolut_")
|
||||
else checking_id
|
||||
)
|
||||
return value.replace("order_", "", 1) if value.startswith("order_") else value
|
||||
|
||||
async def get_order(self, order_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/orders/{order_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def get_subscription(self, subscription_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/subscriptions/{subscription_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def get_subscription_cycle(
|
||||
self, subscription_id: str, cycle_id: str
|
||||
) -> dict[str, Any]:
|
||||
r = await self.client.get(
|
||||
f"/api/subscriptions/{subscription_id}/cycles/{cycle_id}",
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def _get_subscription_customer_id(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> tuple[str | None, str | None]:
|
||||
if not payment_options.customer_email:
|
||||
return (
|
||||
None,
|
||||
"Revolut subscriptions require customer_email.",
|
||||
)
|
||||
|
||||
customer = await self._get_customer_by_email(payment_options.customer_email)
|
||||
customer_id = customer.get("id") if customer else None
|
||||
if customer_id:
|
||||
return customer_id, None
|
||||
|
||||
customer = await self._create_customer(payment_options.customer_email)
|
||||
customer_id = customer.get("id")
|
||||
if not customer_id:
|
||||
return None, "Server error: missing customer id"
|
||||
return customer_id, None
|
||||
|
||||
async def _get_customer_by_email(self, email: str) -> dict[str, Any] | None:
|
||||
page_token = None
|
||||
for _ in range(REVOLUT_CUSTOMER_LIST_MAX_PAGES):
|
||||
customer_page = await self._list_customers(page_token=page_token)
|
||||
customer = _find_customer_by_email(customer_page["customers"], email)
|
||||
if customer:
|
||||
return customer
|
||||
|
||||
page_token = customer_page.get("next_page_token")
|
||||
if not page_token:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _list_customers(self, page_token: str | None = None) -> dict[str, Any]:
|
||||
params: dict[str, Any] = {"limit": REVOLUT_CUSTOMER_LIST_LIMIT}
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
r = await self.client.get(
|
||||
"/api/customers", params=params, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
r.raise_for_status()
|
||||
return _extract_customer_page(r.json())
|
||||
|
||||
async def _create_customer(self, email: str) -> dict[str, Any]:
|
||||
r = await self.client.post(
|
||||
"/api/customers",
|
||||
json={"email": email},
|
||||
timeout=REVOLUT_REQUEST_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@classmethod
|
||||
async def create_webhook(
|
||||
cls,
|
||||
url: str,
|
||||
endpoint: str | None = None,
|
||||
api_secret_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not url:
|
||||
raise ValueError("Missing Revolut webhook URL.")
|
||||
cls._validate_webhook_url(url)
|
||||
if not endpoint and not settings.revolut_api_endpoint:
|
||||
raise ValueError("Missing Revolut API endpoint.")
|
||||
if not api_secret_key and not settings.revolut_api_secret_key:
|
||||
raise ValueError("Missing Revolut API secret key.")
|
||||
|
||||
base_url = normalize_endpoint(endpoint or settings.revolut_api_endpoint)
|
||||
secret_key = api_secret_key or settings.revolut_api_secret_key
|
||||
headers = {
|
||||
"Authorization": f"Bearer {secret_key}",
|
||||
"Revolut-Api-Version": api_version or settings.revolut_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
payload = {"url": url, "events": REVOLUT_WEBHOOK_EVENTS}
|
||||
async with httpx.AsyncClient(base_url=base_url, headers=headers) as client:
|
||||
webhooks = await cls._list_webhooks(client)
|
||||
existing = await cls._get_existing_webhook(client, webhooks, url)
|
||||
if existing:
|
||||
existing["already_exists"] = True
|
||||
return existing
|
||||
|
||||
response = await client.post(
|
||||
"/api/webhooks", json=payload, timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@classmethod
|
||||
async def _list_webhooks(cls, client: httpx.AsyncClient) -> list[dict[str, Any]]:
|
||||
response = await client.get("/api/webhooks", timeout=REVOLUT_REQUEST_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict):
|
||||
for field in ["webhooks", "data", "items"]:
|
||||
if isinstance(data.get(field), list):
|
||||
return data[field]
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
async def _get_existing_webhook(
|
||||
cls, client: httpx.AsyncClient, webhooks: list[dict[str, Any]], url: str
|
||||
) -> dict[str, Any] | None:
|
||||
for webhook in webhooks:
|
||||
if cls._normalize_webhook_url(webhook.get("url")) != (
|
||||
cls._normalize_webhook_url(url)
|
||||
):
|
||||
continue
|
||||
|
||||
webhook_id = webhook.get("id")
|
||||
if webhook_id and (
|
||||
not webhook.get("events") or not webhook.get("signing_secret")
|
||||
):
|
||||
response = await client.get(
|
||||
f"/api/webhooks/{webhook_id}", timeout=REVOLUT_REQUEST_TIMEOUT
|
||||
)
|
||||
response.raise_for_status()
|
||||
webhook = response.json()
|
||||
|
||||
events = set(webhook.get("events") or [])
|
||||
missing_events = set(REVOLUT_WEBHOOK_EVENTS) - events
|
||||
if missing_events:
|
||||
raise ValueError(
|
||||
"A Revolut webhook already exists for this URL, but it is "
|
||||
f"missing required events: {', '.join(sorted(missing_events))}."
|
||||
)
|
||||
|
||||
if not webhook.get("signing_secret"):
|
||||
raise ValueError(
|
||||
"A Revolut webhook already exists for this URL, but Revolut "
|
||||
"did not return a signing secret."
|
||||
)
|
||||
|
||||
return webhook
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _normalize_webhook_url(cls, url: str | None) -> str:
|
||||
return (url or "").strip().rstrip("/")
|
||||
|
||||
@classmethod
|
||||
def _validate_webhook_url(cls, url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
if parsed.scheme not in ["http", "https"] or not hostname:
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
host = hostname.lower()
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
if host.endswith(".local") or host.endswith(".onion"):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
):
|
||||
raise ValueError("Revolut webhook URL must be a clearnet URL.")
|
||||
|
||||
def _status_from_order(self, order: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (order.get("state") or "").upper()
|
||||
if status == "COMPLETED":
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["CANCELLED", "FAILED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
@classmethod
|
||||
def amount_to_minor_units(cls, amount: float | Decimal, currency: str) -> int:
|
||||
scale = Decimal(10) ** cls.currency_exponent(currency)
|
||||
return int((Decimal(str(amount)) * scale).quantize(Decimal("1"), ROUND_HALF_UP))
|
||||
|
||||
@classmethod
|
||||
def minor_units_to_amount(cls, amount: int, currency: str) -> float:
|
||||
scale = Decimal(10) ** cls.currency_exponent(currency)
|
||||
return float(Decimal(amount) / scale)
|
||||
|
||||
@classmethod
|
||||
def currency_exponent(cls, currency: str) -> int:
|
||||
normalized = currency.upper()
|
||||
if normalized in ZERO_DECIMAL_CURRENCIES:
|
||||
return 0
|
||||
if normalized in THREE_DECIMAL_CURRENCIES:
|
||||
return 3
|
||||
return 2
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> RevolutCreateInvoiceOptions | None:
|
||||
try:
|
||||
return RevolutCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid Revolut options: {e}")
|
||||
return None
|
||||
|
||||
def _serialize_subscription_reference(
|
||||
self, reference: RevolutSubscriptionReference
|
||||
) -> str:
|
||||
payload = reference.dict(exclude_none=True)
|
||||
serialized = json.dumps(payload, separators=(",", ":"))
|
||||
if len(serialized) > 1024:
|
||||
raise ValueError("Revolut subscription external_reference is too long.")
|
||||
return serialized
|
||||
|
||||
def deserialize_subscription_reference(
|
||||
self, external_reference: str | None
|
||||
) -> RevolutSubscriptionReference | None:
|
||||
if not external_reference:
|
||||
return None
|
||||
try:
|
||||
return RevolutSubscriptionReference.parse_obj(
|
||||
json.loads(external_reference)
|
||||
)
|
||||
except (json.JSONDecodeError, ValidationError) as exc:
|
||||
logger.warning(exc)
|
||||
return None
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.revolut_api_endpoint),
|
||||
str(settings.revolut_api_secret_key),
|
||||
str(settings.revolut_api_version),
|
||||
str(settings.revolut_webhook_signing_secret),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _extract_customer_page(data: Any) -> dict[str, Any]:
|
||||
if isinstance(data, list):
|
||||
return {"customers": _filter_customer_list(data)}
|
||||
if isinstance(data, dict):
|
||||
for field in ["customers", "data", "items"]:
|
||||
customers = data.get(field)
|
||||
if isinstance(customers, list):
|
||||
return {
|
||||
"customers": _filter_customer_list(customers),
|
||||
"next_page_token": data.get("next_page_token"),
|
||||
}
|
||||
return {"customers": []}
|
||||
|
||||
|
||||
def _filter_customer_list(customers: list[Any]) -> list[dict[str, Any]]:
|
||||
return [customer for customer in customers if isinstance(customer, dict)]
|
||||
|
||||
|
||||
def _find_customer_by_email(
|
||||
customers: list[dict[str, Any]], email: str
|
||||
) -> dict[str, Any] | None:
|
||||
normalized_email = email.casefold()
|
||||
for customer in customers:
|
||||
if str(customer.get("email") or "").casefold() == normalized_email:
|
||||
return customer
|
||||
return None
|
||||
@@ -0,0 +1,620 @@
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
from lnbits.helpers import normalize_endpoint, urlsafe_short_hash
|
||||
from lnbits.settings import settings
|
||||
|
||||
from .base import (
|
||||
FiatInvoiceResponse,
|
||||
FiatPaymentFailedStatus,
|
||||
FiatPaymentPendingStatus,
|
||||
FiatPaymentResponse,
|
||||
FiatPaymentStatus,
|
||||
FiatPaymentSuccessStatus,
|
||||
FiatProvider,
|
||||
FiatStatusResponse,
|
||||
FiatSubscriptionPaymentOptions,
|
||||
FiatSubscriptionResponse,
|
||||
)
|
||||
|
||||
FiatMethod = Literal["checkout", "subscription"]
|
||||
|
||||
|
||||
class SquareCheckoutOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
success_url: str | None = None
|
||||
metadata: dict[str, str] = Field(default_factory=dict)
|
||||
line_item_name: str | None = None
|
||||
|
||||
|
||||
class SquareSubscriptionOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
checking_id: str | None = None
|
||||
payment_request: str | None = None
|
||||
|
||||
|
||||
class SquareCreateInvoiceOptions(BaseModel):
|
||||
class Config:
|
||||
extra = "ignore"
|
||||
|
||||
fiat_method: FiatMethod = "checkout"
|
||||
checkout: SquareCheckoutOptions | None = None
|
||||
subscription: SquareSubscriptionOptions | None = None
|
||||
|
||||
|
||||
class SquareSubscriptionCheckoutInfo(BaseModel):
|
||||
plan_variation_id: str
|
||||
price_money: dict[str, Any]
|
||||
|
||||
|
||||
class SquareWallet(FiatProvider):
|
||||
"""https://developer.squareup.com/reference/square"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initializing SquareWallet")
|
||||
self._settings_fields = self._settings_connection_fields()
|
||||
if not settings.square_api_endpoint:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing endpoint.")
|
||||
if not settings.square_access_token:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing access token.")
|
||||
if not settings.square_location_id:
|
||||
raise ValueError("Cannot initialize SquareWallet: missing location ID.")
|
||||
|
||||
self.endpoint = normalize_endpoint(settings.square_api_endpoint)
|
||||
self.location_id = settings.square_location_id
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {settings.square_access_token}",
|
||||
"Square-Version": settings.square_api_version,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": settings.user_agent,
|
||||
}
|
||||
self.client = httpx.AsyncClient(base_url=self.endpoint, headers=self.headers)
|
||||
logger.info("SquareWallet initialized.")
|
||||
|
||||
async def cleanup(self):
|
||||
try:
|
||||
await self.client.aclose()
|
||||
except RuntimeError as e:
|
||||
logger.warning(f"Error closing Square wallet connection: {e}")
|
||||
|
||||
async def status(
|
||||
self, only_check_settings: bool | None = False
|
||||
) -> FiatStatusResponse:
|
||||
if only_check_settings:
|
||||
if self._settings_fields != self._settings_connection_fields():
|
||||
return FiatStatusResponse("Connection settings have changed.", 0)
|
||||
return FiatStatusResponse(balance=0)
|
||||
|
||||
try:
|
||||
r = await self.client.get(f"/v2/locations/{self.location_id}", timeout=15)
|
||||
r.raise_for_status()
|
||||
_ = r.json()
|
||||
return FiatStatusResponse(balance=0)
|
||||
except json.JSONDecodeError:
|
||||
return FiatStatusResponse("Server error: 'invalid json response'", 0)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatStatusResponse(f"Unable to connect to {self.endpoint}.", 0)
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
memo: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
**kwargs,
|
||||
) -> FiatInvoiceResponse:
|
||||
opts = self._parse_create_opts(extra or {})
|
||||
if not opts:
|
||||
return FiatInvoiceResponse(ok=False, error_message="Invalid Square options")
|
||||
|
||||
if opts.fiat_method == "subscription":
|
||||
return self._create_subscription_invoice(opts.subscription)
|
||||
|
||||
return await self._create_checkout_invoice(
|
||||
amount=amount,
|
||||
payment_hash=payment_hash,
|
||||
currency=currency,
|
||||
opts=opts,
|
||||
memo=memo,
|
||||
)
|
||||
|
||||
async def create_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
quantity: int,
|
||||
payment_options: FiatSubscriptionPaymentOptions,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
if settings.lnbits_running:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Subscription not supported for Square."
|
||||
)
|
||||
success_url = (
|
||||
payment_options.success_url
|
||||
or settings.square_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
|
||||
if not payment_options.subscription_request_id:
|
||||
payment_options.subscription_request_id = urlsafe_short_hash()
|
||||
payment_options.extra = payment_options.extra or {}
|
||||
payment_options.extra["subscription_request_id"] = (
|
||||
payment_options.subscription_request_id
|
||||
)
|
||||
try:
|
||||
checkout_info = await self._get_subscription_checkout_info(subscription_id)
|
||||
metadata = self._serialize_metadata(payment_options)
|
||||
payload = {
|
||||
"idempotency_key": payment_options.subscription_request_id,
|
||||
"description": metadata,
|
||||
"quick_pay": {
|
||||
"name": (payment_options.memo or "LNbits Subscription")[:255],
|
||||
"price_money": checkout_info.price_money,
|
||||
"location_id": self.location_id,
|
||||
},
|
||||
"checkout_options": {
|
||||
"redirect_url": success_url,
|
||||
"subscription_plan_id": checkout_info.plan_variation_id,
|
||||
},
|
||||
"payment_note": metadata,
|
||||
}
|
||||
r = await self.client.post(
|
||||
"/v2/online-checkout/payment-links", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
payment_link = data.get("payment_link") or {}
|
||||
url = payment_link.get("url")
|
||||
if not url:
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: missing url"
|
||||
)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=True,
|
||||
checkout_session_url=url,
|
||||
subscription_request_id=payment_options.subscription_request_id,
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
async def cancel_subscription(
|
||||
self,
|
||||
subscription_id: str,
|
||||
correlation_id: str,
|
||||
**kwargs,
|
||||
) -> FiatSubscriptionResponse:
|
||||
try:
|
||||
square_subscription_id = await self._get_square_subscription_id(
|
||||
subscription_id, correlation_id
|
||||
)
|
||||
r = await self.client.post(
|
||||
f"/v2/subscriptions/{square_subscription_id}/cancel"
|
||||
)
|
||||
r.raise_for_status()
|
||||
return FiatSubscriptionResponse(ok=True)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatSubscriptionResponse(
|
||||
ok=False, error_message="Unable to cancel subscription."
|
||||
)
|
||||
|
||||
async def pay_invoice(self, payment_request: str) -> FiatPaymentResponse:
|
||||
raise NotImplementedError("Square does not support paying invoices directly.")
|
||||
|
||||
async def get_invoice_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
try:
|
||||
square_id = self._normalize_square_id(checking_id)
|
||||
if square_id.startswith("payment_"):
|
||||
payment_id = square_id.replace("payment_", "", 1)
|
||||
return await self._get_payment_status(payment_id)
|
||||
|
||||
order_id = (
|
||||
square_id.replace("order_", "", 1)
|
||||
if square_id.startswith("order_")
|
||||
else square_id
|
||||
)
|
||||
return await self._get_order_status(order_id)
|
||||
except Exception as exc:
|
||||
logger.debug(f"Error getting Square invoice status: {exc}")
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def get_payment_status(self, checking_id: str) -> FiatPaymentStatus:
|
||||
raise NotImplementedError("Square does not support outgoing payments.")
|
||||
|
||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
||||
logger.warning(
|
||||
"Square does not support paid invoices stream. Use webhooks instead."
|
||||
)
|
||||
mock_queue: asyncio.Queue[str] = asyncio.Queue(0)
|
||||
while settings.lnbits_running:
|
||||
value = await mock_queue.get()
|
||||
yield value
|
||||
|
||||
async def _get_order_status(self, order_id: str) -> FiatPaymentStatus:
|
||||
order = await self._get_order(order_id)
|
||||
payment_id = self._payment_id_from_order(order)
|
||||
if payment_id:
|
||||
return await self._get_payment_status(payment_id)
|
||||
|
||||
if (order.get("state") or "").upper() == "CANCELED":
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def _get_order(self, order_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/orders/{order_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("order") or {}
|
||||
|
||||
async def get_payment_for_order(self, order_id: str) -> dict[str, Any] | None:
|
||||
order = await self._get_order(order_id)
|
||||
payment_id = self._payment_id_from_order(order)
|
||||
if not payment_id:
|
||||
return None
|
||||
return await self._get_payment(payment_id)
|
||||
|
||||
def _payment_id_from_order(self, order: dict[str, Any]) -> str | None:
|
||||
tenders = order.get("tenders") or []
|
||||
for tender in tenders:
|
||||
payment_id = tender.get("payment_id")
|
||||
if payment_id:
|
||||
return payment_id
|
||||
return None
|
||||
|
||||
async def _get_payment_status(self, payment_id: str) -> FiatPaymentStatus:
|
||||
return self._status_from_payment(await self._get_payment(payment_id))
|
||||
|
||||
async def _get_payment(self, payment_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/payments/{payment_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("payment") or {}
|
||||
|
||||
async def _get_subscription_checkout_info(
|
||||
self, subscription_plan_id: str
|
||||
) -> SquareSubscriptionCheckoutInfo:
|
||||
catalog_object = await self._get_catalog_object(subscription_plan_id)
|
||||
if catalog_object.get("type") == "SUBSCRIPTION_PLAN":
|
||||
return await self._get_plan_checkout_info(catalog_object)
|
||||
|
||||
if catalog_object.get("type") == "SUBSCRIPTION_PLAN_VARIATION":
|
||||
price_money = await self._get_subscription_price_money(
|
||||
catalog_object,
|
||||
)
|
||||
plan_variation_id = catalog_object.get("id")
|
||||
if not plan_variation_id:
|
||||
raise ValueError("Square subscription plan variation is missing an ID.")
|
||||
return SquareSubscriptionCheckoutInfo(
|
||||
plan_variation_id=plan_variation_id,
|
||||
price_money=price_money,
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
"Square subscription ID must be a plan ID or plan variation ID."
|
||||
)
|
||||
|
||||
async def _get_plan_checkout_info(
|
||||
self, catalog_object: dict[str, Any]
|
||||
) -> SquareSubscriptionCheckoutInfo:
|
||||
plan_data = catalog_object.get("subscription_plan_data") or {}
|
||||
plan_variations = plan_data.get("subscription_plan_variations") or []
|
||||
eligible_item_ids = plan_data.get("eligible_item_ids") or []
|
||||
plan_variation = next(
|
||||
(
|
||||
variation
|
||||
for variation in plan_variations
|
||||
if not variation.get("is_deleted")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not plan_variation:
|
||||
raise ValueError("Square subscription plan is missing a variation.")
|
||||
|
||||
price_money = await self._get_subscription_price_money(
|
||||
plan_variation,
|
||||
eligible_item_ids=eligible_item_ids,
|
||||
)
|
||||
plan_variation_id = plan_variation.get("id")
|
||||
if not plan_variation_id:
|
||||
raise ValueError("Square subscription plan variation is missing an ID.")
|
||||
|
||||
return SquareSubscriptionCheckoutInfo(
|
||||
plan_variation_id=plan_variation_id,
|
||||
price_money=price_money,
|
||||
)
|
||||
|
||||
async def _get_catalog_object(self, object_id: str) -> dict[str, Any]:
|
||||
r = await self.client.get(f"/v2/catalog/object/{object_id}")
|
||||
r.raise_for_status()
|
||||
return r.json().get("object") or {}
|
||||
|
||||
async def _get_subscription_price_money(
|
||||
self,
|
||||
plan_variation: dict[str, Any],
|
||||
eligible_item_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
variation_data = plan_variation.get("subscription_plan_variation_data") or {}
|
||||
phases = variation_data.get("phases") or []
|
||||
for phase in phases:
|
||||
pricing = phase.get("pricing") or {}
|
||||
price_money = pricing.get("price_money") or phase.get(
|
||||
"recurring_price_money"
|
||||
)
|
||||
parsed_price_money = self._parse_price_money(price_money)
|
||||
if parsed_price_money:
|
||||
return parsed_price_money
|
||||
|
||||
if pricing.get("type") == "RELATIVE":
|
||||
return await self._get_relative_subscription_price_money(
|
||||
eligible_item_ids or []
|
||||
)
|
||||
|
||||
raise ValueError("Square subscription plan variation is missing price_money.")
|
||||
|
||||
async def _get_relative_subscription_price_money(
|
||||
self, eligible_item_ids: list[str]
|
||||
) -> dict[str, Any]:
|
||||
if len(eligible_item_ids) != 1:
|
||||
raise ValueError(
|
||||
"Square relative subscription plan must have exactly one item."
|
||||
)
|
||||
|
||||
item = await self._get_catalog_object(eligible_item_ids[0])
|
||||
item_variations: list[dict[str, Any]] = []
|
||||
if item.get("type") == "ITEM":
|
||||
item_variations = (item.get("item_data") or {}).get("variations") or []
|
||||
elif item.get("type") == "ITEM_VARIATION":
|
||||
item_variations = [item]
|
||||
|
||||
item_variation = next(
|
||||
(
|
||||
variation
|
||||
for variation in item_variations
|
||||
if not variation.get("is_deleted")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not item_variation:
|
||||
raise ValueError("Square subscription item is missing a variation.")
|
||||
|
||||
price_money = self._parse_price_money(
|
||||
(item_variation.get("item_variation_data") or {}).get("price_money")
|
||||
)
|
||||
if price_money:
|
||||
return price_money
|
||||
|
||||
raise ValueError("Square subscription item variation is missing price_money.")
|
||||
|
||||
def _parse_price_money(
|
||||
self, price_money: dict[str, Any] | None
|
||||
) -> dict[str, Any] | None:
|
||||
if (
|
||||
price_money
|
||||
and price_money.get("amount") is not None
|
||||
and price_money.get("currency")
|
||||
):
|
||||
return {
|
||||
"amount": int(price_money["amount"]),
|
||||
"currency": price_money["currency"].upper(),
|
||||
}
|
||||
return None
|
||||
|
||||
def _status_from_payment(self, payment: dict[str, Any]) -> FiatPaymentStatus:
|
||||
status = (payment.get("status") or "").upper()
|
||||
if status == "COMPLETED":
|
||||
return FiatPaymentSuccessStatus()
|
||||
if status in ["CANCELED", "FAILED"]:
|
||||
return FiatPaymentFailedStatus()
|
||||
return FiatPaymentPendingStatus()
|
||||
|
||||
async def _create_checkout_invoice(
|
||||
self,
|
||||
amount: float,
|
||||
payment_hash: str,
|
||||
currency: str,
|
||||
opts: SquareCreateInvoiceOptions,
|
||||
memo: str | None = None,
|
||||
) -> FiatInvoiceResponse:
|
||||
amount_cents = int(amount * 100)
|
||||
co = opts.checkout or SquareCheckoutOptions()
|
||||
success_url = (
|
||||
co.success_url
|
||||
or settings.square_payment_success_url
|
||||
or "https://lnbits.com"
|
||||
)
|
||||
line_item_name = (co.line_item_name or memo or "LNbits Invoice")[:255]
|
||||
metadata = {
|
||||
**co.metadata,
|
||||
"payment_hash": payment_hash,
|
||||
"alan_action": "invoice",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"idempotency_key": payment_hash,
|
||||
"order": {
|
||||
"location_id": self.location_id,
|
||||
"metadata": metadata,
|
||||
"line_items": [
|
||||
{
|
||||
"name": line_item_name,
|
||||
"quantity": "1",
|
||||
"base_price_money": {
|
||||
"amount": amount_cents,
|
||||
"currency": currency.upper(),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"checkout_options": {"redirect_url": success_url},
|
||||
}
|
||||
if memo:
|
||||
payload["payment_note"] = memo[:500]
|
||||
|
||||
try:
|
||||
r = await self.client.post(
|
||||
"/v2/online-checkout/payment-links", json=payload
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
payment_link = data.get("payment_link") or {}
|
||||
order_id = payment_link.get("order_id")
|
||||
url = payment_link.get("url")
|
||||
if not order_id or not url:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: missing order id or url"
|
||||
)
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=f"order_{order_id}",
|
||||
payment_request=url,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message="Server error: invalid json response"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
return FiatInvoiceResponse(
|
||||
ok=False, error_message=f"Unable to connect to {self.endpoint}."
|
||||
)
|
||||
|
||||
def _create_subscription_invoice(
|
||||
self, opts: SquareSubscriptionOptions | None
|
||||
) -> FiatInvoiceResponse:
|
||||
term = opts or SquareSubscriptionOptions()
|
||||
checking_id = term.checking_id or f"payment_{urlsafe_short_hash()}"
|
||||
return FiatInvoiceResponse(
|
||||
ok=True,
|
||||
checking_id=checking_id,
|
||||
payment_request=term.payment_request or "",
|
||||
)
|
||||
|
||||
def _normalize_square_id(self, checking_id: str) -> str:
|
||||
return (
|
||||
checking_id.replace("fiat_square_", "", 1)
|
||||
if checking_id.startswith("fiat_square_")
|
||||
else checking_id
|
||||
)
|
||||
|
||||
def _parse_create_opts(
|
||||
self, raw_opts: dict[str, Any]
|
||||
) -> SquareCreateInvoiceOptions | None:
|
||||
try:
|
||||
return SquareCreateInvoiceOptions.parse_obj(raw_opts)
|
||||
except ValidationError as e:
|
||||
logger.warning(f"Invalid Square options: {e}")
|
||||
return None
|
||||
|
||||
def _serialize_metadata(
|
||||
self, payment_options: FiatSubscriptionPaymentOptions
|
||||
) -> str:
|
||||
extra_link = None
|
||||
if payment_options.extra:
|
||||
raw_link = payment_options.extra.get("link")
|
||||
extra_link = str(raw_link)[:200] if raw_link else None
|
||||
|
||||
meta = [
|
||||
payment_options.wallet_id,
|
||||
payment_options.tag,
|
||||
payment_options.subscription_request_id,
|
||||
extra_link,
|
||||
]
|
||||
|
||||
memo_limit = 493 - len(json.dumps(meta, separators=(",", ":")))
|
||||
if memo_limit > 0 and payment_options.memo:
|
||||
meta.append(payment_options.memo[:memo_limit])
|
||||
else:
|
||||
meta.append(None)
|
||||
|
||||
metadata = json.dumps(meta, separators=(",", ":"))
|
||||
if len(metadata) > 500:
|
||||
raise ValueError("Square subscription metadata is too long.")
|
||||
return metadata
|
||||
|
||||
async def _get_square_subscription_id(
|
||||
self, subscription_id: str, wallet_id: str
|
||||
) -> str:
|
||||
try:
|
||||
from lnbits.core.crud.payments import get_payments
|
||||
from lnbits.core.models import PaymentFilters
|
||||
from lnbits.db import Filter, Filters
|
||||
|
||||
payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
filters=Filters(
|
||||
filters=[
|
||||
Filter.parse_query(
|
||||
"external_id", [subscription_id], PaymentFilters
|
||||
)
|
||||
],
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
limit=1,
|
||||
),
|
||||
)
|
||||
payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in payments
|
||||
if payment.external_id and payment.fiat_provider == "square"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if payment and payment.external_id:
|
||||
return payment.external_id
|
||||
|
||||
payments = await get_payments(
|
||||
wallet_id=wallet_id,
|
||||
incoming=True,
|
||||
filters=Filters(
|
||||
model=PaymentFilters,
|
||||
sortby="created_at",
|
||||
direction="desc",
|
||||
),
|
||||
)
|
||||
payment = next(
|
||||
(
|
||||
payment
|
||||
for payment in payments
|
||||
if payment.external_id
|
||||
and payment.fiat_provider == "square"
|
||||
and (payment.extra or {}).get("subscription_request_id")
|
||||
== subscription_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if payment and payment.external_id:
|
||||
return payment.external_id
|
||||
except Exception as exc:
|
||||
logger.warning(exc)
|
||||
|
||||
return subscription_id
|
||||
|
||||
def _settings_connection_fields(self) -> str:
|
||||
return "-".join(
|
||||
[
|
||||
str(settings.square_api_endpoint),
|
||||
str(settings.square_access_token),
|
||||
str(settings.square_location_id),
|
||||
str(settings.square_api_version),
|
||||
]
|
||||
)
|
||||
@@ -372,3 +372,13 @@ def sha256s(value: str) -> str:
|
||||
Returns the hex as a string.
|
||||
"""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def daystart_timestamp(dt: datetime | None = None) -> int:
|
||||
"""
|
||||
Returns the timestamp of the start of the day for the given
|
||||
datetime (or now in UTC if not provided).
|
||||
"""
|
||||
dt = dt or datetime.now(timezone.utc)
|
||||
day_start = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return int(day_start.timestamp())
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Generate llms.txt markdown from FastAPI OpenAPI schema for AI agents."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
|
||||
def generate_llms_txt(app: FastAPI) -> str:
|
||||
"""Convert an OpenAPI schema to llms.txt markdown format."""
|
||||
openapi_schema = app.openapi()
|
||||
lines: list[str] = []
|
||||
|
||||
# H1: API Title
|
||||
info = openapi_schema.get("info", {})
|
||||
title = info.get("title", "API")
|
||||
lines.append(f"# {title}")
|
||||
lines.append("")
|
||||
|
||||
# Blockquote: Description
|
||||
description = info.get("description")
|
||||
if description:
|
||||
for line in description.strip().split("\n"):
|
||||
lines.append(f"> {line}")
|
||||
lines.append("")
|
||||
|
||||
# Group endpoints by tag
|
||||
paths = openapi_schema.get("paths", {})
|
||||
endpoints_by_tag: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
for path, path_item in paths.items():
|
||||
for method in ["get", "post", "put", "patch", "delete", "head", "options"]:
|
||||
if method not in path_item:
|
||||
continue
|
||||
operation = path_item[method]
|
||||
tags = operation.get("tags", ["Endpoints"])
|
||||
tag = tags[0] if tags else "Endpoints"
|
||||
if tag not in endpoints_by_tag:
|
||||
endpoints_by_tag[tag] = []
|
||||
endpoints_by_tag[tag].append(
|
||||
{
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operation": operation,
|
||||
}
|
||||
)
|
||||
|
||||
# Generate sections by tag
|
||||
for tag, endpoints in endpoints_by_tag.items():
|
||||
lines.append(f"## {tag}")
|
||||
lines.append("")
|
||||
for endpoint in endpoints:
|
||||
method = endpoint["method"]
|
||||
path = endpoint["path"]
|
||||
operation = endpoint["operation"]
|
||||
summary = operation.get("summary", "")
|
||||
if summary:
|
||||
lines.append(f"### `{method} {path}` - {summary}")
|
||||
else:
|
||||
lines.append(f"### `{method} {path}`")
|
||||
lines.append("")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def create_llms_txt_route(app: FastAPI) -> None:
|
||||
"""Add a /llms.txt endpoint to the app."""
|
||||
|
||||
@app.get(
|
||||
"/llms.txt",
|
||||
response_class=PlainTextResponse,
|
||||
include_in_schema=False,
|
||||
summary="Get LLM-friendly API documentation",
|
||||
)
|
||||
async def get_llms_txt() -> str:
|
||||
"""Return the API documentation in llms.txt markdown format."""
|
||||
return generate_llms_txt(app)
|
||||
@@ -27,6 +27,8 @@ from lnbits.settings import set_cli_settings, settings
|
||||
@click.option(
|
||||
"--reload", is_flag=True, default=False, help="Enable auto-reload for development"
|
||||
)
|
||||
@click.option("--ws-max-queue", default=128, help="Websocket max queue size")
|
||||
@click.option("--ws-ping-timeout", default=60.0, help="Websocket ping timeout")
|
||||
def main(
|
||||
port: int,
|
||||
host: str,
|
||||
@@ -34,6 +36,8 @@ def main(
|
||||
ssl_keyfile: str,
|
||||
ssl_certfile: str,
|
||||
reload: bool,
|
||||
ws_max_queue: int,
|
||||
ws_ping_timeout: float,
|
||||
):
|
||||
"""Launched with `uv run lnbits` at root level"""
|
||||
|
||||
@@ -58,6 +62,8 @@ def main(
|
||||
ssl_keyfile=ssl_keyfile,
|
||||
ssl_certfile=ssl_certfile,
|
||||
reload=reload or False,
|
||||
ws_ping_timeout=ws_ping_timeout,
|
||||
ws_max_queue=ws_max_queue,
|
||||
)
|
||||
|
||||
server = uvicorn.Server(config=config)
|
||||
|
||||
+101
-6
@@ -60,6 +60,7 @@ 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="""
|
||||
@@ -81,6 +82,33 @@ 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
|
||||
@@ -300,6 +328,7 @@ class ThemesSettings(LNbitsSettings):
|
||||
lnbits_default_card_rounded: bool = Field(default=True)
|
||||
lnbits_default_card_gradient: bool = Field(default=True)
|
||||
lnbits_default_card_shadow: bool = Field(default=False)
|
||||
lnbits_default_burger_menu_background: bool = Field(default=True)
|
||||
|
||||
|
||||
class OpsSettings(LNbitsSettings):
|
||||
@@ -358,9 +387,11 @@ class FeeSettings(LNbitsSettings):
|
||||
|
||||
|
||||
class ExchangeProvidersSettings(LNbitsSettings):
|
||||
lnbits_exchange_rate_cache_seconds: int = Field(default=30, ge=0)
|
||||
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=[
|
||||
@@ -493,6 +524,11 @@ class NotificationsSettings(LNbitsSettings):
|
||||
and self.lnbits_telegram_notifications_access_token is not None
|
||||
)
|
||||
|
||||
def is_email_notifications_configured(self) -> bool:
|
||||
return self.lnbits_email_notifications_enabled and bool(
|
||||
self.lnbits_email_notifications_email
|
||||
)
|
||||
|
||||
|
||||
class FakeWalletFundingSource(LNbitsSettings):
|
||||
fake_wallet_secret: str = Field(default="ToTheMoon1")
|
||||
@@ -586,6 +622,7 @@ 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):
|
||||
@@ -610,6 +647,7 @@ 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)
|
||||
@@ -647,6 +685,7 @@ 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):
|
||||
@@ -698,6 +737,35 @@ class PayPalFiatProvider(LNbitsSettings):
|
||||
paypal_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class SquareFiatProvider(LNbitsSettings):
|
||||
square_enabled: bool = Field(default=False)
|
||||
square_api_endpoint: str = Field(default="https://connect.squareup.com")
|
||||
square_access_token: str | None = Field(default=None)
|
||||
square_location_id: str | None = Field(default=None)
|
||||
square_api_version: str = Field(default="2026-01-22")
|
||||
square_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
square_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/square"
|
||||
)
|
||||
square_webhook_signature_key: str | None = Field(default=None)
|
||||
|
||||
square_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class RevolutFiatProvider(LNbitsSettings):
|
||||
revolut_enabled: bool = Field(default=False)
|
||||
revolut_api_endpoint: str = Field(default="https://merchant.revolut.com")
|
||||
revolut_api_secret_key: str | None = Field(default=None)
|
||||
revolut_api_version: str = Field(default="2026-04-20")
|
||||
revolut_payment_success_url: str = Field(default="https://lnbits.com")
|
||||
revolut_payment_webhook_url: str = Field(
|
||||
default="https://your-lnbits-domain-here.com/api/v1/callback/revolut"
|
||||
)
|
||||
revolut_webhook_signing_secret: str | None = Field(default=None)
|
||||
|
||||
revolut_limits: FiatProviderLimits = Field(default_factory=FiatProviderLimits)
|
||||
|
||||
|
||||
class LightningSettings(LNbitsSettings):
|
||||
lightning_invoice_expiry: int = Field(default=3600, gt=0)
|
||||
|
||||
@@ -735,7 +803,12 @@ class FundingSourcesSettings(
|
||||
funding_source_max_retries: int = Field(default=4, ge=0)
|
||||
|
||||
|
||||
class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
class FiatProvidersSettings(
|
||||
StripeFiatProvider,
|
||||
PayPalFiatProvider,
|
||||
SquareFiatProvider,
|
||||
RevolutFiatProvider,
|
||||
):
|
||||
def is_fiat_provider_enabled(self, provider: str | None) -> bool:
|
||||
"""
|
||||
Checks if a specific fiat provider is enabled.
|
||||
@@ -746,6 +819,10 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
return self.stripe_enabled
|
||||
if provider == "paypal":
|
||||
return self.paypal_enabled
|
||||
if provider == "square":
|
||||
return self.square_enabled
|
||||
if provider == "revolut":
|
||||
return self.revolut_enabled
|
||||
return False
|
||||
|
||||
def get_fiat_providers_for_user(self, user_id: str) -> list[str]:
|
||||
@@ -765,6 +842,18 @@ class FiatProvidersSettings(StripeFiatProvider, PayPalFiatProvider):
|
||||
):
|
||||
allowed_providers.append("paypal")
|
||||
|
||||
if self.square_enabled and (
|
||||
not self.square_limits.allowed_users
|
||||
or user_id in self.square_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("square")
|
||||
|
||||
if self.revolut_enabled and (
|
||||
not self.revolut_limits.allowed_users
|
||||
or user_id in self.revolut_limits.allowed_users
|
||||
):
|
||||
allowed_providers.append("revolut")
|
||||
|
||||
return allowed_providers
|
||||
|
||||
def get_fiat_provider_limits(self, provider_name: str) -> FiatProviderLimits | None:
|
||||
@@ -950,6 +1039,7 @@ class AuditSettings(LNbitsSettings):
|
||||
class EditableSettings(
|
||||
UsersSettings,
|
||||
ExtensionsSettings,
|
||||
WasmRuntimeLimits,
|
||||
ThemesSettings,
|
||||
OpsSettings,
|
||||
AssetSettings,
|
||||
@@ -996,7 +1086,7 @@ class EditableSettings(
|
||||
|
||||
|
||||
class UpdateSettings(EditableSettings):
|
||||
class Config:
|
||||
class Config(EditableSettings.Config):
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
@@ -1021,11 +1111,12 @@ 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:
|
||||
@@ -1147,11 +1238,11 @@ class ReadOnlySettings(
|
||||
|
||||
|
||||
class Settings(EditableSettings, ReadOnlySettings, TransientSettings, BaseSettings):
|
||||
class Config:
|
||||
class Config(EditableSettings.Config, BaseSettings.Config): # type: ignore[misc]
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
case_sensitive = False
|
||||
json_loads = list_parse_fallback
|
||||
json_loads = list_parse_fallback # type: ignore[assignment]
|
||||
|
||||
def is_user_allowed(self, user_id: str) -> bool:
|
||||
return (
|
||||
@@ -1231,12 +1322,14 @@ class PublicSettings(BaseModel):
|
||||
default_card_rounded: bool = Field(alias="defaultCardRounded")
|
||||
default_card_gradient: bool = Field(alias="defaultCardGradient")
|
||||
default_card_shadow: bool = Field(alias="defaultCardShadow")
|
||||
default_burger_menu_background: bool = Field(alias="defaultBurgerMenuBackground")
|
||||
denomination: str | None = Field()
|
||||
extensions: list[str] = Field()
|
||||
allowed_currencies: list[str] = Field(alias="allowedCurrencies")
|
||||
extensions_reviews_url: str = Field(alias="extensionsReviewsUrl")
|
||||
ext_builder: bool = Field(alias="extBuilder")
|
||||
nostr_configured: bool = Field(alias="nostrConfigured")
|
||||
email_configured: bool = Field(alias="emailConfigured")
|
||||
telegram_configured: bool = Field(alias="telegramConfigured")
|
||||
wallet_featured_button_label: str | None = Field(alias="walletFeaturedButtonLabel")
|
||||
wallet_featured_button_url: str | None = Field(alias="walletFeaturedButtonUrl")
|
||||
@@ -1294,12 +1387,14 @@ class PublicSettings(BaseModel):
|
||||
defaultCardRounded=settings.lnbits_default_card_rounded,
|
||||
defaultCardGradient=settings.lnbits_default_card_gradient,
|
||||
defaultCardShadow=settings.lnbits_default_card_shadow,
|
||||
defaultBurgerMenuBackground=settings.lnbits_default_burger_menu_background,
|
||||
denomination=settings.lnbits_denomination,
|
||||
extensions=list(settings.lnbits_installed_extensions_ids),
|
||||
allowedCurrencies=settings.lnbits_allowed_currencies,
|
||||
extensionsReviewsUrl=settings.lnbits_extensions_reviews_url,
|
||||
extBuilder=settings.lnbits_extensions_builder_activate_non_admins,
|
||||
nostrConfigured=settings.is_nostr_notifications_configured(),
|
||||
emailConfigured=settings.is_email_notifications_configured(),
|
||||
telegramConfigured=settings.is_telegram_notifications_configured(),
|
||||
walletFeaturedButtonLabel=settings.lnbits_wallet_featured_button_label,
|
||||
walletFeaturedButtonUrl=settings.lnbits_wallet_featured_button_url,
|
||||
|
||||
+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
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -212,12 +212,15 @@ body.bg-image .q-page-container {
|
||||
backdrop-filter: none; /* Ensure the page content is not affected */
|
||||
}
|
||||
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
body.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
body.body--dark .q-header,
|
||||
body.body--dark .q-drawer {
|
||||
--q-dark: rgba(29, 29, 29, 0.3);
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
backdrop-filter: brightness(0.8);
|
||||
}
|
||||
|
||||
body.rounded-ui .q-card,
|
||||
@@ -388,11 +391,18 @@ body[data-theme=salvador].card-gradient.body--dark .q-drawer {
|
||||
}
|
||||
|
||||
body.card-shadow .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
body.card-shadow.body--dark .q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
body.no-burger-background .q-drawer {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -116,6 +116,7 @@ window.localisation.en = {
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
pay: 'Pay',
|
||||
sending: 'Sending',
|
||||
memo: 'Memo',
|
||||
date: 'Date',
|
||||
path: 'Path',
|
||||
@@ -184,6 +185,7 @@ window.localisation.en = {
|
||||
release_notes: 'Release Notes',
|
||||
activate_extension_details: 'Make extension available/unavailable for users',
|
||||
featured: 'Featured',
|
||||
categories: 'Categories',
|
||||
all: 'All',
|
||||
only_admins_can_install: '(Only admin accounts can install extensions)',
|
||||
only_admins_can_create_extensions:
|
||||
@@ -267,6 +269,15 @@ window.localisation.en = {
|
||||
webhook_events_list: 'The following events must be supported by the webhook:',
|
||||
webhook_stripe_description:
|
||||
'One the stripe side you must configure a webhook with a URL that points to your LNbits server.',
|
||||
webhook_square_description:
|
||||
'On the Square side configure a webhook pointing to this exact LNbits URL.',
|
||||
square_webhook_url_hint:
|
||||
'Must exactly match the Square notification URL. LNbits requires the /api/v1/callback/square path.',
|
||||
access_token: 'Access Token',
|
||||
location_id: 'Location ID',
|
||||
square_location_id_hint:
|
||||
'Square location ID to create payment links for. Use the endpoint to select sandbox or production.',
|
||||
api_version: 'API Version',
|
||||
payment_proof: 'Payment Proof',
|
||||
update: 'Update',
|
||||
update_available: 'Update {version} available!',
|
||||
@@ -481,6 +492,8 @@ window.localisation.en = {
|
||||
toggle_card_gradient: 'Toggle gradient on cards',
|
||||
card_shadow: 'Card Shadow',
|
||||
toggle_card_shadow: 'Toggle shadow on cards',
|
||||
burger_menu_background: 'Burger Menu Background',
|
||||
toggle_burger_menu_background: 'Toggle burger menu background',
|
||||
language: 'Language',
|
||||
assets: 'Assets',
|
||||
max_asset_size_mb: 'Max Asset Size (MB)',
|
||||
@@ -507,6 +520,50 @@ window.localisation.en = {
|
||||
admin_settings: 'Admin Settings',
|
||||
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_tab: 'Extension Permissions',
|
||||
user_permissions_tab: 'My Grants',
|
||||
extension_permissions_none: 'This extension has no install-time permissions.',
|
||||
user_permissions_none:
|
||||
'You have not granted any permissions for this extension.',
|
||||
user_permissions_max_amount: 'Max payment amount',
|
||||
user_permissions_destination_policy: 'Allowed destinations',
|
||||
user_permissions_no_editable_settings: 'This grant has no editable settings.',
|
||||
extension_permissions_grant_install: 'Grant and install',
|
||||
extension_permissions_high_risk_warning:
|
||||
'This extension requests permissions that can move funds.',
|
||||
extension_permission_risk_low: 'Low risk',
|
||||
extension_permission_risk_medium: 'Medium risk',
|
||||
extension_permission_risk_high: 'High risk',
|
||||
extension_permission_warning_wallet_pay_invoice:
|
||||
'Can spend funds from wallets available to your account.',
|
||||
extension_permission_warning_wallet_pay_invoice_background:
|
||||
'Can spend funds later from approved wallets without an active click.',
|
||||
extension_permission_warning_wallet_payments_watch:
|
||||
'Can read payment metadata for approved wallets.',
|
||||
extension_permission_warning_extension_api_request_write:
|
||||
'Can write data or trigger actions in approved extensions.',
|
||||
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_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_hosts: 'Allowed hosts',
|
||||
extension_permission_utils_basic: 'Use basic LNbits utilities',
|
||||
extension_permission_ui_camera_scan_qr: 'Scan QR codes',
|
||||
extension_permission_wallet_payments_watch: 'Watch wallet payments',
|
||||
extension_permission_wallet_create_invoice: 'Create invoices',
|
||||
extension_permission_wallet_create_invoice_public:
|
||||
'Create Lightning invoices from public pages',
|
||||
extension_permission_wallet_balance_read: 'View wallet balances',
|
||||
extension_permission_wallet_list: 'List wallets',
|
||||
extension_permission_wallet_pay_invoice: 'Pay invoices',
|
||||
extension_permission_wallet_pay_invoice_background:
|
||||
'Make background payments',
|
||||
create_extension: 'Create Extension',
|
||||
release_details_error: 'Cannot get the release details.',
|
||||
pay_from_wallet: 'Pay from Wallet',
|
||||
@@ -805,6 +862,8 @@ window.localisation.en = {
|
||||
webhook_id_hint: 'PayPal webhook ID used to verify incoming events.',
|
||||
webhook_paypal_description:
|
||||
'On the PayPal side configure a webhook pointing to your LNbits server.',
|
||||
square_webhook_signature_key_hint:
|
||||
'Square webhook signature key used to verify incoming events.',
|
||||
callback_success_url: 'Callback Success URL',
|
||||
callback_success_url_hint:
|
||||
'The user will be redirected to this URL after the payment is successful',
|
||||
|
||||
@@ -452,7 +452,9 @@ window.app.component('username-password', {
|
||||
confirmationMethod: 'code',
|
||||
confirmationEmail: '',
|
||||
confirmationCode: this.invitationCode || '',
|
||||
showConfirmationCode: false
|
||||
showConfirmationCode: false,
|
||||
showPwd: false,
|
||||
showPwdRepeat: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -62,12 +62,6 @@ 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 => {
|
||||
@@ -127,18 +121,21 @@ 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_exchange_rate_providers,
|
||||
{name: 'LNbits'}
|
||||
]
|
||||
const exchanges = this.formData.lnbits_price_aggregator_enabled
|
||||
? [{name: 'Aggregator'}]
|
||||
: [...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 : 1,
|
||||
borderWidth: exchange.name === 'LNbits' ? 4 : 2,
|
||||
tension: 0.4
|
||||
}))
|
||||
this.exchangeRatesChart = new Chart(
|
||||
@@ -148,7 +145,11 @@ window.app.component('lnbits-admin-exchange-providers', {
|
||||
options: {
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: true
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Bitcoin Price History'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
return {
|
||||
formAddStripeUser: '',
|
||||
formAddPaypalUser: '',
|
||||
formAddSquareUser: '',
|
||||
formAddRevolutUser: '',
|
||||
creatingRevolutWebhook: false,
|
||||
hideInputToggle: true
|
||||
}
|
||||
},
|
||||
@@ -20,6 +23,12 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
this.formData?.paypal_payment_webhook_url ||
|
||||
this.calculateWebhookUrl('paypal')
|
||||
)
|
||||
},
|
||||
revolutWebhookUrl() {
|
||||
return (
|
||||
this.formData?.revolut_payment_webhook_url ||
|
||||
this.calculateWebhookUrl('revolut')
|
||||
)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -58,6 +67,8 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
syncWebhookUrls() {
|
||||
this.maybeSetWebhookUrl('stripe_payment_webhook_url', 'stripe')
|
||||
this.maybeSetWebhookUrl('paypal_payment_webhook_url', 'paypal')
|
||||
this.maybeSetWebhookUrl('square_payment_webhook_url', 'square')
|
||||
this.maybeSetWebhookUrl('revolut_payment_webhook_url', 'revolut')
|
||||
},
|
||||
maybeSetWebhookUrl(fieldName, provider) {
|
||||
if (!this.formData) {
|
||||
@@ -77,6 +88,47 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
}
|
||||
this.copyText(url)
|
||||
},
|
||||
isClearnetWebhookUrl(url) {
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = parsedUrl.hostname.toLowerCase()
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
host === 'localhost' ||
|
||||
host.endsWith('.localhost') ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.onion')
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host) ||
|
||||
host === '0.0.0.0' ||
|
||||
host === '::1'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
notifyRevolutWebhookWarning(message) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message,
|
||||
icon: null,
|
||||
closeBtn: true
|
||||
})
|
||||
},
|
||||
addStripeAllowedUser() {
|
||||
const addUser = this.formAddStripeUser || ''
|
||||
if (
|
||||
@@ -111,6 +163,40 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
this.formData.paypal_limits.allowed_users =
|
||||
this.formData.paypal_limits.allowed_users.filter(u => u !== user)
|
||||
},
|
||||
addSquareAllowedUser() {
|
||||
const addUser = this.formAddSquareUser || ''
|
||||
if (
|
||||
addUser.length &&
|
||||
!this.formData.square_limits.allowed_users.includes(addUser)
|
||||
) {
|
||||
this.formData.square_limits.allowed_users = [
|
||||
...this.formData.square_limits.allowed_users,
|
||||
addUser
|
||||
]
|
||||
this.formAddSquareUser = ''
|
||||
}
|
||||
},
|
||||
removeSquareAllowedUser(user) {
|
||||
this.formData.square_limits.allowed_users =
|
||||
this.formData.square_limits.allowed_users.filter(u => u !== user)
|
||||
},
|
||||
addRevolutAllowedUser() {
|
||||
const addUser = this.formAddRevolutUser || ''
|
||||
if (
|
||||
addUser.length &&
|
||||
!this.formData.revolut_limits.allowed_users.includes(addUser)
|
||||
) {
|
||||
this.formData.revolut_limits.allowed_users = [
|
||||
...this.formData.revolut_limits.allowed_users,
|
||||
addUser
|
||||
]
|
||||
this.formAddRevolutUser = ''
|
||||
}
|
||||
},
|
||||
removeRevolutAllowedUser(user) {
|
||||
this.formData.revolut_limits.allowed_users =
|
||||
this.formData.revolut_limits.allowed_users.filter(u => u !== user)
|
||||
},
|
||||
checkFiatProvider(providerName) {
|
||||
LNbits.api
|
||||
.request('PUT', `/api/v1/fiat/check/${providerName}`)
|
||||
@@ -124,6 +210,48 @@ window.app.component('lnbits-admin-fiat-providers', {
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
},
|
||||
createRevolutWebhook() {
|
||||
const webhookUrl = this.calculateWebhookUrl('revolut')
|
||||
this.formData.revolut_payment_webhook_url = webhookUrl
|
||||
|
||||
if (!this.formData.revolut_api_secret_key) {
|
||||
this.notifyRevolutWebhookWarning(
|
||||
'Add your Revolut API secret key before creating a webhook.'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isClearnetWebhookUrl(webhookUrl)) {
|
||||
this.notifyRevolutWebhookWarning(
|
||||
'Revolut webhook URL must be a clearnet URL.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.creatingRevolutWebhook = true
|
||||
LNbits.api
|
||||
.request('POST', '/api/v1/fiat/revolut/webhook', null, {
|
||||
url: webhookUrl,
|
||||
endpoint: this.formData.revolut_api_endpoint,
|
||||
api_secret_key: this.formData.revolut_api_secret_key,
|
||||
api_version: this.formData.revolut_api_version
|
||||
})
|
||||
.then(response => {
|
||||
const data = response.data
|
||||
this.formData.revolut_payment_webhook_url = data.url
|
||||
this.formData.revolut_webhook_signing_secret = data.signing_secret
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: `Revolut webhook ${
|
||||
data.already_exists ? 'already exists' : 'created'
|
||||
}${data.id ? `: ${data.id}` : ''}.`,
|
||||
icon: null
|
||||
})
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
.finally(() => {
|
||||
this.creatingRevolutWebhook = false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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: ['is-super-user', 'form-data', 'settings'],
|
||||
props: ['active', 'is-super-user', 'form-data', 'settings'],
|
||||
template: '#lnbits-admin-funding',
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,380 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,337 @@
|
||||
;(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, warningKey) {
|
||||
return {
|
||||
level: 'medium',
|
||||
color: 'warning',
|
||||
label: translate(translateFn, 'extension_permission_risk_medium'),
|
||||
warning: warningKey ? translate(translateFn, warningKey) : ''
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
['wallet.pay_invoice', 'wallet.pay_invoice_background'].includes(
|
||||
permission.id
|
||||
)
|
||||
) {
|
||||
return highRisk(
|
||||
translateFn,
|
||||
permission.id === 'wallet.pay_invoice_background'
|
||||
? 'extension_permission_warning_wallet_pay_invoice_background'
|
||||
: '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 (permission.id === 'wallet.payments.watch') {
|
||||
return mediumRisk(
|
||||
translateFn,
|
||||
'extension_permission_warning_wallet_payments_watch'
|
||||
)
|
||||
}
|
||||
if (
|
||||
[
|
||||
'wallet.list',
|
||||
'wallet.balance.read',
|
||||
'wallet.create_invoice_public',
|
||||
'ext.storage.read_public'
|
||||
].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.pay_invoice_background',
|
||||
'wallet.payments.watch',
|
||||
'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
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
@@ -35,6 +35,18 @@ window.app.component('lnbits-manage-extension-list', {
|
||||
.toLocaleLowerCase()
|
||||
.includes(this.searchTerm.toLocaleLowerCase())
|
||||
})
|
||||
},
|
||||
extensionUrl(extension) {
|
||||
if (extension.is_wasm) {
|
||||
return `/ext/${extension.code}`
|
||||
}
|
||||
return `/${extension.code}/`
|
||||
},
|
||||
extensionActive(extension) {
|
||||
const extensionPath = extension.is_wasm
|
||||
? `/ext/${extension.code}`
|
||||
: `/${extension.code}`
|
||||
return this.$route.path.startsWith(extensionPath)
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
|
||||
@@ -360,18 +360,37 @@ window.app.component('lnbits-payment-list', {
|
||||
paymentTableRowKey(row) {
|
||||
return row.payment_hash + row.amount
|
||||
},
|
||||
exportCSV(detailed = false) {
|
||||
async exportCSV(detailed = false) {
|
||||
// status is important for export but it is not in paymentsTable
|
||||
// because it is manually added with payment detail link and icons
|
||||
// and would cause duplication in the list
|
||||
const pagination = this.paymentsTable.pagination
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc'
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
LNbits.api.getPayments(this.wallet, params).then(response => {
|
||||
let payments = response.data.data.map(this.mapPayment)
|
||||
const maxPages = 100
|
||||
const limit = 1000
|
||||
let payments = []
|
||||
|
||||
this.paymentsCSV.loading = true
|
||||
try {
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const query = {
|
||||
sortby: pagination.sortBy ?? 'time',
|
||||
direction: pagination.descending ? 'desc' : 'asc',
|
||||
limit,
|
||||
offset: page * limit
|
||||
}
|
||||
const params = new URLSearchParams(query)
|
||||
const response = await LNbits.api.getPayments(this.wallet, params)
|
||||
const pagePayments = response.data.data || []
|
||||
payments = payments.concat(pagePayments.map(this.mapPayment))
|
||||
|
||||
if (
|
||||
pagePayments.length < limit ||
|
||||
payments.length >= response.data.total
|
||||
) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let columns = this.paymentsCSV.columns
|
||||
|
||||
if (detailed) {
|
||||
@@ -400,7 +419,11 @@ window.app.component('lnbits-payment-list', {
|
||||
payments,
|
||||
this.wallet.name + '-payments'
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
} finally {
|
||||
this.paymentsCSV.loading = false
|
||||
}
|
||||
},
|
||||
addFilterTag() {
|
||||
if (!this.exportTagName) return
|
||||
|
||||
@@ -8,6 +8,10 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
prefix: {
|
||||
type: String,
|
||||
default: 'lnurlp'
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -21,7 +25,10 @@ window.app.component('lnbits-qrcode-lnurl', {
|
||||
if (this.tab == 'bech32') {
|
||||
const bytes = new TextEncoder().encode(this.url)
|
||||
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
|
||||
this.lnurl = `lightning:${bech32.toUpperCase()}`
|
||||
this.lnurl =
|
||||
this.href && this.href.trim() !== ''
|
||||
? `${this.href}?lightning=${bech32.toUpperCase()}`
|
||||
: `lightning:${bech32.toUpperCase()}`
|
||||
} else if (this.tab == 'lud17') {
|
||||
if (this.url.startsWith('http://')) {
|
||||
this.lnurl = this.url.replace('http://', this.prefix + '://')
|
||||
|
||||
@@ -85,6 +85,9 @@ window.app.component('lnbits-qrcode', {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return false
|
||||
} else if (this.href && this.href.startsWith('http')) {
|
||||
window.open(this.href, '_blank')
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
async writeNfcTag() {
|
||||
|
||||
@@ -69,6 +69,14 @@ window.app.component('lnbits-theme', {
|
||||
document.body.classList.remove('card-shadow')
|
||||
}
|
||||
},
|
||||
'g.burgerMenuChoice'(val) {
|
||||
this.$q.localStorage.set('lnbits.burgerMenu', val)
|
||||
if (val === true) {
|
||||
document.body.classList.remove('no-burger-background')
|
||||
} else {
|
||||
document.body.classList.add('no-burger-background')
|
||||
}
|
||||
},
|
||||
'g.mobileSimple'(val) {
|
||||
this.$q.localStorage.set('lnbits.mobileSimple', val)
|
||||
if (val === true) {
|
||||
@@ -150,6 +158,9 @@ window.app.component('lnbits-theme', {
|
||||
if (this.g.cardShadowChoice === true) {
|
||||
document.body.classList.add('card-shadow')
|
||||
}
|
||||
if (this.g.burgerMenuChoice !== true) {
|
||||
document.body.classList.add('no-burger-background')
|
||||
}
|
||||
if (this.g.bgimageChoice !== '') {
|
||||
document.body.classList.add('bg-image')
|
||||
document.body.style.setProperty(
|
||||
|
||||
@@ -29,6 +29,10 @@ window.g = Vue.reactive({
|
||||
SETTINGS.defaultCardGradient
|
||||
),
|
||||
cardShadowChoice: localStore('lnbits.cardShadow', SETTINGS.defaultCardShadow),
|
||||
burgerMenuChoice: localStore(
|
||||
'lnbits.burgerMenu',
|
||||
SETTINGS.defaultBurgerMenuBackground
|
||||
),
|
||||
reactionChoice: localStore('lnbits.reactions', SETTINGS.defaultReaction),
|
||||
bgimageChoice: localStore(
|
||||
'lnbits.backgroundImage',
|
||||
|
||||
@@ -27,6 +27,14 @@ const DynamicComponent = {
|
||||
name: r.name,
|
||||
component: async () => {
|
||||
await LNbits.utils.loadTemplate(r.template)
|
||||
if (r.i18n) {
|
||||
const locale =
|
||||
window.i18n?.global?.locale?.value ??
|
||||
window.i18n?.global?.locale ??
|
||||
window.g.locale ??
|
||||
'en'
|
||||
await LNbits.utils.loadExtI18n(r.i18n, locale)
|
||||
}
|
||||
await LNbits.utils.loadScript(r.component)
|
||||
return window[r.name]
|
||||
}
|
||||
@@ -91,6 +99,26 @@ 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',
|
||||
@@ -131,6 +159,16 @@ const routes = [
|
||||
name: 'PageError',
|
||||
component: PageError
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId',
|
||||
name: 'WasmExtensionRoot',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/ext/:extId/:pathMatch(.*)*',
|
||||
name: 'WasmExtension',
|
||||
component: window.WasmExtensionComponent
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'DynamicComponent',
|
||||
@@ -151,6 +189,30 @@ window.i18n = new VueI18n.createI18n({
|
||||
fallbackLocale: 'en',
|
||||
messages: window.localisation
|
||||
})
|
||||
;(function () {
|
||||
let _applying = false
|
||||
let _target = null
|
||||
Vue.watch(
|
||||
() => window.i18n.global.locale,
|
||||
async (locale, prevLocale) => {
|
||||
if (_applying || !LNbits.utils._extI18nDirs.size) return
|
||||
_target = locale
|
||||
_applying = true
|
||||
window.i18n.global.locale = prevLocale
|
||||
_applying = false
|
||||
await Promise.all(
|
||||
[...LNbits.utils._extI18nDirs].map(dir =>
|
||||
LNbits.utils.loadExtI18n(dir, locale)
|
||||
)
|
||||
)
|
||||
if (_target !== locale) return
|
||||
_applying = true
|
||||
window.i18n.global.locale = locale
|
||||
_applying = false
|
||||
},
|
||||
{flush: 'sync'}
|
||||
)
|
||||
})()
|
||||
|
||||
window.app.mixin({
|
||||
data() {
|
||||
|
||||
@@ -732,7 +732,8 @@ window.PageAccount = {
|
||||
darkChoice: this.g.settings.defaultDark,
|
||||
cardRoundedChoice: this.g.settings.defaultCardRounded,
|
||||
cardGradientChoice: this.g.settings.defaultCardGradient,
|
||||
cardShadowChoice: this.g.settings.defaultCardShadow
|
||||
cardShadowChoice: this.g.settings.defaultCardShadow,
|
||||
burgerMenuChoice: this.g.settings.defaultBurgerMenuBackground
|
||||
}
|
||||
this.siteCustomisationChanged(defaults)
|
||||
}
|
||||
|
||||
@@ -16,18 +16,26 @@ window.PageAdmin = {
|
||||
},
|
||||
watch: {
|
||||
tab(tab) {
|
||||
this.$router.push(`/admin#${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)
|
||||
}
|
||||
},
|
||||
$route(to) {
|
||||
if (to.hash.length > 1) {
|
||||
this.tab = to.hash.replace('#', '')
|
||||
const tab = this.adminTabFromRoute(to)
|
||||
if (this.tab !== tab) {
|
||||
this.tab = tab
|
||||
}
|
||||
}
|
||||
},
|
||||
async created() {
|
||||
if (this.$route.hash.length > 1) {
|
||||
this.tab = this.$route.hash.replace('#', '')
|
||||
}
|
||||
this.tab = this.adminTabFromRoute(this.$route)
|
||||
await this.getSettings()
|
||||
},
|
||||
computed: {
|
||||
@@ -36,6 +44,27 @@ 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
|
||||
|
||||
@@ -10,6 +10,7 @@ window.PageExtensions = {
|
||||
tab: 'installed',
|
||||
manageExtensionTab: 'releases',
|
||||
filteredExtensions: [],
|
||||
categories: new Set(),
|
||||
updatableExtensions: [],
|
||||
showUninstallDialog: false,
|
||||
showManageExtensionDialog: false,
|
||||
@@ -23,6 +24,28 @@ window.PageExtensions = {
|
||||
selectedExtensionDetails: null,
|
||||
selectedExtensionRepos: null,
|
||||
selectedRelease: null,
|
||||
permissionGrant: {
|
||||
show: false,
|
||||
permissions: [],
|
||||
resolve: null
|
||||
},
|
||||
managedExtensionPermissions: {
|
||||
loading: false,
|
||||
extensionPermissions: [],
|
||||
userPermissions: {},
|
||||
savingKey: '',
|
||||
deletingKey: ''
|
||||
},
|
||||
backgroundPaymentDestinationOptions: [
|
||||
{
|
||||
label: 'Only transfers to my wallets',
|
||||
value: 'own_wallets_only'
|
||||
},
|
||||
{
|
||||
label: 'Allow external payments',
|
||||
value: 'external_allowed'
|
||||
}
|
||||
],
|
||||
uninstallAndDropDb: false,
|
||||
maxStars: 5,
|
||||
paylinkWebsocket: null,
|
||||
@@ -92,6 +115,32 @@ window.PageExtensions = {
|
||||
this.filterExtensions(this.searchTerm, val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
managedUserPermissionRows() {
|
||||
const rows = []
|
||||
const userPermissions =
|
||||
this.managedExtensionPermissions.userPermissions || {}
|
||||
Object.entries(userPermissions).forEach(([permissionId, grants]) => {
|
||||
if (!Array.isArray(grants)) return
|
||||
grants.forEach(grant => {
|
||||
if (!grant || typeof grant !== 'object') return
|
||||
const grantId = String(grant.id || '')
|
||||
const walletId = String(grant.wallet_id || '')
|
||||
if (!grantId || !walletId) return
|
||||
rows.push({
|
||||
key: grantId,
|
||||
permissionId,
|
||||
label: this.permissionLabelById(permissionId),
|
||||
grantId,
|
||||
walletId,
|
||||
walletName: this.walletName(walletId),
|
||||
grant
|
||||
})
|
||||
})
|
||||
})
|
||||
return rows
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterExtensions(term, tab) {
|
||||
// Filter the extensions list
|
||||
@@ -106,6 +155,10 @@ window.PageExtensions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isCategoryTab = !['installed', 'all', 'featured'].includes(tab)
|
||||
const isInSelectedCategory = extension =>
|
||||
extension.categories?.includes(tab) ?? false
|
||||
|
||||
this.filteredExtensions = this.extensions
|
||||
.filter(e => (tab === 'all' ? !e.isInstalled : true))
|
||||
.filter(e => (tab === 'installed' ? e.isInstalled : true))
|
||||
@@ -113,6 +166,7 @@ window.PageExtensions = {
|
||||
tab === 'installed' ? (e.isActive ? true : !!this.g.user.admin) : true
|
||||
)
|
||||
.filter(e => (tab === 'featured' ? e.isFeatured : true))
|
||||
.filter(e => (isCategoryTab ? isInSelectedCategory(e) : true))
|
||||
.filter(extensionNameContains(term))
|
||||
.map(e => ({
|
||||
...e,
|
||||
@@ -126,6 +180,12 @@ window.PageExtensions = {
|
||||
// the install logic has been triggered one way or another
|
||||
this.unsubscribeFromPaylinkWs()
|
||||
|
||||
const grantedPermissions =
|
||||
await this.resolveExtensionPermissionGrant(release)
|
||||
if (grantedPermissions === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
release.payment_hash =
|
||||
@@ -137,7 +197,8 @@ window.PageExtensions = {
|
||||
archive: release.archive,
|
||||
source_repo: release.source_repo,
|
||||
payment_hash: release.payment_hash,
|
||||
version: release.version
|
||||
version: release.version,
|
||||
permissions: grantedPermissions
|
||||
})
|
||||
.then(response => {
|
||||
this.selectedExtension.inProgress = false
|
||||
@@ -146,6 +207,12 @@ 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)
|
||||
extension.inProgress = false
|
||||
@@ -333,8 +400,18 @@ window.PageExtensions = {
|
||||
this.selectedExtension = extension
|
||||
this.selectedRelease = null
|
||||
this.selectedExtensionRepos = null
|
||||
this.manageExtensionTab = 'releases'
|
||||
this.resetManagedExtensionPermissions()
|
||||
this.manageExtensionTab = this.g.user.admin
|
||||
? 'releases'
|
||||
: 'extension-permissions'
|
||||
this.showManageExtensionDialog = true
|
||||
if (this.canManageExtensionPermissions(extension)) {
|
||||
this.loadManagedExtensionPermissions(extension)
|
||||
}
|
||||
|
||||
if (!this.g.user.admin) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
@@ -372,6 +449,61 @@ window.PageExtensions = {
|
||||
extension.inProgress = false
|
||||
}
|
||||
},
|
||||
canShowManageExtensionButton(extension) {
|
||||
return (
|
||||
this.g.user.admin ||
|
||||
(extension?.isWasm === true && extension?.isInstalled === true)
|
||||
)
|
||||
},
|
||||
canManageExtensionPermissions(extension = this.selectedExtension) {
|
||||
return extension?.isWasm === true && extension?.isInstalled === true
|
||||
},
|
||||
canShowAdminManageTabs() {
|
||||
return this.g.user.admin === true
|
||||
},
|
||||
resetManagedExtensionPermissions() {
|
||||
this.managedExtensionPermissions = {
|
||||
loading: false,
|
||||
extensionPermissions: [],
|
||||
userPermissions: {},
|
||||
savingKey: '',
|
||||
deletingKey: ''
|
||||
}
|
||||
},
|
||||
async loadManagedExtensionPermissions(extension = this.selectedExtension) {
|
||||
if (!this.canManageExtensionPermissions(extension)) return
|
||||
this.managedExtensionPermissions.loading = true
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'GET',
|
||||
`/api/v1/extension/${extension.id}/permissions`
|
||||
)
|
||||
this.managedExtensionPermissions.extensionPermissions =
|
||||
data.extension_permissions || []
|
||||
this.managedExtensionPermissions.userPermissions =
|
||||
this.cloneUserPermissions(data.user_permissions || {})
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.loading = false
|
||||
}
|
||||
},
|
||||
cloneUserPermissions(userPermissions) {
|
||||
const permissions = {}
|
||||
Object.entries(userPermissions || {}).forEach(
|
||||
([permissionId, grants]) => {
|
||||
if (!Array.isArray(grants)) return
|
||||
permissions[permissionId] = grants
|
||||
.filter(grant => grant && typeof grant === 'object')
|
||||
.map(grant => ({
|
||||
...grant,
|
||||
_original: {...grant}
|
||||
}))
|
||||
}
|
||||
)
|
||||
return permissions
|
||||
},
|
||||
|
||||
async showExtensionDetails(extId, detailsLink) {
|
||||
if (!detailsLink) {
|
||||
@@ -400,6 +532,10 @@ window.PageExtensions = {
|
||||
},
|
||||
async payAndInstall(release) {
|
||||
try {
|
||||
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedExtension.inProgress = true
|
||||
this.showManageExtensionDialog = false
|
||||
const paymentInfo = await this.requestPaymentForInstall(
|
||||
@@ -445,6 +581,10 @@ window.PageExtensions = {
|
||||
}
|
||||
},
|
||||
async showInstallQRCode(release) {
|
||||
if ((await this.resolveExtensionPermissionGrant(release)) === null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectedRelease = release
|
||||
|
||||
try {
|
||||
@@ -607,6 +747,138 @@ window.PageExtensions = {
|
||||
|
||||
return ''
|
||||
},
|
||||
extensionOpenUrl(extension) {
|
||||
return extension.isWasm ? `/ext/${extension.id}` : `/${extension.id}`
|
||||
},
|
||||
permissionLabelById(permissionId) {
|
||||
const key = `extension_permission_${String(permissionId).replace(
|
||||
/[^A-Za-z0-9]/g,
|
||||
'_'
|
||||
)}`
|
||||
const label = this.$t(key)
|
||||
return label === key ? permissionId : label
|
||||
},
|
||||
walletName(walletId) {
|
||||
const wallet = (this.g.user.wallets || []).find(
|
||||
wallet => wallet.id === walletId
|
||||
)
|
||||
return wallet ? wallet.name || wallet.id : walletId
|
||||
},
|
||||
userPermissionRowCaption(row) {
|
||||
return `${row.walletName} (${row.walletId.slice(0, 8)}...)`
|
||||
},
|
||||
isBackgroundPaymentPermission(row) {
|
||||
return row.permissionId === 'wallet.pay_invoice_background'
|
||||
},
|
||||
userPermissionGrantPayload(row) {
|
||||
return {
|
||||
wallet_id: row.walletId,
|
||||
max_amount: this.positiveInteger(row.grant.max_amount, 0),
|
||||
destination_policy: this.backgroundPaymentDestinationPolicy(
|
||||
row.grant.destination_policy
|
||||
)
|
||||
}
|
||||
},
|
||||
positiveInteger(value, fallback) {
|
||||
const number = Number(value)
|
||||
if (!Number.isFinite(number) || number <= 0) return fallback
|
||||
return Math.floor(number)
|
||||
},
|
||||
backgroundPaymentDestinationPolicy(value) {
|
||||
return value === 'external_allowed'
|
||||
? 'external_allowed'
|
||||
: 'own_wallets_only'
|
||||
},
|
||||
backgroundPaymentGrantIncreased(row, payload) {
|
||||
const original = row.grant._original || {}
|
||||
const originalAmount = this.positiveInteger(original.max_amount, 0)
|
||||
const originalPolicy = this.backgroundPaymentDestinationPolicy(
|
||||
original.destination_policy
|
||||
)
|
||||
return (
|
||||
payload.max_amount > originalAmount ||
|
||||
(originalPolicy === 'own_wallets_only' &&
|
||||
payload.destination_policy === 'external_allowed')
|
||||
)
|
||||
},
|
||||
confirmUserPermissionIncrease() {
|
||||
return new Promise(resolve => {
|
||||
let resolved = false
|
||||
const finish = value => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
resolve(value)
|
||||
}
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
'This increases what the extension can do with this wallet. Continue?'
|
||||
)
|
||||
.onOk(() => finish(true))
|
||||
.onCancel(() => finish(false))
|
||||
.onDismiss(() => finish(false))
|
||||
})
|
||||
},
|
||||
async saveUserPermissionGrant(row) {
|
||||
if (!this.isBackgroundPaymentPermission(row)) return
|
||||
const payload = this.userPermissionGrantPayload(row)
|
||||
if (!payload.max_amount) {
|
||||
Quasar.Notify.create({
|
||||
type: 'negative',
|
||||
message: 'Max payment amount must be greater than zero.'
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
this.backgroundPaymentGrantIncreased(row, payload) &&
|
||||
!(await this.confirmUserPermissionIncrease())
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.managedExtensionPermissions.savingKey = row.key
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'POST',
|
||||
`/api/v1/extension/${this.selectedExtension.id}/permissions/background-payment`,
|
||||
null,
|
||||
payload
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Permission updated.'
|
||||
})
|
||||
await this.loadManagedExtensionPermissions()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.savingKey = ''
|
||||
}
|
||||
},
|
||||
deleteUserPermissionGrant(row) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Remove this permission grant?')
|
||||
.onOk(async () => {
|
||||
this.managedExtensionPermissions.deletingKey = row.key
|
||||
try {
|
||||
const grantId = encodeURIComponent(row.grantId)
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/api/v1/extension/${this.selectedExtension.id}/permissions/user/${grantId}`
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
type: 'positive',
|
||||
message: 'Permission removed.'
|
||||
})
|
||||
await this.loadManagedExtensionPermissions()
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
LNbits.utils.notifyApiError(error)
|
||||
} finally {
|
||||
this.managedExtensionPermissions.deletingKey = ''
|
||||
}
|
||||
})
|
||||
},
|
||||
async getGitHubReleaseDetails(release) {
|
||||
if (!release.is_github_release || release.loaded) {
|
||||
return
|
||||
@@ -622,6 +894,8 @@ window.PageExtensions = {
|
||||
release.is_version_compatible = data.is_version_compatible
|
||||
release.min_lnbits_version = data.min_lnbits_version
|
||||
release.warning = data.warning
|
||||
release.extension_type = data.extension_type
|
||||
release.permissions = data.permissions || []
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
release.error = error
|
||||
@@ -630,6 +904,75 @@ window.PageExtensions = {
|
||||
release.inProgress = false
|
||||
}
|
||||
},
|
||||
async resolveExtensionPermissionGrant(release) {
|
||||
const permissions = this.extensionPermissionsForRelease(release)
|
||||
if (
|
||||
!this.releaseRequiresPermissionGrant(release) ||
|
||||
!permissions.length
|
||||
) {
|
||||
return []
|
||||
}
|
||||
if (release.grantedPermissions) {
|
||||
return release.grantedPermissions
|
||||
}
|
||||
const grantedPermissions =
|
||||
await this.confirmExtensionPermissions(permissions)
|
||||
if (!grantedPermissions) {
|
||||
return null
|
||||
}
|
||||
release.grantedPermissions = grantedPermissions
|
||||
return grantedPermissions
|
||||
},
|
||||
extensionPermissionsForRelease(release) {
|
||||
return release.permissions || this.selectedExtension?.permissions || []
|
||||
},
|
||||
releaseRequiresPermissionGrant(release) {
|
||||
return (
|
||||
release.extension_type === 'wasm' ||
|
||||
this.selectedExtension?.isWasm === true
|
||||
)
|
||||
},
|
||||
confirmExtensionPermissions(permissions) {
|
||||
return new Promise(resolve => {
|
||||
this.selectedRelease = null
|
||||
this.permissionGrant = {
|
||||
show: true,
|
||||
permissions,
|
||||
resolve
|
||||
}
|
||||
this.showManageExtensionDialog = true
|
||||
})
|
||||
},
|
||||
grantExtensionPermissions() {
|
||||
this.resolveExtensionPermissionDialog(this.permissionGrant.permissions)
|
||||
},
|
||||
cancelExtensionPermissions() {
|
||||
this.resolveExtensionPermissionDialog(null)
|
||||
},
|
||||
onManageExtensionDialogHide() {
|
||||
if (this.permissionGrant.show) {
|
||||
this.resolveExtensionPermissionDialog(null)
|
||||
}
|
||||
},
|
||||
resolveExtensionPermissionDialog(grantedPermissions) {
|
||||
const resolve = this.permissionGrant.resolve
|
||||
this.permissionGrant = {
|
||||
show: false,
|
||||
permissions: [],
|
||||
resolve: null
|
||||
}
|
||||
this.showManageExtensionDialog = false
|
||||
if (resolve) {
|
||||
resolve(grantedPermissions)
|
||||
}
|
||||
},
|
||||
permissionGrantHasHighRisk() {
|
||||
return window.LNbitsExtensionPermissions.hasHighRisk({
|
||||
permissions: this.permissionGrant.permissions,
|
||||
extensions: this.extensions,
|
||||
translate: key => this.$t(key)
|
||||
})
|
||||
},
|
||||
async selectAllUpdatableExtensionss() {
|
||||
this.updatableExtensions.forEach(e => (e.selectedForUpdate = true))
|
||||
},
|
||||
@@ -640,6 +983,13 @@ window.PageExtensions = {
|
||||
if (!ext.selectedForUpdate) {
|
||||
continue
|
||||
}
|
||||
if (ext.isWasm) {
|
||||
Quasar.Notify.create({
|
||||
type: 'warning',
|
||||
message: `Skipping ${ext.id}; this extension update requires permission approval.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
ext.inProgress = true
|
||||
await LNbits.api.request('POST', `/api/v1/extension`, null, {
|
||||
ext_id: ext.id,
|
||||
@@ -832,6 +1182,9 @@ window.PageExtensions = {
|
||||
async fetchAllExtensions() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
|
||||
data.forEach(ext => {
|
||||
ext.categories?.forEach(category => this.categories.add(category))
|
||||
})
|
||||
return data
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
|
||||
@@ -7,6 +7,7 @@ window.PageWallet = {
|
||||
invoice: null,
|
||||
lnurlpay: null,
|
||||
lnurlauth: null,
|
||||
sending: false,
|
||||
data: {
|
||||
request: '',
|
||||
amount: 0,
|
||||
@@ -131,6 +132,7 @@ window.PageWallet = {
|
||||
this.parse.data.request = ''
|
||||
this.parse.data.comment = ''
|
||||
this.parse.data.internalMemo = null
|
||||
this.parse.sending = false
|
||||
this.parse.data.paymentChecker = null
|
||||
this.parse.camera.show = false
|
||||
},
|
||||
@@ -223,6 +225,12 @@ window.PageWallet = {
|
||||
if (data.tag === 'payRequest') {
|
||||
this.parse.lnurlpay = Object.freeze(data)
|
||||
this.parse.data.amount = data.minSendable / 1000
|
||||
this.receive.units = [
|
||||
'sats',
|
||||
...(this.g.allowedCurrencies.length > 0
|
||||
? this.g.allowedCurrencies
|
||||
: this.g.currencies)
|
||||
]
|
||||
} else if (data.tag === 'login') {
|
||||
this.parse.lnurlauth = Object.freeze(data)
|
||||
} else if (data.tag === 'withdrawRequest') {
|
||||
@@ -356,6 +364,9 @@ window.PageWallet = {
|
||||
this.parse.invoice = Object.freeze(cleanInvoice)
|
||||
},
|
||||
payInvoice() {
|
||||
if (this.parse.sending) return
|
||||
|
||||
this.parse.sending = true
|
||||
const dismissPaymentMsg = Quasar.Notify.create({
|
||||
timeout: 0,
|
||||
message: this.$t('payment_processing')
|
||||
@@ -368,6 +379,7 @@ window.PageWallet = {
|
||||
this.parse.data.internalMemo
|
||||
)
|
||||
.then(response => {
|
||||
this.parse.sending = false
|
||||
dismissPaymentMsg()
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
this.parse.show = false
|
||||
@@ -385,13 +397,16 @@ window.PageWallet = {
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.parse.sending = false
|
||||
dismissPaymentMsg()
|
||||
LNbits.utils.notifyApiError(err)
|
||||
this.g.updatePayments = !this.g.updatePayments
|
||||
this.parse.show = false
|
||||
})
|
||||
},
|
||||
payLnurl() {
|
||||
if (this.parse.sending) return
|
||||
|
||||
this.parse.sending = true
|
||||
LNbits.api
|
||||
.request('post', '/api/v1/payments/lnurl', this.g.wallet.adminkey, {
|
||||
res: this.parse.lnurlpay,
|
||||
@@ -402,18 +417,26 @@ window.PageWallet = {
|
||||
internalMemo: this.parse.data.internalMemo
|
||||
})
|
||||
.then(response => {
|
||||
this.parse.sending = false
|
||||
this.parse.show = false
|
||||
if (response.data.extra.success_action) {
|
||||
const action = JSON.parse(response.data.extra.success_action)
|
||||
switch (action.tag) {
|
||||
case 'url':
|
||||
Quasar.Notify.create({
|
||||
message: `<a target="_blank" style="color: inherit" href="${action.url}">${action.url}</a>`,
|
||||
message: action.url,
|
||||
caption: action.description,
|
||||
html: true,
|
||||
html: false,
|
||||
type: 'positive',
|
||||
timeout: 0,
|
||||
closeBtn: true
|
||||
closeBtn: true,
|
||||
actions: [
|
||||
{
|
||||
label: 'Open link',
|
||||
color: 'white',
|
||||
handler: () => this.utils.openUrlInNewTab(action.url)
|
||||
}
|
||||
]
|
||||
})
|
||||
break
|
||||
case 'message':
|
||||
@@ -425,19 +448,36 @@ window.PageWallet = {
|
||||
})
|
||||
break
|
||||
case 'aes':
|
||||
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
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(LNbits.utils.notifyApiError)
|
||||
.catch(err => {
|
||||
this.parse.sending = false
|
||||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
authLnurl() {
|
||||
const dismissAuthMsg = Quasar.Notify.create({
|
||||
|
||||
@@ -323,6 +323,20 @@ window._lnbitsUtils = {
|
||||
converter.setOption('simpleLineBreaks', true)
|
||||
return converter.makeHtml(text)
|
||||
},
|
||||
_extI18nDirs: new Set(),
|
||||
_extI18nLoaded: {},
|
||||
loadExtI18n(dir, locale) {
|
||||
this._extI18nDirs.add(dir)
|
||||
const loaded = (this._extI18nLoaded[dir] ??= {})
|
||||
if (loaded[locale]) return loaded[locale]
|
||||
loaded[locale] = this.loadScript(`${dir}/${locale}.js`).catch(() => {
|
||||
if (locale !== 'en') {
|
||||
loaded['en'] ??= this.loadScript(`${dir}/en.js`).catch(() => {})
|
||||
return loaded['en']
|
||||
}
|
||||
})
|
||||
return loaded[locale]
|
||||
},
|
||||
async decryptLnurlPayAES(success_action, preimage) {
|
||||
let keyb = new Uint8Array(
|
||||
preimage.match(/[\da-f]{2}/gi).map(h => parseInt(h, 16))
|
||||
@@ -351,5 +365,27 @@ 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')
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,11 +58,15 @@ body.bg-image {
|
||||
}
|
||||
// transparent background for specific elements
|
||||
body.body--dark {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark),
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
}
|
||||
|
||||
.q-header,
|
||||
.q-drawer {
|
||||
--q-dark: #{color.adjust(#1d1d1d, $alpha: -0.7)};
|
||||
background-color: var(--q-dark);
|
||||
backdrop-filter: blur(6px) brightness(0.8);
|
||||
backdrop-filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,21 @@ body.rounded-ui {
|
||||
|
||||
body.card-shadow {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 10px 24px rgba(0, 0, 0, 0.18));
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
body.card-shadow.body--dark {
|
||||
.q-card:not(.q-dialog .q-card, .lnbits__dialog-card, .q-dialog-plugin--dark) {
|
||||
filter: drop-shadow(0 12px 28px rgba(0, 0, 0, 0.45));
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
body.no-burger-background {
|
||||
.q-drawer {
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"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",
|
||||
@@ -66,6 +67,8 @@
|
||||
"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",
|
||||
@@ -89,6 +92,7 @@
|
||||
"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",
|
||||
@@ -96,6 +100,7 @@
|
||||
"js/components/extension-settings.js",
|
||||
"js/components/data-fields.js",
|
||||
"js/components.js",
|
||||
"js/wasm-extension-component.js",
|
||||
"js/init-app.js"
|
||||
],
|
||||
"css": ["vendor/quasar.css", "css/base.css"]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user