Compare commits

..
Author SHA1 Message Date
Arc 2e71830817 clean 2026-02-25 22:24:22 +00:00
Arc 0fb2334181 fundle 2026-02-25 22:08:00 +00:00
Arc 94c7796dd5 Merge remote-tracking branch 'origin/dev' into wasm_extensions 2026-02-25 21:30:24 +00:00
Arc af4ce8da52 pull almost all out to a parent extension 2026-02-25 21:05:28 +00:00
Arc 4e8a5e050b make 2026-02-25 16:20:26 +00:00
Arc 3fd7aae10d tasks and docs 2026-02-25 16:11:14 +00:00
Arc 64ad19a65b tags 2026-02-25 13:39:26 +00:00
Arc ee6ff7179e bug fixes 2026-02-22 01:19:19 +00:00
Arc 684dfcc9fb for uninstalling wasm 2026-02-21 20:47:18 +00:00
Arc a498d4a877 show keys 2026-02-21 14:48:41 +00:00
Arc ef90c983f2 make 2026-02-21 14:10:05 +00:00
Arc da8a82c78e black 2026-02-21 13:48:27 +00:00
Arc 51f98e8130 added websocket listener back 2026-02-21 12:39:27 +00:00
Arc 7b4436b288 not needed 2026-02-21 11:55:31 +00:00
Arc ec88cf483e path proxy 2026-02-21 11:26:38 +00:00
Arc ca215063be uv lock 2026-02-21 09:51:10 +00:00
Arc 2674cfb5f1 whoops, forgot to add 2026-02-21 09:26:20 +00:00
Arc 26f0b69085 init 2026-02-21 09:18:13 +00:00
dni ⚡andGitHub 2069e3de8b chore: update to v1.5.0-rc2 (#3806) 2026-02-20 10:04:59 +01:00
Vlad StanandGitHub 800be6042f fix: paypal subscriptions (#3797) 2026-02-20 10:56:08 +02:00
dni ⚡andGitHub c65d373f23 feat: restructure docker images, add sparkl2 docker image (#3794) 2026-02-18 13:52:19 +01:00
dni ⚡andGitHub 087e095bcf feat: hide first_install_token if is not required (#3795) 2026-02-18 13:52:02 +01:00
dni ⚡andGitHub 027a161494 chore: update lnbits to version v1.5.0-rc1 (#3793) 2026-02-17 12:26:50 +01:00
83ef05696e feat: Spark L2 (#3715)
Co-authored-by: blackcoffeexbt <87530449+blackcoffeexbt@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 12:25:38 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
42962308e6 chore(deps): bump pillow from 12.1.0 to 12.1.1 (#3785)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-17 11:33:28 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Vlad Stan
783ee256a1 chore(deps): bump axios from 1.13.2 to 1.13.5 (#3786)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-17 11:32:58 +01:00
4e805fd00e feat: modern theme stuff like rounded corners and card gradient (#3766)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-02-16 08:06:36 +01:00
dni ⚡andGitHub f54824cc44 fix: /first_install new superuser may duplicate an username (#3787) 2026-02-13 14:44:52 +00:00
ArcandGitHub 768d2cbe2a fix: fail closed on PayPal webhook verification errors (#3769) 2026-02-10 08:50:50 +01:00
krviandGitHub efd36d0221 fix: use canonical values in rate limit unit select (#3753) 2026-02-09 10:43:03 +02:00
Vlad StanandGitHub 4684939e13 fix: validation of invitation code (#3767) 2026-02-05 18:53:01 +02:00
Vlad StanandGitHub 193ee20da4 [feat] User invitation code (#3761) 2026-02-05 12:35:24 +02:00
Vlad StanandGitHub 2fe7ab4f83 [feat] Allow no exchange providers (#3763) 2026-02-05 11:11:48 +01:00
dni ⚡andGitHub 10c8ca4ca1 fix: datetime in db.py added utc offset (#3762) 2026-02-05 11:05:58 +01:00
Vlad StanandGitHub f079762b71 fix: handle validation errors when the response is binary (#3765) 2026-02-05 11:55:40 +02:00
Vlad StanandGitHub 2e042d2597 fix: extension paid/free label (#3764) 2026-02-05 11:32:46 +02:00
Vlad StanandGitHub 49b57c9f0b [feat] User activation (#3749) 2026-02-05 11:31:47 +02:00
dni ⚡andGitHub 656c6cac5b hotfix: websocket with old balance was sent. (#3759) 2026-02-03 12:23:49 +01:00
45e773b66f feat: FIRST_INSTALL_TOKEN to help services like Umbrel secure the first_install endpoint (#3751)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-30 11:21:52 +01:00
f692ac6f12 refactor: payment.check_status() into check_payment_status(payment) (#3747)
Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
2026-01-30 11:18:17 +01:00
dni ⚡andGitHub 78202a027b fix: uiCustomisation JS Error (#3752) 2026-01-30 09:27:54 +01:00
dni ⚡andGitHub 28fa0906a6 refactor: g.settings with a PublicSettings class for mapping, for reactive WINDOW_SETTINGS (#3644) 2026-01-30 09:19:01 +01:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
44 changed files with 1726 additions and 861 deletions
+2 -8
View File
@@ -70,6 +70,7 @@ FORWARDED_ALLOW_IPS="*"
# Inside this directory the `extensions` and `upgrades` sub-directories will be created. # Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir" # LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# ID of the super user. The user ID must exist. # ID of the super user. The user ID must exist.
# SUPER_USER="" # SUPER_USER=""
@@ -191,14 +192,7 @@ BOLTZ_CLIENT_MACAROON="/home/bob/.boltz/macaroons/admin.macaroon"
# HEXSTRING instead of path also possible # HEXSTRING instead of path also possible
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert" BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# SparkL2Wallet (external sidecar: https://github.com/lnbits/spark_sidecar) # TODO: add Spark
SPARK_L2_NETWORK=MAINNET
SPARK_L2_EXTERNAL_ENDPOINT=http://127.0.0.1:8765
SPARK_L2_EXTERNAL_API_KEY=
# optional tuning
# SPARK_L2_PAY_WAIT_MS=4000
# SPARK_L2_PAY_POLL_MS=500
# SPARK_L2_STREAM_KEEPALIVE_MS=15000
# StrikeWallet # StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1 STRIKE_API_ENDPOINT=https://api.strike.me/v1
+2 -5
View File
@@ -26,10 +26,8 @@ password = "boltz"
host = "0.0.0.0" host = "0.0.0.0"
port = 9005 port = 9005
[swap]
deferredClaimSymbols = ["BTC", "L-BTC"]
[[pairs]] [[pairs]]
isLegacy = true
base = "BTC" base = "BTC"
quote = "BTC" quote = "BTC"
rate = 1 rate = 1
@@ -46,6 +44,7 @@ minSwapAmount = 50_000
swapTaproot = 10080 swapTaproot = 10080
[[pairs]] [[pairs]]
isLegacy = true
base = "L-BTC" base = "L-BTC"
quote = "BTC" quote = "BTC"
fee = 0.25 fee = 0.25
@@ -84,7 +83,6 @@ maxZeroConfAmount = 0
zmqpubrawblock = "tcp://bitcoind:29001" zmqpubrawblock = "tcp://bitcoind:29001"
port = 18_443 port = 18_443
cookie = "/root/.bitcoin/regtest/.cookie" cookie = "/root/.bitcoin/regtest/.cookie"
feeFloor = 0.2
wallet = "lnbits" wallet = "lnbits"
@@ -103,7 +101,6 @@ maxSwapAmount = 40_294_967
minSwapAmount = 10_000 minSwapAmount = 10_000
maxZeroConfAmount = 40_294_967 maxZeroConfAmount = 40_294_967
[liquid.chain] [liquid.chain]
host = "elementsd" host = "elementsd"
port = 18884 port = 18884
+1 -1
View File
@@ -28,7 +28,7 @@ services:
- lnd-2 - lnd-2
- boltz-postgres - boltz-postgres
restart: always restart: always
image: boltz/boltz:v3.12.1 image: boltz/boltz:latest
ports: ports:
- 9000:9000 - 9000:9000
entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'" entrypoint: "sh -c 'sleep 30; /boltz-backend/bin/boltzd'"
+2 -2
View File
@@ -50,7 +50,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
| **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. | | **Blink** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; focuses on mobile integrations. |
| **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. | | **ZBD** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Gaming-focused payment platform. |
| **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. | | **Spark (CLN)** | Self-custodial | ❌ | Higher | ✅ | High | Manual | Moderate | High | Infrastructure cost and channel opening fees | High | Web interface for CLN; requires Spark server setup. |
| **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | High | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. | | **Spark (L2)** | Self-custodial | ❌ | Medium | ❌ | Medium | Automatic | Easy | Low | Minimal fees | Medium | Runs via Spark sidecar; seed-based self-custody. |
| **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. | | **Cliche Wallet** | Self-custodial | ❌ | Medium | ❌ | Medium | Manual | Moderate | Moderate | Minimal fees | Medium | Lightweight wallet; suitable for embedded systems. |
| **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | **Strike** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
| **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. | | **LNPay** | Custodial | ✅ | Low | ❌ | Low | Provider-managed | Easy | Low | Transaction fees apply | Medium | Third-party service; suitable for quick setups. |
@@ -60,7 +60,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
## Spark (L2) ## Spark (L2)
Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. Sidecar repo `https://github.com/lnbits/spark_sidecar`. Spark L2 uses a local Node.js sidecar to expose an HTTP API that LNbits can use as a funding source. It is self-custodial and secured by a standard mnemonic seed. You can let LNbits run the sidecar internally or run your own instance (see `https://github.com/lnbits/spark_sidecar`).
### Notes for readers ### Notes for readers
+25 -11
View File
@@ -41,8 +41,8 @@ A backend wallet is selected and configured entirely through LNbits environment
| [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) | | [CoreLightning](#corelightning) | [LND (gRPC)](#lnd-grpc) | [Blink](#blink) |
| [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) | | [CoreLightning REST](#corelightning-rest) | [LNbits](#lnbits) | [Alby](#alby) |
| [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) | | [Spark (Core Lightning)](#spark-core-lightning) | [LNPay](#lnpay) | [Boltz](#boltz) |
| [Spark L2](#spark-l2) | [ZBD](#zbd) | [Phoenixd](#phoenixd) | | [Spark (L2)](#spark-l2) | | |
| [Cliche Wallet](#cliche-wallet) | | | | [Cliche Wallet](#cliche-wallet) | [ZBD](#zbd) | [Phoenixd](#phoenixd) |
| [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) | | [Breez SDK](#breez-sdk) | [Breez Liquid SDK](#breez-liquid-sdk) | [Nostr Wallet Connect](#nostr-wallet-connect-nwc) |
| [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) | | [Strike](#strike) | [Eclair (ACINQ)](#eclair-acinq) | [LN.tips](#lntips) |
| [Fake Wallet](#fake-wallet) | | | | [Fake Wallet](#fake-wallet) | | |
@@ -128,18 +128,32 @@ Old REST interface using [RTL c-lightning-REST](https://github.com/Ride-The-Ligh
- `SPARK_URL`: `http://10.147.17.230:9737/rpc` - `SPARK_URL`: `http://10.147.17.230:9737/rpc`
- `SPARK_TOKEN`: `secret_access_key` - `SPARK_TOKEN`: `secret_access_key`
## Spark L2 ## Spark (L2)
Self-custodial funding source using the [Spark L2](https://docs.spark.money/start/overview) network. Requires a Node.js [sidecar](https://github.com/lnbits/spark_sidecar) that bridges lnbits talking to Spark. Works in addition with any Spark-compatible seed (Wallet of Satoshi, BuhoGO, BlitzWallet). Spark L2 is a self-custodial funding source that uses a small Node.js sidecar to talk to the Spark network and expose an HTTP API. You can let LNbits run the sidecar internally, or run it yourself and point LNbits at it.
If the sidecar is started with a `mnemonic` then that mnemonic will be used. Otherwhise if a mnemonic is set for the `Spark L2` LNbits funding source then that mnemonic will be used.
### Optional tuning **Required env vars**
- `SPARK_L2_PAY_WAIT_MS`: `4000` _(payment timeout in ms)_ - `LNBITS_BACKEND_WALLET_CLASS`: `SparkL2Wallet`
- `SPARK_L2_PAY_POLL_MS`: `500` _(polling interval in ms)_
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000` _(SSE keepalive in ms)_
### Example: run the sidecar **Internal sidecar (LNbits-managed)**
- `SPARK_L2_INTERNAL_SIDECAR_VERSION`: `0.1.1`
- `SPARK_L2_MNEMONIC`: `...` (optional; auto-generated if blank)
- `SPARK_L2_NETWORK`: `MAINNET`
Optional tuning:
- `SPARK_L2_PAY_WAIT_MS`: `4000`
- `SPARK_L2_PAY_POLL_MS`: `500`
- `SPARK_L2_STREAM_KEEPALIVE_MS`: `15000`
**External sidecar**
- `SPARK_L2_EXTERNAL_ENDPOINT`: `http://127.0.0.1:8765`
- `SPARK_L2_EXTERNAL_API_KEY`: `...` (if your sidecar requires auth)
Example: run the sidecar manually (external mode)
```bash ```bash
git clone https://github.com/lnbits/spark_sidecar.git git clone https://github.com/lnbits/spark_sidecar.git
@@ -153,7 +167,7 @@ SPARK_PAY_WAIT_MS=20000 \
node server.mjs node server.mjs
``` ```
For testing, you can generate a 12-word mnemonic at https://iancoleman.io/bip39/. Store it securely — it controls your funds. Then select Spark (L2) as the funding source in LNbits. For testing, you can generate a 12-word mnemonic at `https://iancoleman.io/bip39/`. Store it securely — it controls your funds. Then select **Spark (L2)** as the funding source in LNbits.
## LND (REST) ## LND (REST)
+28 -2
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import glob import glob
import importlib import importlib
import json
import os import os
import shutil import shutil
import sys import sys
@@ -35,9 +36,12 @@ from lnbits.core.tasks import (
purge_audit_data, purge_audit_data,
run_by_the_minute_tasks, run_by_the_minute_tasks,
wait_for_audit_data, wait_for_audit_data,
wait_for_paid_invoices,
wait_notification_messages, wait_notification_messages,
) )
from lnbits.core.tasks import (
wait_for_paid_invoices as wait_for_paid_invoices_core,
)
from lnbits.core.wasm.extension_host import register_wasm_ext_routes
from lnbits.exceptions import register_exception_handlers from lnbits.exceptions import register_exception_handlers
from lnbits.helpers import version_parse from lnbits.helpers import version_parse
from lnbits.settings import settings from lnbits.settings import settings
@@ -419,6 +423,8 @@ def register_new_ratelimiter(app: FastAPI) -> Callable:
def register_ext_tasks(ext: Extension) -> None: def register_ext_tasks(ext: Extension) -> None:
"""Register extension async tasks.""" """Register extension async tasks."""
if ext.extension_type == "wasm":
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
if hasattr(ext_module, f"{ext.code}_start"): if hasattr(ext_module, f"{ext.code}_start"):
@@ -428,6 +434,12 @@ def register_ext_tasks(ext: Extension) -> None:
def register_ext_routes(app: FastAPI, ext: Extension) -> None: def register_ext_routes(app: FastAPI, ext: Extension) -> None:
"""Register FastAPI routes for extension.""" """Register FastAPI routes for extension."""
if ext.extension_type != "wasm":
ext.extension_type = _load_extension_type(ext.code) or ext.extension_type
if ext.extension_type == "wasm":
settings.activate_extension_paths(ext.code, ext.upgrade_hash, [])
register_wasm_ext_routes(app, ext)
return
ext_module = importlib.import_module(ext.module_name) ext_module = importlib.import_module(ext.module_name)
ext_route = getattr(ext_module, f"{ext.code}_ext") ext_route = getattr(ext_module, f"{ext.code}_ext")
@@ -453,6 +465,20 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
app.include_router(router=ext_route, prefix=prefix) app.include_router(router=ext_route, prefix=prefix)
def _load_extension_type(ext_id: str) -> str | None:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return None
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type")
except Exception:
return None
async def check_and_register_extensions(app: FastAPI) -> None: async def check_and_register_extensions(app: FastAPI) -> None:
await check_installed_extensions(app) await check_installed_extensions(app)
for ext in await get_valid_extensions(False): for ext in await get_valid_extensions(False):
@@ -476,7 +502,7 @@ def register_async_tasks() -> None:
# core invoice listener # core invoice listener
invoice_queue: asyncio.Queue = asyncio.Queue() invoice_queue: asyncio.Queue = asyncio.Queue()
register_invoice_listener(invoice_queue, "core") register_invoice_listener(invoice_queue, "core")
create_permanent_task(lambda: wait_for_paid_invoices(invoice_queue)) create_permanent_task(lambda: wait_for_paid_invoices_core(invoice_queue))
create_permanent_task(run_by_the_minute_tasks) create_permanent_task(run_by_the_minute_tasks)
create_permanent_task(purge_audit_data) create_permanent_task(purge_audit_data)
+4 -1
View File
@@ -1,3 +1,5 @@
import re
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
InstallableExtension, InstallableExtension,
@@ -49,7 +51,8 @@ async def drop_extension_db(ext_id: str, conn: Connection | None = None) -> None
) )
# Check that 'ext_id' is a valid extension id and not a malicious string # Check that 'ext_id' is a valid extension id and not a malicious string
if not row: if not row:
raise Exception(f"Extension '{ext_id}' db version cannot be found") if not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", ext_id):
raise Exception(f"Extension '{ext_id}' db version cannot be found")
is_file_based_db = await Database.clean_ext_db_files(ext_id) is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db: if is_file_based_db:
+30
View File
@@ -22,6 +22,8 @@ from lnbits.settings import settings
async def migrate_extension_database( async def migrate_extension_database(
ext: InstallableExtension, current_version: DbVersion | None = None ext: InstallableExtension, current_version: DbVersion | None = None
): ):
if _is_wasm_extension(ext):
return
try: try:
ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") ext_migrations = importlib.import_module(f"{ext.module_name}.migrations")
@@ -58,6 +60,34 @@ async def run_migration(
await update_migration_version(conn, db_name, version) await update_migration_version(conn, db_name, version)
def _is_wasm_extension(ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
import json
from pathlib import Path
candidate_dirs = [
Path(ext.ext_dir),
Path(settings.lnbits_extensions_path, "extensions", ext.id),
Path(settings.lnbits_path, "extensions", ext.id),
Path.cwd() / "extensions" / ext.id,
]
for base in candidate_dirs:
conf_path = Path(base, "config.json")
if not conf_path.is_file():
continue
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
if config_json.get("extension_type") == "wasm":
return True
except Exception as exc:
logger.debug(f"Failed to load extension config for '{ext.id}': {exc!s}")
return False
def to_valid_user_id(user_id: str) -> UUID: def to_valid_user_id(user_id: str) -> UUID:
if len(user_id) < 32: if len(user_id) < 32:
raise ValueError("User ID must have at least 128 bits") raise ValueError("User ID must have at least 128 bits")
+58 -1
View File
@@ -83,6 +83,11 @@ class ExtensionConfig(BaseModel):
warning: str | None = "" warning: str | None = ""
min_lnbits_version: str | None min_lnbits_version: str | None
max_lnbits_version: str | None max_lnbits_version: str | None
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
def is_version_compatible(self) -> bool: def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version) return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -115,6 +120,20 @@ class PayToEnableInfo(BaseModel):
class UserExtensionInfo(BaseModel): class UserExtensionInfo(BaseModel):
paid_to_enable: bool | None = False paid_to_enable: bool | None = False
payment_hash_to_enable: str | None = None payment_hash_to_enable: str | None = None
granted_permissions: list[str] | None = None
granted_payment_tags: list[str] | None = None
class ExtensionPermission(BaseModel):
id: str
label: str
description: str
dangerous: bool | None = False
class ExtensionPermissionsGrant(BaseModel):
permissions: list[str] = []
payment_tags: list[str] = []
class UserExtension(BaseModel): class UserExtension(BaseModel):
@@ -147,6 +166,10 @@ class Extension(BaseModel):
short_description: str | None = None short_description: str | None = None
tile: str | None = None tile: str | None = None
upgrade_hash: str | None = "" upgrade_hash: str | None = ""
extension_type: str | None = None
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
@property @property
def module_name(self) -> str: def module_name(self) -> str:
@@ -170,6 +193,12 @@ class Extension(BaseModel):
short_description=ext_info.short_description, short_description=ext_info.short_description,
tile=ext_info.icon, tile=ext_info.icon,
upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "", upgrade_hash=ext_info.hash if ext_info.ext_upgrade_dir.is_dir() else "",
extension_type=ext_info.meta.extension_type if ext_info.meta else None,
public_kv_keys=ext_info.meta.public_kv_keys if ext_info.meta else [],
public_wasm_functions=(
ext_info.meta.public_wasm_functions if ext_info.meta else []
),
payment_tags=ext_info.meta.payment_tags if ext_info.meta else [],
) )
@@ -331,6 +360,11 @@ class ExtensionMeta(BaseModel):
pay_to_enable: PayToEnableInfo | None = None pay_to_enable: PayToEnableInfo | None = None
payments: list[ReleasePaymentInfo] = [] payments: list[ReleasePaymentInfo] = []
dependencies: list[str] = [] dependencies: list[str] = []
permissions: list[ExtensionPermission] = []
extension_type: str | None = "python"
public_kv_keys: list[str] = []
public_wasm_functions: list[str] = []
payment_tags: list[str] = []
archive: str | None = None archive: str | None = None
featured: bool = False featured: bool = False
paid_features: str | None = None paid_features: str | None = None
@@ -454,6 +488,14 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name") self.name = config_json.get("name")
self.short_description = config_json.get("short_description") self.short_description = config_json.get("short_description")
if self.meta:
self.meta.permissions = config_json.get("permissions", [])
self.meta.extension_type = config_json.get("extension_type", "python")
self.meta.public_kv_keys = config_json.get("public_kv_keys", [])
self.meta.public_wasm_functions = config_json.get(
"public_wasm_functions", []
)
self.meta.payment_tags = config_json.get("payment_tags", [])
if ( if (
self.meta self.meta
@@ -572,6 +614,11 @@ class InstallableExtension(BaseModel):
latest_release=ExtensionRelease.from_github_release( latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release source_repo, latest_release
), ),
permissions=config.permissions,
extension_type=config.extension_type,
public_kv_keys=config.public_kv_keys,
public_wasm_functions=config.public_wasm_functions,
payment_tags=config.payment_tags,
), ),
) )
except Exception as e: except Exception as e:
@@ -617,7 +664,14 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}", source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"), min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"), max_lnbits_version=config_json.get("max_lnbits_version"),
) ),
permissions=config_json.get("permissions", []),
extension_type=config_json.get("extension_type", "python"),
public_kv_keys=config_json.get("public_kv_keys", []),
public_wasm_functions=config_json.get(
"public_wasm_functions", []
),
payment_tags=config_json.get("payment_tags", []),
), ),
) )
@@ -841,3 +895,6 @@ def icon_to_github_url(source_repo: str, path: str | None) -> str:
_, _, *rest = path.split("/") _, _, *rest = path.split("/")
tail = "/".join(rest) tail = "/".join(rest)
return f"https://github.com/{source_repo}/raw/main/{tail}" return f"https://github.com/{source_repo}/raw/main/{tail}"
ExtensionConfig.update_forward_refs(ExtensionPermission=ExtensionPermission)
+35 -1
View File
@@ -10,13 +10,18 @@ from lnbits.core.crud import (
get_db_version, get_db_version,
get_installed_extension, get_installed_extension,
update_installed_extension_state, update_installed_extension_state,
update_migration_version,
) )
from lnbits.core.crud.extensions import ( from lnbits.core.crud.extensions import (
get_installed_extensions, get_installed_extensions,
update_installed_extension, update_installed_extension,
) )
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.db import Connection from lnbits.core.wasm.extension_host import (
clear_schedules_for_extension,
clear_tag_watches_for_extension,
)
from lnbits.db import COCKROACH, POSTGRES, Connection, Database
from lnbits.settings import settings from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
@@ -53,6 +58,9 @@ async def install_extension(
else: else:
await update_installed_extension(ext_info) await update_installed_extension(ext_info)
if ext_info.meta and ext_info.meta.extension_type == "wasm":
await update_migration_version(None, ext_info.id, 0)
extension = Extension.from_installable_ext(ext_info) extension = Extension.from_installable_ext(ext_info)
if extension.is_upgrade_extension: if extension.is_upgrade_extension:
# call stop while the old routes are still active # call stop while the old routes are still active
@@ -70,10 +78,26 @@ async def uninstall_extension(ext_id: str):
extension = await get_installed_extension(ext_id) extension = await get_installed_extension(ext_id)
if extension: if extension:
if extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await _purge_wasm_extension_db(ext_id)
extension.clean_extension_files() extension.clean_extension_files()
await delete_installed_extension(ext_id=ext_id) await delete_installed_extension(ext_id=ext_id)
async def _purge_wasm_extension_db(ext_id: str) -> None:
cleaned = await Database.clean_ext_db_files(ext_id)
if cleaned:
return
try:
db = Database(f"ext_{ext_id}")
if db.type in {POSTGRES, COCKROACH}:
await db.execute(f"DROP SCHEMA IF EXISTS {ext_id} CASCADE")
except Exception as exc:
logger.warning(f"Failed to drop WASM extension schema for '{ext_id}': {exc}")
async def activate_extension(ext: Extension): async def activate_extension(ext: Extension):
core_app_extra.register_new_ext_routes(ext) core_app_extra.register_new_ext_routes(ext)
await update_installed_extension_state(ext_id=ext.code, active=True) await update_installed_extension_state(ext_id=ext.code, active=True)
@@ -83,6 +107,10 @@ async def activate_extension(ext: Extension):
async def deactivate_extension(ext_id: str): async def deactivate_extension(ext_id: str):
settings.deactivate_extension_paths(ext_id) settings.deactivate_extension_paths(ext_id)
await update_installed_extension_state(ext_id=ext_id, active=False) await update_installed_extension_state(ext_id=ext_id, active=False)
extension = await get_installed_extension(ext_id)
if extension and extension.meta and extension.meta.extension_type == "wasm":
await clear_tag_watches_for_extension(ext_id)
await clear_schedules_for_extension(ext_id)
await stop_extension_background_work(ext_id) await stop_extension_background_work(ext_id)
@@ -91,6 +119,9 @@ async def stop_extension_background_work(ext_id: str) -> bool:
Stop background work for extension (like asyncio.Tasks, WebSockets, etc). Stop background work for extension (like asyncio.Tasks, WebSockets, etc).
Extension must expose a `myextension_stop()` function if it is starting tasks. Extension must expose a `myextension_stop()` function if it is starting tasks.
""" """
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id) upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
@@ -123,6 +154,9 @@ async def start_extension_background_work(ext_id: str) -> bool:
Extension CAN expose a `myextension_start()` function if it is starting tasks. Extension CAN expose a `myextension_start()` function if it is starting tasks.
Extension MUST expose a `myextension_stop()` in that case. Extension MUST expose a `myextension_stop()` in that case.
""" """
installed = await get_installed_extension(ext_id)
if installed and installed.meta and installed.meta.extension_type == "wasm":
return True
upgrade_hash = settings.extension_upgrade_hash(ext_id) upgrade_hash = settings.extension_upgrade_hash(ext_id)
ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash) ext = Extension(code=ext_id, is_valid=True, upgrade_hash=upgrade_hash)
+5
View File
@@ -313,6 +313,11 @@ async def send_ws_payment_notification(wallet: Wallet, payment: Payment):
payment.payment_hash, payment.payment_hash,
json.dumps({"pending": payment.pending, "status": payment.status}), json.dumps({"pending": payment.pending, "status": payment.status}),
) )
if payment.tag and not payment.is_out:
await websocket_manager.send(
f"tag:{wallet.id}:{payment.tag}",
payment.json(),
)
async def send_chat_payment_notification(wallet: Wallet, payment: Payment): async def send_chat_payment_notification(wallet: Wallet, payment: Payment):
+420 -13
View File
@@ -1,12 +1,15 @@
import json
import sys import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
import httpx import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Body, Depends, HTTPException
from fastapi.requests import Request from fastapi.requests import Request
from loguru import logger from loguru import logger
from starlette.routing import Match
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
from lnbits.core.crud.wallets import get_wallets_ids from lnbits.core.crud.wallets import get_wallets_ids
@@ -20,6 +23,8 @@ from lnbits.core.models.extensions import (
Extension, Extension,
ExtensionConfig, ExtensionConfig,
ExtensionMeta, ExtensionMeta,
ExtensionPermission,
ExtensionPermissionsGrant,
ExtensionRelease, ExtensionRelease,
ExtensionReview, ExtensionReview,
ExtensionReviewPaymentRequest, ExtensionReviewPaymentRequest,
@@ -40,6 +45,11 @@ from lnbits.core.services.extensions import (
install_extension, install_extension,
uninstall_extension, uninstall_extension,
) )
from lnbits.core.wasm import WASM_HOST_MANIFEST
from lnbits.core.wasm.extension_host import (
clear_schedules_for_user,
clear_tag_watches_for_user,
)
from lnbits.db import Page from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists, check_account_exists,
@@ -174,38 +184,146 @@ async def api_update_pay_to_enable(
@extension_router.put("/{ext_id}/enable") @extension_router.put("/{ext_id}/enable")
async def api_enable_extension( async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists) ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus: ) -> SimpleStatus:
await _ensure_extension_exists(ext_id)
logger.info(f"Enabling extension: {ext_id}.")
ext = await _get_installed_active_extension(ext_id)
user_ext = await _get_or_create_user_extension(account_id.id, ext_id)
required_permissions = _get_required_permissions(ext_id, ext)
granted_permissions = _get_granted_permissions(grant, user_ext)
granted_tags = _get_granted_payment_tags(grant, user_ext)
required_tags = _get_required_payment_tags(ext_id, ext)
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(required_tags, granted_tags)
_ensure_permissions(required_permissions, granted_permissions)
if grant and grant.permissions:
await _store_granted_permissions(user_ext, granted_permissions)
if grant and grant.payment_tags:
await _store_granted_payment_tags(user_ext, granted_tags)
if account_id.is_admin_id or not ext.requires_payment:
await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
return await _enable_paid_extension(ext_id, ext, user_ext)
async def _ensure_extension_exists(ext_id: str) -> None:
if ext_id not in [e.code for e in await get_valid_extensions()]: if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException( raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist." HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
) )
logger.info(f"Enabling extension: {ext_id}.")
async def _get_installed_active_extension(ext_id: str) -> InstallableExtension:
ext = await get_installed_extension(ext_id) ext = await get_installed_extension(ext_id)
if not ext: if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.") raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active: if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.") raise ValueError(f"Extension '{ext_id}' is not activated.")
return ext
user_ext = await get_user_extension(account_id.id, ext_id)
async def _get_or_create_user_extension(user_id: str, ext_id: str) -> UserExtension:
user_ext = await get_user_extension(user_id, ext_id)
if not user_ext: if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False) user_ext = UserExtension(user=user_id, extension=ext_id, active=False)
await create_user_extension(user_ext) await create_user_extension(user_ext)
return user_ext
if account_id.is_admin_id or not ext.requires_payment:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
def _get_required_permissions(ext_id: str, ext: InstallableExtension) -> list[str]:
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
return [p.id for p in permissions_source] if permissions_source else []
def _get_granted_permissions(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.permissions:
return grant.permissions
if user_ext.extra and user_ext.extra.granted_permissions:
return user_ext.extra.granted_permissions
return []
def _get_required_payment_tags(ext_id: str, ext: InstallableExtension) -> list[str]:
tags_source = (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext_id)
)
return tags_source if tags_source else []
def _get_granted_payment_tags(
grant: ExtensionPermissionsGrant | None, user_ext: UserExtension
) -> list[str]:
if grant and grant.payment_tags:
return grant.payment_tags
if user_ext.extra and user_ext.extra.granted_payment_tags:
return user_ext.extra.granted_payment_tags
return []
def _ensure_permissions(required: list[str], granted: list[str]) -> None:
if not required:
return
missing = [p for p in required if p not in granted]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to enable this extension.",
)
async def _store_granted_permissions(
user_ext: UserExtension, granted_permissions: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _store_granted_payment_tags(
user_ext: UserExtension, granted_tags: list[str]
) -> None:
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
async def _activate_user_extension(user_ext: UserExtension) -> None:
user_ext.active = True
await update_user_extension(user_ext)
async def _enable_paid_extension(
ext_id: str, ext: InstallableExtension, user_ext: UserExtension
) -> SimpleStatus:
if not (user_ext.extra and user_ext.extra.payment_hash_to_enable): if not (user_ext.extra and user_ext.extra.payment_hash_to_enable):
raise HTTPException( raise HTTPException(
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment." HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
) )
if user_ext.is_paid: if user_ext.is_paid:
user_ext.active = True await _activate_user_extension(user_ext)
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet: if not ext.meta or not ext.meta.pay_to_enable or not ext.meta.pay_to_enable.wallet:
@@ -222,9 +340,8 @@ async def api_enable_extension(
f"Invoice generated but not paid for enabeling extension '{ext_id}'.", f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
) )
user_ext.active = True
user_ext.extra.paid_to_enable = True user_ext.extra.paid_to_enable = True
await update_user_extension(user_ext) await _activate_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.") return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
@@ -243,10 +360,117 @@ async def api_disable_extension(
) )
logger.info(f"Disabling extension: {ext_id}.") logger.info(f"Disabling extension: {ext_id}.")
user_ext.active = False user_ext.active = False
if user_ext.extra and user_ext.extra.granted_permissions:
user_ext.extra.granted_permissions = []
if user_ext.extra and user_ext.extra.granted_payment_tags:
user_ext.extra.granted_payment_tags = []
await update_user_extension(user_ext) await update_user_extension(user_ext)
ext = await get_installed_extension(ext_id)
if ext and ext.meta and ext.meta.extension_type == "wasm":
await clear_tag_watches_for_user(ext_id, account_id.id)
await clear_schedules_for_user(ext_id, account_id.id)
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.") return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
@extension_router.put("/{ext_id}/permissions")
async def api_update_extension_permissions(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
grant: ExtensionPermissionsGrant | None = Body(default=None),
) -> SimpleStatus:
if ext_id not in [e.code for e in await get_valid_extensions()]:
raise HTTPException(
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
user_ext = UserExtension(user=account_id.id, extension=ext_id, active=False)
await create_user_extension(user_ext)
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
required_permissions = (
[p.id for p in permissions_source] if permissions_source else []
)
granted_permissions = grant.permissions if grant and grant.permissions else []
granted_tags = grant.payment_tags if grant and grant.payment_tags else []
if _is_wasm_extension(ext_id, ext):
_ensure_api_permissions_available(
request, _merge_permissions(required_permissions, granted_permissions)
)
_ensure_payment_tags_allowed(
_get_required_payment_tags(ext_id, ext), granted_tags
)
if required_permissions:
missing = [p for p in required_permissions if p not in granted_permissions]
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Missing required permissions to save for this extension.",
)
user_ext_info = user_ext.extra or UserExtensionInfo()
user_ext_info.granted_permissions = granted_permissions
user_ext_info.granted_payment_tags = granted_tags
user_ext.extra = user_ext_info
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Permissions saved for '{ext_id}'.")
@extension_router.get("/{ext_id}/capabilities")
async def api_extension_capabilities(
ext_id: str,
request: Request,
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
await _ensure_extension_exists(ext_id)
ext = await get_installed_extension(ext_id)
if not ext:
raise ValueError(f"Extension '{ext_id}' is not installed.")
permissions_source = (
ext.meta.permissions
if ext.meta and ext.meta.permissions
else _load_permissions_from_config(ext_id)
)
permissions = [p.id for p in permissions_source] if permissions_source else []
missing = (
_missing_api_permissions(request, permissions)
if _is_wasm_extension(ext_id, ext)
else []
)
payment_tags = _get_required_payment_tags(ext_id, ext)
return {
"ok": True,
"extension": ext_id,
"is_wasm": _is_wasm_extension(ext_id, ext),
"permissions": permissions,
"missing_permissions": missing,
"payment_tags": payment_tags,
}
@extension_router.get("/wasm/manifest")
async def api_wasm_manifest(
account_id: AccountId = Depends(check_account_id_exists),
) -> dict:
return WASM_HOST_MANIFEST
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
async def api_activate_extension(ext_id: str) -> SimpleStatus: async def api_activate_extension(ext_id: str) -> SimpleStatus:
try: try:
@@ -519,6 +743,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
installed_exts: list[InstallableExtension] = await get_installed_extensions( installed_exts: list[InstallableExtension] = await get_installed_extensions(
conn=conn conn=conn
) )
user_exts = await get_user_extensions(account_id.id, conn=conn)
all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)] all_ext_ids = [ext.code for ext in await get_valid_extensions(conn=conn)]
inactive_extensions = [ inactive_extensions = [
e.id for e in await get_installed_extensions(active=False, conn=conn) e.id for e in await get_installed_extensions(active=False, conn=conn)
@@ -526,6 +751,7 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
db_versions = await get_db_versions(conn=conn) db_versions = await get_db_versions(conn=conn)
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
user_exts_map = {e.extension: e for e in user_exts}
installable_exts = await InstallableExtension.get_installable_extensions( installable_exts = await InstallableExtension.get_installable_extensions(
post_refresh_cache=account_id.is_admin_id post_refresh_cache=account_id.is_admin_id
@@ -554,6 +780,10 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
e.name = installed_ext.name e.name = installed_ext.name
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
e.icon = installed_ext.icon e.icon = installed_ext.icon
if e.meta and not e.meta.permissions:
e.meta.permissions = _load_permissions_from_config(e.id)
if e.meta and not e.meta.payment_tags:
e.meta.payment_tags = _load_payment_tags_from_config(e.id)
extension_data = [ extension_data = [
{ {
@@ -571,6 +801,27 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
"isAvailable": ext.id in all_ext_ids, "isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions, "isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isActive": ext.id not in inactive_extensions, "isActive": ext.id not in inactive_extensions,
"permissions": (
[dict(p) for p in ext.meta.permissions]
if ext.meta and ext.meta.permissions
else [dict(p) for p in _load_permissions_from_config(ext.id)]
),
"paymentTags": (
ext.meta.payment_tags
if ext.meta and ext.meta.payment_tags
else _load_payment_tags_from_config(ext.id)
),
"kvSchema": _load_kv_schema_from_config(ext.id),
"grantedPermissions": (
user_ext.extra.granted_permissions
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"grantedPaymentTags": (
user_ext.extra.granted_payment_tags
if (user_ext := user_exts_map.get(ext.id)) and user_ext.extra
else []
),
"latestRelease": ( "latestRelease": (
dict(ext.meta.latest_release) dict(ext.meta.latest_release)
if ext.meta and ext.meta.latest_release if ext.meta and ext.meta.latest_release
@@ -598,6 +849,162 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
return extension_data return extension_data
def _load_permissions_from_config(ext_id: str) -> list[ExtensionPermission]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
permissions = config_json.get("permissions", [])
return [ExtensionPermission(**p) for p in permissions]
except Exception:
return []
def _load_kv_schema_from_config(ext_id: str) -> dict:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return {}
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
schema = config_json.get("kv_schema", {})
return schema if isinstance(schema, dict) else {}
except Exception:
return {}
def _load_payment_tags_from_config(ext_id: str) -> list[str]:
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return []
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
tags = config_json.get("payment_tags", [])
return tags if isinstance(tags, list) else []
except Exception:
return []
def _merge_permissions(required: list[str], granted: list[str]) -> list[str]:
merged = set()
for perm in required or []:
merged.add(perm)
for perm in granted or []:
merged.add(perm)
return list(merged)
def _ensure_payment_tags_allowed(required: list[str], granted: list[str]) -> None:
if not required and granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"This extension does not declare any payment tags.",
)
if required and not granted:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
"Select at least one payment tag before enabling this extension.",
)
if not required or not granted:
return
invalid = [t for t in granted if t not in required]
if invalid:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Invalid payment tags requested: {', '.join(invalid)}",
)
def _ensure_api_permissions_available(request: Request, permissions: list[str]) -> None:
if not permissions:
return
missing = []
for perm in permissions:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
if missing:
raise HTTPException(
HTTPStatus.BAD_REQUEST,
f"Permissions reference missing endpoints: {', '.join(missing)}",
)
def _parse_api_permission(perm: str) -> tuple[str, str] | None:
if not perm.startswith("api."):
return None
try:
method_part, path = perm.split(":", 1)
method = method_part.replace("api.", "").upper()
except ValueError:
return None
if not path.startswith("/"):
return None
return method, path
def _missing_api_permissions(request: Request, permissions: list[str]) -> list[str]:
missing = []
for perm in permissions or []:
parsed = _parse_api_permission(perm)
if not parsed:
continue
method, path = parsed
if not _route_exists(request, method, path):
missing.append(perm)
return missing
def _route_exists(request: Request, method: str, path: str) -> bool:
scope = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
"headers": [],
}
for route in request.app.router.routes:
methods = getattr(route, "methods", None)
if methods and method not in methods:
continue
try:
match, _ = route.matches(scope)
except Exception as exc:
logger.debug(f"Route match failed for {method} {path}: {exc!s}")
continue
if match == Match.FULL:
return True
return False
def _is_wasm_extension(ext_id: str, ext: InstallableExtension) -> bool:
if ext.meta and ext.meta.extension_type == "wasm":
return True
try:
conf_path = Path(
settings.lnbits_extensions_path, "extensions", ext_id, "config.json"
)
if not conf_path.is_file():
return False
with open(conf_path, "r+") as json_file:
config_json = json.load(json_file)
return config_json.get("extension_type") == "wasm"
except Exception:
return False
@extension_router.get( @extension_router.get(
"/reviews/tags", "/reviews/tags",
dependencies=[Depends(check_account_exists)], dependencies=[Depends(check_account_exists)],
+26
View File
@@ -1,5 +1,7 @@
from fastapi import APIRouter, WebSocket from fastapi import APIRouter, WebSocket
from ..crud.wallets import get_wallet_for_key
from ..models import KeyType
from ..services import websocket_manager from ..services import websocket_manager
websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"]) websocket_router = APIRouter(prefix="/api/v1/ws", tags=["Websocket"])
@@ -11,6 +13,30 @@ async def websocket_connect(websocket: WebSocket, item_id: str) -> None:
await websocket_manager.listen(conn) await websocket_manager.listen(conn)
@websocket_router.websocket("/tag/{tag}")
async def websocket_connect_tag(websocket: WebSocket, tag: str) -> None:
api_key = websocket.headers.get("X-API-KEY") or websocket.query_params.get(
"api-key"
)
if not api_key:
await websocket.close(code=4401)
return
wallet = await get_wallet_for_key(api_key)
if not wallet:
await websocket.close(code=4404)
return
key_type = KeyType.admin if wallet.adminkey == api_key else KeyType.invoice
if key_type not in {KeyType.admin, KeyType.invoice}:
await websocket.close(code=4403)
return
item_id = f"tag:{wallet.id}:{tag}"
conn = await websocket_manager.connect(item_id, websocket)
await websocket_manager.listen(conn)
@websocket_router.post("/{item_id}") @websocket_router.post("/{item_id}")
async def websocket_update_post(item_id: str, data: str): async def websocket_update_post(item_id: str, data: str):
try: try:
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.extension_host import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.runner import * # noqa: F401,F403
+1
View File
@@ -0,0 +1 @@
from lnbits.extensions.wasm.wasm_host.service import * # noqa: F401,F403
+3 -4
View File
@@ -292,13 +292,13 @@ class ThemesSettings(LNbitsSettings):
lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png") lnbits_qr_logo: str = Field(default="/static/images/favicon_qr_logo.png")
lnbits_apple_touch_icon: str | None = Field(default=None) lnbits_apple_touch_icon: str | None = Field(default=None)
lnbits_default_reaction: str = Field(default="confettiBothSides") lnbits_default_reaction: str = Field(default="confettiBothSides")
lnbits_default_theme: str = Field(default="bitcoin") lnbits_default_theme: str = Field(default="salvador")
lnbits_default_border: str = Field(default="hard-border") lnbits_default_border: str = Field(default="hard-border")
lnbits_default_gradient: bool = Field(default=True) lnbits_default_gradient: bool = Field(default=True)
lnbits_default_bgimage: str | None = Field(default=None) lnbits_default_bgimage: str | None = Field(default=None)
lnbits_default_dark: bool = Field(default=True) lnbits_default_dark: bool = Field(default=True)
lnbits_default_card_rounded: bool = Field(default=True) lnbits_default_card_rounded: bool = Field(default=False)
lnbits_default_card_gradient: bool = Field(default=True) lnbits_default_card_gradient: bool = Field(default=False)
lnbits_default_card_shadow: bool = Field(default=False) lnbits_default_card_shadow: bool = Field(default=False)
@@ -610,7 +610,6 @@ class SparkL2FundingSource(LNbitsSettings):
spark_l2_network: str = Field(default="MAINNET") spark_l2_network: str = Field(default="MAINNET")
spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765") spark_l2_external_endpoint: str | None = Field(default="http://localhost:8765")
spark_l2_external_api_key: str | None = Field(default=None) spark_l2_external_api_key: str | None = Field(default=None)
spark_l2_mnemonic: str | None = Field(default=None)
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0) spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
spark_l2_pay_poll_ms: int = Field(default=500, ge=0) spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0) spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+84 -90
View File
@@ -6,7 +6,7 @@ window.localisation.en = {
funding: 'Funding', funding: 'Funding',
users: 'Users', users: 'Users',
audit: 'Audit', audit: 'Audit',
api_watch: 'API Watch', api_watch: 'Api Watch',
apps: 'Apps', apps: 'Apps',
channels: 'Channels', channels: 'Channels',
transactions: 'Transactions', transactions: 'Transactions',
@@ -271,16 +271,12 @@ window.localisation.en = {
update: 'Update', update: 'Update',
update_available: 'Update {version} available!', update_available: 'Update {version} available!',
funding_sources: 'Funding Sources', funding_sources: 'Funding Sources',
funding_source: 'Funding Source',
requires_server_restart:
'Changing these settings requires a server restart to take effect.',
funding_source_info: 'Select the active funding wallet',
latest_update: 'You are on the latest version {version}.', latest_update: 'You are on the latest version {version}.',
notifications: 'Notifications', notifications: 'Notifications',
notifications_configure: 'Configure Notifications', notifications_configure: 'Configure Notifications',
notifications_nostr_config: 'Nostr Configuration', notifications_nostr_config: 'Nostr Configuration',
notifications_enable_nostr: 'Enable Nostr', notifications_enable_nostr: 'Enable Nostr',
notifications_enable_nostr_desc: 'Send notifications over Nostr', notifications_enable_nostr_desc: 'Send notfications over Nostr',
notifications_nostr_private_key: 'Nostr Private Key', notifications_nostr_private_key: 'Nostr Private Key',
notifications_nostr_private_key_desc: notifications_nostr_private_key_desc:
'Private key (hex or nsec) to sign the messages sent to Nostr', 'Private key (hex or nsec) to sign the messages sent to Nostr',
@@ -293,7 +289,7 @@ window.localisation.en = {
notifications_telegram_config: 'Telegram Configuration', notifications_telegram_config: 'Telegram Configuration',
notifications_enable_telegram: 'Enable Telegram', notifications_enable_telegram: 'Enable Telegram',
notifications_enable_telegram_desc: 'Send notifications over Telegram', notifications_enable_telegram_desc: 'Send notfications over Telegram',
notifications_telegram_access_token: 'Access Token', notifications_telegram_access_token: 'Access Token',
notifications_telegram_access_token_desc: 'Access token for the bot', notifications_telegram_access_token_desc: 'Access token for the bot',
notifications_chat_id: 'Telegram Chat ID', notifications_chat_id: 'Telegram Chat ID',
@@ -322,15 +318,15 @@ window.localisation.en = {
notification_settings_update: 'Settings updated', notification_settings_update: 'Settings updated',
notification_settings_update_desc: notification_settings_update_desc:
'Send a notification when server settings have been updated', 'Notify when server settings have been updated',
notification_server_start_stop: 'Server Start/Stop', notification_server_start_stop: 'Server Start/Stop',
notification_server_start_stop_desc: notification_server_start_stop_desc:
'Send a notification when the server has been started/stopped', 'Notify when the server has been started/stopped',
notification_watchdog_limit: 'Watchdog Limit Notification', notification_watchdog_limit: 'Watchdog Limit Notification',
notification_watchdog_limit_desc: notification_watchdog_limit_desc:
'Send a notification when the watchdog limit has been reached (does not affect the funding source)', 'Notify when the watchdog limit has been reached (does not affect the funding source)',
notification_server_status: 'Server Status', notification_server_status: 'Server Status',
notification_server_status_desc: notification_server_status_desc:
@@ -338,31 +334,29 @@ window.localisation.en = {
notification_incoming_payment: 'Incoming Payments', notification_incoming_payment: 'Incoming Payments',
notification_incoming_payment_desc: notification_incoming_payment_desc:
'Send a notification when a wallet has received a payment above the specified amount (sats)', 'Notify when a wallet has received a payment above the specified amount (sats)',
notification_outgoing_payment: 'Outgoing Payments', notification_outgoing_payment: 'Outgoing Payments',
notification_outgoing_payment_desc: notification_outgoing_payment_desc:
'Send a notification when a wallet has sent a payment above the specified amount (sats)', 'Notify when a wallet has sent a payment above the specified amount (sats)',
notification_credit_debit: 'Credit / Debit', notification_credit_debit: 'Credit / Debit',
notification_credit_debit_desc: notification_credit_debit_desc:
'Send a notification when a wallet has been credited/debited by the superuser', 'Notify when a wallet has been credited/debited by the superuser',
notification_balance_delta_changed: 'Balance Delta Changed', notification_balance_delta_changed: 'Balance Delta Changed',
notification_balance_delta_changed_desc: notification_balance_delta_changed_desc:
'Send a notification when the difference between the node balance and the LNbits balance has changed by more than the specified amount (in sats). Set to 0 to disable. This runs every minute.', 'Notify when the difference between the node balance and the LNbits balance has changed by more than the specified amount (in sats). Set to 0 to disable. This runs every minute.',
watchdog_introduction: enable_watchdog: 'Enable Watchdog Switch',
"Watchdog is a feature that allows you to automatically switch the LNbits funding source to VoidWallet if your node balance is lower than the LNbits balance by a certain threshold. This can help prevent overspending and keep your node's funds safe.",
enable_watchdog: 'Enable Watchdog',
enable_watchdog_desc: enable_watchdog_desc:
'You will need to re-enable this manually after an update.', 'If enabled it will change your funding source to VoidWallet automatically if your balance is lower than the LNbits balance. You will need to enable manually after an update.',
watchdog_interval: 'Watchdog Check Interval', watchdog_interval: 'Watchdog Interval',
watchdog_interval_desc: watchdog_interval_desc:
'How often LNbits should check for a killswitch signal in the watchdog threshold delta value [node_balance - lnbits_balance] (in minutes).', 'How often the background task should check for a killswitch signal in the watchdog delta [node_balance - lnbits_balance] (in minutes).',
watchdog_delta: 'Watchdog Threshold Delta', watchdog_delta: 'Watchdog Delta',
watchdog_delta_desc: watchdog_delta_desc:
"The LNbit's > Node balance delta threshold. If this threshold is exceeded, the funding source is changed to VoidWallet.", 'Limit before killswitch changes funding source to VoidWallet [lnbits_balance - node_balance > delta]',
status: 'Status', status: 'Status',
notification_source: 'Notification Source', notification_source: 'Notification Source',
notification_source_label: notification_source_label:
@@ -373,12 +367,12 @@ window.localisation.en = {
releases: 'Releases', releases: 'Releases',
watchdog: 'Watchdog', watchdog: 'Watchdog',
server_logs: 'Server Logs', server_logs: 'Server Logs',
ip_blocker: 'IP Blacklist/Whitelist', ip_blocker: 'IP Blocker',
security: 'Security', security: 'Security',
security_tools: 'Security Tools', security_tools: 'Security tools',
block_access_hint: 'Block access by IP', block_access_hint: 'Block access by IP',
allow_access_hint: 'Allow access by IP (will override blocked IPs)', allow_access_hint: 'Allow access by IP (will override blocked IPs)',
enter_ip: 'Enter an IP address and press enter', enter_ip: 'Enter IP and hit enter',
rate_limiter: 'Rate Limiter', rate_limiter: 'Rate Limiter',
callback_url_rules: 'Callback URL Rules', callback_url_rules: 'Callback URL Rules',
enter_callback_url_rule: 'Enter URL rule as regex and hit enter', enter_callback_url_rule: 'Enter URL rule as regex and hit enter',
@@ -392,16 +386,14 @@ window.localisation.en = {
wallet_max_ballance: 'Wallet max balance in sats (0 to disable)', wallet_max_ballance: 'Wallet max balance in sats (0 to disable)',
wallet_limit_secs_between_trans: wallet_limit_secs_between_trans:
'Min secs between transactions per wallet (0 to disable)', 'Min secs between transactions per wallet (0 to disable)',
only_incoming_payments_allowed: 'Allow incoming payments only', only_incoming_payments_allowed: 'Only incoming payments allowed',
disable_outgoing_payments: 'Disable outgoing payments', disable_outgoing_payments: 'Disable outgoing payments',
number_of_requests: 'Number of requests to allow', number_of_requests: 'Number of requests',
number_of_requests_hint:
'Number of requests to allow per "time unit" for the rate limiter. Set to 0 to disable.',
time_unit: 'Time unit', time_unit: 'Time unit',
minute: 'Minute', minute: 'minute',
settings: 'Settings', settings: 'Settings',
second: 'Second', second: 'second',
hour: 'Hour', hour: 'hour',
disable_server_log: 'Disable Server Log', disable_server_log: 'Disable Server Log',
enable_server_log: 'Enable Server Log', enable_server_log: 'Enable Server Log',
coming_soon: 'Feature coming soon', coming_soon: 'Feature coming soon',
@@ -541,30 +533,33 @@ window.localisation.en = {
create_ticker_converter: 'Create Currency Ticker Converter', create_ticker_converter: 'Create Currency Ticker Converter',
enable_audit: 'Enable Audit', enable_audit: 'Enable Audit',
recommended: 'Recommended', recommended: 'Recommended',
audit_desc: 'Log HTTP requests according to the filters specified below.', audit_desc: 'Record HTTP requests according with the specified filters',
audit_record_req: 'Log Request Body', audit_record_req: 'Record Request Body',
audit_record_warning: 'Warning!', audit_record_warning: 'Warning: ',
audit_record_req_warning_1: 'Sensitive data (like passwords) will be logged.', audit_record_req_warning_1:
audit_record_req_warning_2: 'confidential data (like passwords) will be logged.',
'The request body can be large. This can fill up your logs quickly.', audit_record_req_warning_2: 'the request body can have large size.',
audit_record_use: 'Use this with caution!', audit_record_use: 'Use it with caution.',
audit_ip: 'Log IP Address', audit_ip: 'Record IP Address',
audit_ip_desc: 'Log the IP address of users making requests to LNbits.', audit_ip_desc: 'Record the IP address of the client',
audit_path_params: 'Log Path Parameters', audit_path_params: 'Record Path Parameters',
audit_query_params: 'Log Query Parameters', audit_query_params: 'Record Query Parameters',
audit_http_methods: 'Include HTTP Methods', audit_http_methods: 'Include HTTP Methods',
audit_http_methods_hint: audit_http_methods_hint:
'List of HTTP methods to be logged. No value means all methods will be logged.', 'List of HTTP methods to be included. Empty lists means all.',
audit_http_methods_label: 'HTTP Methods to Log', audit_http_methods_label: 'HTTP Methods',
audit_resp_codes: 'Include HTTP Response Codes',
audit_resp_codes_hint: audit_resp_codes_hint:
'List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*', 'List of HTTP codes to be included (regex match). Empty lists means all. Eg: 4.*, 5.*',
audit_resp_codes_label: 'HTTP Response Codes to Log (regex)', audit_resp_codes_label: 'HTTP Response code (regex)',
audit_paths: 'Include Paths',
audit_paths_hint: audit_paths_hint:
'List of paths to be included (regex match). Empty list means all.', 'List of paths to be included (regex match). Empty list means all.',
audit_paths_label: 'HTTP Paths to Log (regex)', audit_paths_label: 'HTTP Path (regex)',
audit_paths_exclude: 'Exclude Paths',
audit_paths_exclude_hint: audit_paths_exclude_hint:
'List of paths to be excluded (regex match). Empty list means none.', 'List of paths to be excluded (regex match). Empty list means none.',
audit_paths_exclude_label: 'HTTP Paths to Exclude from Logging (regex)', audit_paths_exclude_label: 'HTTP Path (regex)',
exchange_providers: 'Exchange Providers', exchange_providers: 'Exchange Providers',
admin_extensions: 'Admin Extensions', admin_extensions: 'Admin Extensions',
admin_extensions_label: 'Admin extensions', admin_extensions_label: 'Admin extensions',
@@ -584,16 +579,16 @@ window.localisation.en = {
misc_disable_extensions_builder_label: misc_disable_extensions_builder_label:
'Enable Extensions Builder for non admin users.', 'Enable Extensions Builder for non admin users.',
misc_hide_api: 'Hide API', misc_hide_api: 'Hide API',
misc_hide_api_label: 'Hides wallet API, extensions can choose to honor', misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
wallets_management: 'Wallets Management', wallets_management: 'Wallets Management',
funding_source_info: 'Funding Source Information', funding_source_info: 'Funding Source Info',
funding_source: 'Funding source: {wallet_class}', funding_source: 'Funding Source: {wallet_class}',
node_balance: 'Node balance: {balance} sats', node_balance: 'Node Balance: {balance} sats',
lnbits_balance: 'LNbits balance: {balance} sats', lnbits_balance: 'LNbits Balance: {balance} sats',
funding_reserve_percent: 'Funding reserve percentage: {percent} %', funding_reserve_percent: 'Reserve Percent: {percent} %',
node_management: 'Node Management', node_management: 'Node Management',
node_management_not_supported: node_management_not_supported:
'Node management is not supported by the active funding source', 'Node Management not supported by active funding source',
toggle_node_ui: 'Node UI', toggle_node_ui: 'Node UI',
toggle_public_node_ui: 'Public Node UI', toggle_public_node_ui: 'Public Node UI',
toggle_transactions_node_ui: 'Transactions Tab (Disable on large CLN nodes)', toggle_transactions_node_ui: 'Transactions Tab (Disable on large CLN nodes)',
@@ -609,15 +604,16 @@ window.localisation.en = {
payment_wait_time_tooltip: payment_wait_time_tooltip:
'Controls how long LNbits waits for an outgoing payment attempt to confirm before marking it as pending. Higher values help when paying slow-settling invoices (e.g., HODL invoices, Boltz). The payment will be rechecked later and updated automatically or manually.', 'Controls how long LNbits waits for an outgoing payment attempt to confirm before marking it as pending. Higher values help when paying slow-settling invoices (e.g., HODL invoices, Boltz). The payment will be rechecked later and updated automatically or manually.',
server_management: 'Server Management', server_management: 'Server Management',
base_url_label: 'Base URL of the server', base_url: 'Base URL',
base_url_label: 'Static/Base url for the server',
authentication: 'Authentication', authentication: 'Authentication',
auth_token_expiry_label: 'Token expiry (minutes)', auth_token_expiry_label: 'Token expire minutes',
auth_token_expiry_hint: 'Time in minutes until the token expires', auth_token_expiry_hint: 'Time in minutes until the token expires',
auth_authentication_cache_label: 'Cache time (minutes)', auth_authentication_cache_label: 'Cache time (minutes)',
auth_authentication_cache_hint: auth_authentication_cache_hint:
'Time in minutes to cache successful authentication (0 to disable)', 'Time in minutes to cache successful authentication (0 to disable)',
auth_allowed_methods_label: 'Allowed authorization methods', auth_allowed_methods_label: 'Allowed authorization methods',
auth_allowed_methods_hint: 'Select allowed authorization methods', auth_allowed_methods_hint: 'Select authorization methods',
auth_nostr_label: 'Nostr Request URL', auth_nostr_label: 'Nostr Request URL',
auth_nostr_hint: 'Absolute URL that the clients will use to login.', auth_nostr_hint: 'Absolute URL that the clients will use to login.',
auth_google_ci_label: 'Google Client ID', auth_google_ci_label: 'Google Client ID',
@@ -637,27 +633,31 @@ window.localisation.en = {
auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)', auth_keycloak_custom_icon_label: 'Keycloak Custom Icon (URL)',
currency_settings: 'Currency Settings', currency_settings: 'Currency Settings',
allowed_currencies: 'Allowed Currencies', allowed_currencies: 'Allowed Currencies',
allowed_currencies_hint: allowed_currencies_hint: 'Limit the number of available fiat currencies',
'Set the allowed fiat currencies for the exchange features', default_account_currency: 'Default Account Currency',
default_account_currency: 'Default Accounting Currency', default_account_currency_hint: 'Default currency for accounting',
default_account_currency_hint:
'The default currency to use for accounting features.',
min_incoming_payment_amount: 'Min Incoming Payment Amount', min_incoming_payment_amount: 'Min Incoming Payment Amount',
min_incoming_payment_amount_desc: min_incoming_payment_amount_desc:
'Minimum amount allowed for generating an invoice', 'Minimum amount allowed for generating an invoice',
max_incoming_payment_amount: 'Maximum Incoming Payment Amount', max_incoming_payment_amount: 'Max Incoming Payment Amount',
max_incoming_payment_amount_desc: max_incoming_payment_amount_desc:
'Maximum amount allowed when generating an invoice', 'Maximum amount allowed for generating an invoice',
max_outgoing_payment_amount: 'Maximum Outgoing Payment Amount', max_outgoing_payment_amount: 'Max Outgoing Payment Amount',
max_outgoing_payment_amount_desc: max_outgoing_payment_amount_desc:
'Maximum amount allowed when making a payment', 'Maximum amount allowed for making a payment',
service_fees: 'Service Fees',
service_fee: 'Service Fee', service_fee: 'Service Fee',
service_fee_label: 'Service Fee Charged Per Transaction', service_fee_label: 'Service fee (%)',
service_fee_max_label: 'Maximum Service Fee Limit', service_fee_hint: 'Fee charged per tx (%)',
fee_wallet_label: 'Service Fee Wallet ID', service_fee_max: 'Service Fee Max',
fee_wallet_hint: 'The ID of the wallet to which to send service funds', service_fee_max_label: 'Service fee max (sats)',
disable_fee: 'Disable Service Fees for Internal Payments', service_fee_max_hint: 'Max service fee to charge in (sats)',
fee_wallet: 'Fee Wallet',
fee_wallet_label: 'Fee wallet (wallet ID)',
fee_wallet_hint: 'Wallet ID to send funds to',
disable_fee: 'Disable Fee',
disable_fee_internal: 'Disable Service Fee for Internal Payments',
disable_fee_internal_desc:
'Disable Service Fee for Internal Lightning Payments',
ui_management: 'UI Management', ui_management: 'UI Management',
ui_site_title: 'Site Title', ui_site_title: 'Site Title',
ui_changing_remove_lnbits_elements: ui_changing_remove_lnbits_elements:
@@ -670,7 +670,6 @@ window.localisation.en = {
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML', ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
ui_default_wallet_name: 'Default Wallet Name', ui_default_wallet_name: 'Default Wallet Name',
ui_default_theme: 'Default Theme', ui_default_theme: 'Default Theme',
wallet_featured_button_title: 'Wallet - Featured Button',
wallet_featured_button_label: 'Wallet Featured Button Label', wallet_featured_button_label: 'Wallet Featured Button Label',
wallet_featured_button_label_hint: wallet_featured_button_label_hint:
'Show featured button on the wallet homepage', 'Show featured button on the wallet homepage',
@@ -679,38 +678,33 @@ window.localisation.en = {
'On click the button will open this URL. Leave empty to hide the button.', 'On click the button will open this URL. Leave empty to hide the button.',
wallet_featured_button_icon: 'Featured Button Icon', wallet_featured_button_icon: 'Featured Button Icon',
wallet_featured_button_icon_hint: wallet_featured_button_icon_hint:
'Icon shown on the featured button (Quasar icon name e.g. "bolt")', 'Icon shown on the featured button (check quasar icons)',
lnbits_wallet: 'LNbits wallet', lnbits_wallet: 'LNbits wallet',
denomination: 'Denomination', denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token', denomination_hint: 'The name for the FakeWallet token',
denomination_error: 'Denomination must be 3 characters, or `sats`', denomination_error: 'Denomination must be 3 characters, or `sats`',
ui_qr_code_logo: 'QR Code/Favicon Logo', ui_qr_code_logo: 'QR Code/Favicon Logo',
ui_qr_code_logo_hint: 'QR code icon and favicon logo URL', ui_qr_code_logo_hint: 'QR code and favicon logo url',
ui_apple_touch_icon: 'Apple Touch Icon', ui_apple_touch_icon: 'Apple Touch Icon',
ui_apple_touch_icon_hint: 'Apple touch icon URL', ui_apple_touch_icon_hint: 'Apple touch icon url',
ui_custom_image: 'Custom Image', ui_custom_image: 'Custom Image',
ui_custom_image_label: 'URL to custom image', ui_custom_image_label: 'URL to custom image',
ui_custom_image_hint: ui_custom_image_hint: 'Image showed at homepage/login',
'This image is shown on the LNbits homepage and login screen.', ui_custom_badge: 'Custom Badge',
ui_custom_badge_title: 'Custom Badge Settings',
ui_custom_badge_desc: 'Show a custom badge in the header of LNbits',
ui_custom_badge: 'Custom Badge Text',
ui_custom_badge_label: "Custom Badge 'USE WITH CAUTION'", ui_custom_badge_label: "Custom Badge 'USE WITH CAUTION'",
ui_custom_badge_color_label: 'Custom Badge Color', ui_custom_badge_color_label: 'Custom Badge Color',
themes: 'Themes', themes: 'Themes',
themes_hint: 'Choose themes available for users', themes_hint: 'Choose themes available for users',
custom_logo: 'Custom Logo', custom_logo: 'Custom Logo',
custom_logo_hint: 'URL to logo image', custom_logo_hint: 'URL to logo image',
ad_space_section_title: 'Advertisement Space', ad_space_title: 'Ad Space Title',
ad_space_section_desc: ad_space_title_label: 'Supported by',
'Configure the advertisement space on the wallet sidebar.',
ad_space_title: 'Advertisement Space Title',
ad_space_title_hint: 'Title shown above the advertisement space',
ad_slots: 'Advertisement Slots', ad_slots: 'Advertisement Slots',
ad_slots_hint: ad_slots_hint:
'Advertisement image filepaths in CSV format, extensions can choose to honor. Format: url;img_light_url;img_dark_url, url..', 'Ad url and image filepaths in CSV format, extensions can choose to honor',
ads_enabled: 'Enable Advertisement', ad_slots_label: 'url;img_light_url;img_dark_url, url...',
ads_disabled: 'Disabled Advertisement', ads_enabled: 'Ads Enabled',
ads_disabled: 'Ads Disabled',
user_management: 'User Management', user_management: 'User Management',
admin_users: 'Admin Users', admin_users: 'Admin Users',
admin_users_hint: 'Users with admin privileges', admin_users_hint: 'Users with admin privileges',
@@ -234,12 +234,7 @@ window.app.component('lnbits-admin-funding-sources', {
{ {
spark_l2_external_endpoint: { spark_l2_external_endpoint: {
label: 'External Sidecar Endpoint', label: 'External Sidecar Endpoint',
hint: 'Make sure to also specify the API key if your sidecar requires authentication.', hint: 'If specified then this endpoint will be used instead of the internal sidecar. Make sure to also specify the API key if your sidecar requires authentication.',
value: ''
},
spark_l2_mnemonic: {
label: 'External Sidecar Mnemonic',
hint: 'Mnemonic for the Spark wallet on the external sidecar. Required if the side car does not have its own mnemonic.',
value: '' value: ''
}, },
spark_l2_external_api_key: { spark_l2_external_api_key: {
@@ -17,13 +17,6 @@ window.app.component('lnbits-manage-wallet-list', {
} else { } else {
this.activeWalletId = null this.activeWalletId = null
} }
},
'g.user.wallets': {
handler() {
this.paymentEvents()
},
deep: true,
immediate: true
} }
}, },
created() { created() {
+169 -3
View File
@@ -28,6 +28,14 @@ window.PageExtensions = {
paylinkWebsocket: null, paylinkWebsocket: null,
searchToggle: false, searchToggle: false,
reviewsUrl: null, reviewsUrl: null,
permissionsDialog: {
show: false,
extension: null,
checked: [],
missing: [],
tags: [],
tagOptions: []
},
reviewsDialog: { reviewsDialog: {
show: false, show: false,
extension: null, extension: null,
@@ -92,6 +100,20 @@ window.PageExtensions = {
this.filterExtensions(this.searchTerm, val) this.filterExtensions(this.searchTerm, val)
} }
}, },
computed: {
permissionsAllChecked() {
const ext = this.permissionsDialog.extension
if (!ext || !Array.isArray(ext.permissions)) return true
const required = ext.permissions.map(p => p.id)
return required.every(p => this.permissionsDialog.checked.includes(p))
},
permissionsHasMissingEndpoints() {
return (
this.permissionsDialog.missing &&
this.permissionsDialog.missing.length > 0
)
}
},
methods: { methods: {
filterExtensions(term, tab) { filterExtensions(term, tab) {
// Filter the extensions list // Filter the extensions list
@@ -241,18 +263,49 @@ window.PageExtensions = {
}) })
}, },
async enableExtensionForUser(extension) { async enableExtensionForUser(extension) {
if (extension.permissions && extension.permissions.length) {
if (!extension._grantedPermissions) {
Quasar.Notify.create({
type: 'warning',
message: 'Save permissions before enabling this extension.'
})
return
}
}
if (extension.paymentTags && extension.paymentTags.length) {
if (
!extension._grantedPaymentTags ||
!extension._grantedPaymentTags.length
) {
Quasar.Notify.create({
type: 'warning',
message: 'Select payment tags before enabling this extension.'
})
return
}
}
if (extension.isPaymentRequired) { if (extension.isPaymentRequired) {
this.showPayToEnable(extension) this.showPayToEnable(extension)
return return
} }
this.enableExtension(extension) this.enableExtension(extension)
}, },
async enableExtension(extension) { async enableExtension(extension, permissions) {
const granted =
permissions ||
extension._grantedPermissions ||
(extension.permissions ? extension.permissions.map(p => p.id) : [])
const tags =
extension._grantedPaymentTags ||
(extension.paymentTags ? extension.paymentTags.slice() : [])
LNbits.api LNbits.api
.request( .request(
'PUT', 'PUT',
`/api/v1/extension/${extension.id}/enable`, `/api/v1/extension/${extension.id}/enable`,
this.g.user.wallets[0].adminkey this.g.user.wallets[0].adminkey,
granted.length || tags.length
? {permissions: granted, payment_tags: tags}
: null
) )
.then(response => { .then(response => {
this.g.user.extensions = this.g.user.extensions.concat([extension.id]) this.g.user.extensions = this.g.user.extensions.concat([extension.id])
@@ -287,13 +340,116 @@ window.PageExtensions = {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
}) })
}, },
showPayToEnable(extension) { showPayToEnable(extension, permissions) {
this.selectedExtension = extension this.selectedExtension = extension
if (permissions) {
this.selectedExtension._grantedPermissions = permissions
}
this.selectedExtension.payToEnable.paidAmount = this.selectedExtension.payToEnable.paidAmount =
extension.payToEnable.amount extension.payToEnable.amount
this.selectedExtension.payToEnable.showQRCode = false this.selectedExtension.payToEnable.showQRCode = false
this.showPayToEnableDialog = true this.showPayToEnableDialog = true
}, },
openPermissionsDialog(extension) {
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
this.permissionsDialog.show = true
},
cancelPermissionsDialog() {
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
},
async openPermissionsForExtension(extension) {
if (!extension.permissions || !extension.permissions.length) {
Quasar.Notify.create({
type: 'warning',
message: 'This extension does not declare permissions.'
})
return
}
this.permissionsDialog.extension = extension
this.permissionsDialog.checked = extension._grantedPermissions
? extension._grantedPermissions.slice()
: extension.grantedPermissions
? extension.grantedPermissions.slice()
: []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = extension._grantedPaymentTags
? extension._grantedPaymentTags.slice()
: extension.grantedPaymentTags
? extension.grantedPaymentTags.slice()
: []
this.permissionsDialog.tagOptions = []
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/capabilities`,
this.g.user.wallets[0].adminkey
)
if (data && Array.isArray(data.missing_permissions)) {
this.permissionsDialog.missing = data.missing_permissions
}
if (data && Array.isArray(data.payment_tags)) {
this.permissionsDialog.tagOptions = data.payment_tags
}
} catch (err) {
LNbits.utils.notifyApiError(err)
}
this.permissionsDialog.show = true
},
async confirmPermissionsDialog() {
const ext = this.permissionsDialog.extension
const granted = this.permissionsDialog.checked.slice()
const tags = this.permissionsDialog.tags.slice()
this.permissionsDialog.show = false
this.permissionsDialog.extension = null
this.permissionsDialog.checked = []
const missing = this.permissionsDialog.missing || []
this.permissionsDialog.missing = []
this.permissionsDialog.tags = []
this.permissionsDialog.tagOptions = []
if (missing.length) {
Quasar.Notify.create({
type: 'negative',
message: 'Missing API endpoints for one or more permissions.'
})
return
}
if (!ext) return
ext._grantedPermissions = granted
ext.grantedPermissions = granted
ext._grantedPaymentTags = tags
ext.grantedPaymentTags = tags
try {
await LNbits.api.request(
'PUT',
`/api/v1/extension/${ext.id}/permissions`,
this.g.user.wallets[0].adminkey,
{permissions: granted, payment_tags: tags}
)
Quasar.Notify.create({
type: 'positive',
message: 'Permissions saved.'
})
} catch (err) {
LNbits.utils.notifyApiError(err)
}
},
updatePayToInstallData(extension) { updatePayToInstallData(extension) {
LNbits.api LNbits.api
.request( .request(
@@ -832,6 +988,16 @@ window.PageExtensions = {
async fetchAllExtensions() { async fetchAllExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`) const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
data.forEach(ext => {
if (ext.grantedPermissions && ext.grantedPermissions.length) {
ext._grantedPermissions = ext.grantedPermissions
}
if (ext.grantedPaymentTags && ext.grantedPaymentTags.length) {
ext._grantedPaymentTags = ext.grantedPaymentTags
}
const schema = ext.kvSchema || ext.kv_schema || {}
ext.kvSchemaKeys = Object.keys(schema)
})
return data return data
} catch (error) { } catch (error) {
console.warn(error) console.warn(error)
-16
View File
@@ -131,22 +131,6 @@ window._lnbitsUtils = {
currency: currency || 'sat' currency: currency || 'sat'
}).format(value) }).format(value)
}, },
getCurrencySymbol(currency) {
const code = (currency || '').toUpperCase()
if (code === 'BTC' || code === 'XBT' || code === 'SAT' || code === 'SATS') {
return '₿'
}
try {
const parts = new Intl.NumberFormat(window.i18n.global.locale, {
style: 'currency',
currency: code
}).formatToParts(0)
const symbolPart = parts.find(part => part.type === 'currency')
return symbolPart?.value || code || '¤'
} catch (e) {
return code || '¤'
}
},
formatSat(value) { formatSat(value) {
return new Intl.NumberFormat(window.i18n.global.locale).format(value) return new Intl.NumberFormat(window.i18n.global.locale).format(value)
}, },
+5
View File
@@ -96,6 +96,11 @@ def register_invoice_listener(send_chan: asyncio.Queue, name: str | None = None)
invoice_listeners[name] = send_chan invoice_listeners[name] = send_chan
def unregister_invoice_listener(name: str) -> None:
if name in invoice_listeners:
invoice_listeners.pop(name, None)
internal_invoice_queue: asyncio.Queue = asyncio.Queue(0) internal_invoice_queue: asyncio.Queue = asyncio.Queue(0)
@@ -3,6 +3,9 @@
<h6 class="q-my-none q-mb-sm">Assets</h6> <h6 class="q-my-none q-mb-sm">Assets</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('max_asset_size_mb')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -15,6 +18,9 @@
</div> </div>
<div class="col-12 col-md-9"> <div class="col-12 col-md-9">
<p>
<span v-text="$t('assets_allowed_mime_types')"></span>
</p>
<q-input <q-input
filled filled
v-model.number="newAllowedAssetMimeType" v-model.number="newAllowedAssetMimeType"
@@ -50,8 +56,11 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm">Thumbnails</h6> <h6 class="q-my-none q-mb-sm">Thumbnails</h6>
<div class="row q-col-gutter-md q-my-lg"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('thumbnail_width')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -62,7 +71,10 @@
:hint="$t('thumbnail_width_desc')" :hint="$t('thumbnail_width_desc')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('thumbnail_height')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -73,7 +85,10 @@
:hint="$t('thumbnail_height_desc')" :hint="$t('thumbnail_height_desc')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('thumbnail_format')"></span>
</p>
<q-input <q-input
filled filled
v-model.number="formData.lnbits_asset_thumbnail_format" v-model.number="formData.lnbits_asset_thumbnail_format"
@@ -86,6 +101,9 @@
<h6 class="q-my-none q-mb-sm">Users</h6> <h6 class="q-my-none q-mb-sm">Users</h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-3"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('max_assets_per_user')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -97,6 +115,9 @@
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-9"> <div class="col-12 col-md-9">
<p>
<span v-text="$t('assets_no_limit_users')"></span>
</p>
<q-input <q-input
filled filled
v-model="newNoLimitUser" v-model="newNoLimitUser"
+10 -4
View File
@@ -32,7 +32,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -62,7 +62,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -83,7 +83,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -104,7 +104,7 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-3 col-sm-12 q-pr-sm">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -129,6 +129,7 @@
<q-separator class="q-mb-xl q-mt-sm"></q-separator> <q-separator class="q-mb-xl q-mt-sm"></q-separator>
<div class="row q-mb-lg"> <div class="row q-mb-lg">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_http_methods')"></span></p>
<q-select <q-select
filled filled
v-model="formData.lnbits_audit_http_methods" v-model="formData.lnbits_audit_http_methods"
@@ -147,6 +148,7 @@
></q-select> ></q-select>
</div> </div>
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_resp_codes')"></span></p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -175,6 +177,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-sm-12 q-pr-sm"> <div class="col-md-6 col-sm-12 q-pr-sm">
<p><span v-text="$t('audit_paths')"></span></p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -201,6 +204,9 @@
<br /> <br />
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-md-6 col-sm-12">
<p>
<span v-text="$t('audit_paths_exclude')"></span>
</p>
<q-input <q-input
class="q-mb-md" class="q-mb-md"
filled filled
@@ -19,7 +19,6 @@
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<q-input <q-input
class="q-mb-md"
filled filled
v-model="formData.lnbits_exchange_history_refresh_interval_seconds" v-model="formData.lnbits_exchange_history_refresh_interval_seconds"
type="number" type="number"
@@ -137,7 +137,7 @@
:hint="$t('extension_builder_manifest_url_hint')" :hint="$t('extension_builder_manifest_url_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6 q-pb-md"> <div class="col-12 col-md-6">
<p> <p>
<span v-text="$t('reviews_url')"></span> <span v-text="$t('reviews_url')"></span>
</p> </p>
@@ -128,7 +128,7 @@
</ul> </ul>
</q-card-section> </q-card-section>
</q-expansion-item> </q-expansion-item>
<q-expansion-item :label="$t('service_fees')"> <q-expansion-item :label="$t('service_fee')">
<q-card-section> <q-card-section>
<div class="row"> <div class="row">
<div class="col-md-4 col-sm-12"> <div class="col-md-4 col-sm-12">
@@ -140,6 +140,7 @@
v-model="formData.stripe_limits.service_fee_percent" v-model="formData.stripe_limits.service_fee_percent"
@update:model-value="formData.touch = null" @update:model-value="formData.touch = null"
:label="$t('service_fee_label')" :label="$t('service_fee_label')"
:hint="$t('service_fee_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-md-4 col-sm-12"> <div class="col-md-4 col-sm-12">
@@ -150,7 +151,8 @@
min="0" min="0"
v-model="formData.stripe_limits.service_max_fee_sats" v-model="formData.stripe_limits.service_max_fee_sats"
@update:model-value="formData.touch = null" @update:model-value="formData.touch = null"
:label="$t('service_fee_max_label')" :label="$t('service_fee_max')"
:hint="$t('service_fee_max_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-md-4 col-sm-12"> <div class="col-md-4 col-sm-12">
@@ -165,11 +165,6 @@
<h6 class="q-mt-lg q-mb-sm"> <h6 class="q-mt-lg q-mb-sm">
<p v-text="$t('watchdog')"></p> <p v-text="$t('watchdog')"></p>
</h6> </h6>
<div class="row q-col-gutter-md">
<div class="col-12">
<p v-text="$t('watchdog_introduction')"></p>
</div>
</div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
@@ -244,7 +239,6 @@
<q-input <q-input
filled filled
v-model="formData.lnbits_watchdog_delta" v-model="formData.lnbits_watchdog_delta"
:suffix="$t('sats')"
type="number" type="number"
/> />
</q-item-section> </q-item-section>
@@ -12,14 +12,11 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<p> <p>Active Funding<small> (Requires server restart)</small></p>
<span v-text="$t('funding_source')"></span>
<small><span v-text="$t('requires_server_restart')"></span></small>
</p>
<q-select <q-select
filled filled
v-model="formData.lnbits_backend_wallet_class" v-model="formData.lnbits_backend_wallet_class"
:hint="$t('funding_source_info')" hint="Select the active funding wallet"
:options="sortedAllowedFundingSources" :options="sortedAllowedFundingSources"
:option-label="item => getFundingSourceLabel(item)" :option-label="item => getFundingSourceLabel(item)"
></q-select> ></q-select>
@@ -1,10 +1,11 @@
<template id="lnbits-admin-security"> <template id="lnbits-admin-security">
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none">
<span v-text="$t('server_management')"></span> <span v-text="$t('server_management')"></span>
</h6> </h6>
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('base_url')"></span></p>
<q-input <q-input
filled filled
v-model.number="formData.lnbits_baseurl" v-model.number="formData.lnbits_baseurl"
@@ -16,7 +17,7 @@
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none q-mb-sm">
<span v-text="$t('authentication')"></span> <span v-text="$t('authentication')"></span>
</h6> </h6>
<div class="row q-col-gutter-sm q-mb-md"> <div class="row q-col-gutter-sm">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<q-input <q-input
filled filled
@@ -304,7 +305,6 @@
type="number" type="number"
v-model.number="formData.lnbits_rate_limit_no" v-model.number="formData.lnbits_rate_limit_no"
:label="$t('number_of_requests')" :label="$t('number_of_requests')"
:hint="$t('number_of_requests_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
+26 -10
View File
@@ -38,11 +38,14 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none">
<span v-text="$t('payments')"></span> <span v-text="$t('payments')"></span>
</h6> </h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('max_outgoing_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -54,7 +57,10 @@
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-3">
<p>
<span v-text="$t('max_incoming_payment_amount')"></span>
</p>
<q-input <q-input
filled filled
type="number" type="number"
@@ -69,7 +75,7 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none">
<span v-text="$t('wallet_limiter')"></span> <span v-text="$t('wallet_limiter')"></span>
</h6> </h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
@@ -107,32 +113,35 @@
</div> </div>
<q-separator class="q-mb-lg q-mt-sm"></q-separator> <q-separator class="q-mb-lg q-mt-sm"></q-separator>
<h6 class="q-my-none q-mb-sm"> <h6 class="q-my-none">
<span v-text="$t('service_fees')"></span> <span v-text="$t('service_fee')"></span>
</h6> </h6>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
v-model.number="formData.lnbits_service_fee" v-model.number="formData.lnbits_service_fee"
:label="$t('service_fee_label')" :label="$t('service_fee_label')"
step="0.1" step="0.1"
:suffix="'%'" :hint="$t('service_fee_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-6">
<p><span v-text="$t('service_fee_max')"></span></p>
<q-input <q-input
filled filled
type="number" type="number"
v-model.number="formData.lnbits_service_fee_max" v-model.number="formData.lnbits_service_fee_max"
:label="$t('service_fee_max_label')" :label="$t('service_fee_max_label')"
:suffix="$t('sats')" :hint="$t('service_fee_max_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('fee_wallet')"></span></p>
<q-input <q-input
filled filled
v-model="formData.lnbits_service_fee_wallet" v-model="formData.lnbits_service_fee_wallet"
@@ -142,9 +151,16 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p>
<span v-text="$t('disable_fee_internal')"></span>
</p>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label v-text="$t('disable_fee')"></q-item-label> <q-item-label v-text="$t('disable_fee')"></q-item-label>
<q-item-label
caption
v-text="$t('disable_fee_internal_desc')"
></q-item-label>
</q-item-section> </q-item-section>
<q-item-section avatar> <q-item-section avatar>
<q-toggle <q-toggle
@@ -6,7 +6,8 @@
<br /> <br />
<div> <div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_title')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -17,7 +18,8 @@
></q-input> ></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-5">
<p><span v-text="$t('ui_site_tagline')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -26,9 +28,7 @@
></q-input> ></q-input>
<br /> <br />
</div> </div>
</div> <div class="col-12 col-md-2 q-mt-xl">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<q-toggle <q-toggle
:tip="$t('ui_toggle_elements_tip')" :tip="$t('ui_toggle_elements_tip')"
v-model="formData.lnbits_show_home_page_elements" v-model="formData.lnbits_show_home_page_elements"
@@ -40,66 +40,78 @@
></q-toggle> ></q-toggle>
</div> </div>
</div> </div>
<div>
<p>
<span v-text="$t('ui_site_description')"></span>
</p>
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:hint="$t('ui_site_description_hint')"
/>
</div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12">
<q-input
v-model="formData.lnbits_site_description"
filled
type="textarea"
:label="$t('ui_site_description')"
:hint="$t('ui_site_description_hint')"
/>
</div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p>
<span v-text="$t('ui_default_wallet_name')"></span>
</p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_default_wallet_name" v-model="formData.lnbits_default_wallet_name"
:label="$t('ui_default_wallet_name')" :label="$t('lnbits_wallet')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('ui_qr_code_logo')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_qr_logo" v-model="formData.lnbits_qr_logo"
:label="$t('ui_qr_code_logo')" label="https://example.com/image.svg"
:hint="$t('ui_qr_code_logo_hint')" :hint="$t('ui_qr_code_logo_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('ui_apple_touch_icon')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_apple_touch_icon" v-model="formData.lnbits_apple_touch_icon"
:label="$t('ui_apple_touch_icon')" label="https://example.com/image.png"
:hint="$t('ui_apple_touch_icon_hint')" :hint="$t('ui_apple_touch_icon_hint')"
></q-input> ></q-input>
</div> </div>
</div> </div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-12">
<p><span v-text="$t('wallet_featured_button_title')"></span></p>
</div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p>
<span v-text="$t('wallet_featured_button_label')"></span>
</p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_wallet_featured_button_label" v-model="formData.lnbits_wallet_featured_button_label"
:label="$t('wallet_featured_button_label')" label="Loop to Onchain"
:hint="$t('wallet_featured_button_label_hint')" :hint="$t('wallet_featured_button_label_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('wallet_featured_button_url')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_wallet_featured_button_url" v-model="formData.lnbits_wallet_featured_button_url"
:label="$t('wallet_featured_button_url')" label="/boltz"
:hint="$t('wallet_featured_button_url_hint')" :hint="$t('wallet_featured_button_url_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<p><span v-text="$t('wallet_featured_button_icon')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -109,27 +121,21 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-md q-mt-md"> <div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-12"> <div class="col-12 col-md-6">
<h6 class="q-my-sm"> <p><span v-text="$t('ui_custom_badge')"></span></p>
<span v-text="$t('ui_custom_badge_title')"></span>
</h6>
<p class="q-mb-none">
<span v-text="$t('ui_custom_badge_desc')"></span>
</p>
</div>
<div class="col-12">
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-8">
<q-input <q-input
filled filled
type="text" type="text"
tip="Custom Badge" tip="Custom Badge"
v-model.trim="formData.lnbits_custom_badge" v-model.trim="formData.lnbits_custom_badge"
:label="$t('ui_custom_badge')" :label="$t('ui_custom_badge_label')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-4">
<q-select <q-select
filled filled
v-model="formData.lnbits_custom_badge_color" v-model="formData.lnbits_custom_badge_color"
@@ -139,8 +145,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-12"> <div class="col-12 col-md-6">
<h6 class="q-my-sm"><span v-text="$t('ui_custom_image')"></span></h6> <p><span v-text="$t('ui_custom_image')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
@@ -151,8 +157,10 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<br />
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('themes')"></span></p>
<q-select <q-select
filled filled
v-model="formData.lnbits_theme_options" v-model="formData.lnbits_theme_options"
@@ -164,26 +172,41 @@
<br /> <br />
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<p><span v-text="$t('custom_logo')"></span></p>
<q-input <q-input
filled filled
type="text" type="text"
v-model="formData.lnbits_custom_logo" v-model="formData.lnbits_custom_logo"
:label="$t('custom_logo')" label="https://example.com/image.png"
:hint="$t('custom_logo_hint')" :hint="$t('custom_logo_hint')"
></q-input> ></q-input>
<br /> <br />
</div> </div>
</div> </div>
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-12"> <div class="col-12 col-md-6">
<h6 class="q-my-sm"> <p><span v-text="$t('ad_space_title')"></span></p>
<span v-text="$t('ad_space_section_title')"></span> <q-input
</h6> filled
<p class="q-my-none"> type="text"
<span v-text="$t('ad_space_section_desc')"></span> v-model="formData.lnbits_ad_space_title"
</p> :label="$t('ad_space_title_label')"
></q-input>
<br />
</div> </div>
<div class="col-12 col-md-12"> <div class="col-12 col-md-6">
<p><span v-text="$t('ad_slots')"></span></p>
<q-input
class="q-mb-md"
filled
v-model="formData.lnbits_ad_space"
type="text"
:label="$t('ad_slots_label')"
:hint="$t('ad_slots_hint')"
><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input>
<q-toggle <q-toggle
v-model="formData.lnbits_ad_space_enabled" v-model="formData.lnbits_ad_space_enabled"
:label=" :label="
@@ -192,30 +215,8 @@
: $t('ads_disabled') : $t('ads_disabled')
" "
/> />
</div>
<div class="col-12 col-md-6">
<q-input
filled
type="text"
v-model="formData.lnbits_ad_space_title"
:label="$t('ad_space_title')"
:hint="$t('ad_space_title_hint')"
></q-input>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6">
<q-input
class="q-mb-md"
filled
v-model="formData.lnbits_ad_space"
type="text"
:label="$t('ad_slots')"
:hint="$t('ad_slots_hint')"
><q-tooltip>
format {url};{img-light};{img-dark},{url};{img-light};{img-dark}"
</q-tooltip>
</q-input>
</div>
</div> </div>
<div class="row q-col-gutter-md q-my-md"> <div class="row q-col-gutter-md q-my-md">
<div class="col-12" v-text="$t('ui_default_theme')"></div> <div class="col-12" v-text="$t('ui_default_theme')"></div>
@@ -27,9 +27,12 @@
></q-btn> ></q-btn>
<q-toolbar-title> <q-toolbar-title>
<q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/"> <q-btn flat no-caps dense class="q-mr-sm" size="lg" type="a" href="/">
<q-avatar v-if="g.settings.customLogo" height="30px"> <q-img
<img alt="Logo" :src="g.settings.customLogo" /> v-if="customLogo"
</q-avatar> height="30px"
alt="Logo"
:src="customLogo"
></q-img>
<span v-else-if="g.settings.siteTitle == 'LNbits'" <span v-else-if="g.settings.siteTitle == 'LNbits'"
><strong>LN</strong>bits</span ><strong>LN</strong>bits</span
> >
File diff suppressed because it is too large Load Diff
+111
View File
@@ -329,6 +329,19 @@
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
</span> </q-tooltip </span> </q-tooltip
></q-btn> ></q-btn>
<q-btn
v-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id) &&
extension.permissions &&
extension.permissions.length
"
flat
color="grey-5"
@click="openPermissionsForExtension(extension)"
label="Permissions"
></q-btn>
<q-btn <q-btn
@click="showManageExtension(extension)" @click="showManageExtension(extension)"
@@ -921,6 +934,104 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="permissionsDialog.show" position="top">
<q-card class="q-pa-md" style="min-width: 360px; max-width: 90vw">
<q-card-section>
<div class="text-h6">Permissions required</div>
<div class="text-caption text-grey">This extension can:</div>
<q-list v-if="permissionsDialog.extension">
<q-item
v-for="perm in permissionsDialog.extension.permissions"
:key="perm.id"
clickable
>
<q-item-section>
<q-item-label v-text="perm.label"></q-item-label>
<q-item-label caption v-text="perm.description"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.checked" :val="perm.id" />
</q-item-section>
</q-item>
</q-list>
<div
v-if="
permissionsDialog.tagOptions && permissionsDialog.tagOptions.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Allow this extension to listen for payment tags:
</div>
<q-list>
<q-item
v-for="tag in permissionsDialog.tagOptions"
:key="tag"
clickable
>
<q-item-section>
<q-item-label v-text="tag"></q-item-label>
</q-item-section>
<q-item-section side>
<q-checkbox v-model="permissionsDialog.tags" :val="tag" />
</q-item-section>
</q-item>
</q-list>
</div>
<div
v-if="
permissionsDialog.extension &&
permissionsDialog.extension.kvSchemaKeys &&
permissionsDialog.extension.kvSchemaKeys.length
"
class="q-mt-md"
>
<div class="text-caption text-grey">
Data this extension stores in its db:
</div>
<q-chip
v-for="kvKey in permissionsDialog.extension.kvSchemaKeys"
:key="kvKey"
:label="kvKey"
color="grey-3"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</div>
<div
v-if="permissionsDialog.missing && permissionsDialog.missing.length"
class="q-mt-md text-negative"
>
<div class="text-caption">
Missing API endpoints required by this extension:
</div>
<q-chip
v-for="perm in permissionsDialog.missing"
:key="perm"
:label="perm"
color="red-2"
text-color="black"
class="q-mr-xs q-mt-xs"
/>
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn
flat
color="grey"
v-text="$t('cancel')"
@click="cancelPermissionsDialog"
></q-btn>
<q-btn
color="primary"
:disable="!permissionsAllChecked || permissionsHasMissingEndpoints"
label="Save"
@click="confirmPermissionsDialog"
></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showExtensionDetailsDialog" position="top"> <q-dialog v-model="showExtensionDetailsDialog" position="top">
<q-card <q-card
v-if="selectedExtensionDetails" v-if="selectedExtensionDetails"
+1 -1
View File
@@ -204,7 +204,7 @@
</q-card> </q-card>
</div> </div>
<div <div
v-show="!g.mobileSimple || !$q.screen.lt.md" v-if="!g.mobileSimple || !$q.screen.lt.md"
class="col-12 col-md-5 q-gutter-y-md" class="col-12 col-md-5 q-gutter-y-md"
> >
<lnbits-wallet-extra <lnbits-wallet-extra
+1 -35
View File
@@ -8,8 +8,6 @@ from typing import Any, cast
import httpx import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from coincurve.keys import PrivateKey
from embit.bip39 import mnemonic_from_bytes, mnemonic_is_valid
from loguru import logger from loguru import logger
from lnbits.helpers import normalize_endpoint from lnbits.helpers import normalize_endpoint
@@ -81,11 +79,6 @@ class SparkL2Wallet(Wallet):
async def status(self) -> StatusResponse: async def status(self) -> StatusResponse:
try: try:
res = await self._request("POST", "/v1/balance") res = await self._request("POST", "/v1/balance")
status = res.get("status")
if status == "missing_mnemonic":
await self._check_sidecar_mnemonic()
return StatusResponse("Spark sidecar mnemonic not set", 0)
balance_msat = res.get("balance_msat") balance_msat = res.get("balance_msat")
if balance_msat is not None: if balance_msat is not None:
return StatusResponse(None, int(balance_msat)) return StatusResponse(None, int(balance_msat))
@@ -244,7 +237,7 @@ class SparkL2Wallet(Wallet):
) -> dict[str, Any]: ) -> dict[str, Any]:
error_message = None error_message = None
try: try:
r = await self.client.request(method, path, json=json_data, timeout=30) r = await self.client.request(method, path, json=json_data)
r.raise_for_status() r.raise_for_status()
j = r.json() j = r.json()
except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError) as exc: except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError) as exc:
@@ -326,30 +319,3 @@ class SparkL2Wallet(Wallet):
if mapped.failed: if mapped.failed:
return False return False
return None return None
async def _check_sidecar_mnemonic(self):
if settings.spark_l2_mnemonic:
valid = mnemonic_is_valid(settings.spark_l2_mnemonic)
if not valid:
logger.warning("SPARK_L2_MNEMONIC is set but invalid. Please recheck!")
return
await self._set_sidecar_mnemonic(settings.spark_l2_mnemonic)
return
logger.info("SPARK_L2_MNEMONIC is not set, one will be generated for you.")
mnemonic = mnemonic_from_bytes(PrivateKey().secret)
await self._set_sidecar_mnemonic(mnemonic)
async def _set_sidecar_mnemonic(self, mnemonic: str):
logger.info("Checking 'SPARK_L2_MNEMONIC' on the Spark sidecar.")
payload = {"mnemonic": mnemonic}
resp = await self._request("POST", "/v1/mnemonic", payload)
status = resp.get("status")
logger.info(f"Spark sidecar mnemonic status: {status}")
if status == "set":
logger.info("Updating 'SPARK_L2_MNEMONIC' mnemonic settings.")
from lnbits.core.crud.settings import set_settings_field
await set_settings_field("spark_l2_mnemonic", mnemonic)
else:
logger.info("Nothing to do for 'SPARK_L2_MNEMONIC' on the Spark sidecar.")
+3 -3
View File
@@ -1386,9 +1386,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "lnbits" name = "lnbits"
version = "1.5.1" version = "1.5.0"
requires-python = ">=3.10,<3.13" requires-python = ">=3.10,<3.13"
description = "LNbits, free and open-source Lightning wallet and accounts system." description = "LNbits, free and open-source Lightning wallet and accounts system."
authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }] authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
@@ -51,6 +51,7 @@ dependencies = [
"pillow>=12.1.0", "pillow>=12.1.0",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"greenlet (>=3.3.0,<4.0.0)", "greenlet (>=3.3.0,<4.0.0)",
"wasmtime>=16.0.0",
] ]
[project.scripts] [project.scripts]
Generated
+25 -4
View File
@@ -1247,7 +1247,7 @@ wheels = [
[[package]] [[package]]
name = "lnbits" name = "lnbits"
version = "1.5.1" version = "1.5.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -1290,6 +1290,7 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "uvloop" }, { name = "uvloop" },
{ name = "wasmtime" },
{ name = "websocket-client" }, { name = "websocket-client" },
{ name = "websockets" }, { name = "websockets" },
] ]
@@ -1371,6 +1372,7 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.54" }, { name = "sqlalchemy", specifier = "==1.4.54" },
{ name = "sse-starlette", specifier = "==2.3.6" }, { name = "sse-starlette", specifier = "==2.3.6" },
{ name = "starlette", specifier = "==0.47.1" }, { name = "starlette", specifier = "==0.47.1" },
{ name = "wasmtime", specifier = ">=16.0.0" },
{ name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-extensions", specifier = "==4.15.0" },
{ name = "uvicorn", specifier = "==0.40.0" }, { name = "uvicorn", specifier = "==0.40.0" },
{ name = "uvloop", specifier = "==0.22.1" }, { name = "uvloop", specifier = "==0.22.1" },
@@ -2710,6 +2712,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/8e43c5d27753135fa8df3de333c1f1917d6dc315d119ffa60d0587d8d9ff/wallycore-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b932510c859ac3e1fc57161420ecb2bb9b6562fce7acd1d9351337cc2cc3515", size = 1728013, upload-time = "2025-08-22T04:26:40.774Z" }, { url = "https://files.pythonhosted.org/packages/49/4b/8e43c5d27753135fa8df3de333c1f1917d6dc315d119ffa60d0587d8d9ff/wallycore-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b932510c859ac3e1fc57161420ecb2bb9b6562fce7acd1d9351337cc2cc3515", size = 1728013, upload-time = "2025-08-22T04:26:40.774Z" },
] ]
[[package]]
name = "wasmtime"
version = "41.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/be/68/6dc0e7156f883afe0129dd89e4031c8d1163131794ba6ce9e454a09168ad/wasmtime-41.0.0.tar.gz", hash = "sha256:fc2aaacf3ba794eac8baeb739939b2f7903e12d6b78edddc0b7f3ac3a9af6dfc", size = 117354, upload-time = "2026-01-20T18:18:00.565Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/f9/f6aef5de536d12652d97cf162f124cbdd642150c7da61ffa7863272cdab7/wasmtime-41.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:f5a6e237b5b94188ef9867926b447f779f540c729c92e4d91cc946f2bee7c282", size = 6837018, upload-time = "2026-01-20T18:17:41.489Z" },
{ url = "https://files.pythonhosted.org/packages/04/b9/42ec977972b2dcc8c61e3a40644d24d229b41fba151410644e44e35e6eb1/wasmtime-41.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:4a3e33d0d3cf49062eaa231f748f54af991e89e9a795c5ab9d4f0eee85736e4c", size = 7654957, upload-time = "2026-01-20T18:17:43.285Z" },
{ url = "https://files.pythonhosted.org/packages/18/ca/6cce49b03c35c7fecb4437fd98990c64694a5e0024f9279bef0ddef000f7/wasmtime-41.0.0-py3-none-any.whl", hash = "sha256:5f6721406a6cd186d11f34e6d4991c4d536387b0c577d09a56bd93b8a3cf10c2", size = 6325757, upload-time = "2026-01-20T18:17:44.789Z" },
{ url = "https://files.pythonhosted.org/packages/a0/16/d91cb80322cc7ae10bfa5db8cea4e0b9bb112f0c100b4486783ab16c1c22/wasmtime-41.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:2107360212fce33ed2adcfc33b7e75ed7136380a17d3ed598a5bab376dcf9e1b", size = 7471888, upload-time = "2026-01-20T18:17:46.185Z" },
{ url = "https://files.pythonhosted.org/packages/bc/f0/dcc80973d2ec58a1978b838887ccbd84d56900cf66dec5fb730bec3bd081/wasmtime-41.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f475df32ce9bfec4f6d0e124a49ca4a89e2ee71ccca460677f5237b1c8ee92ae", size = 6507285, upload-time = "2026-01-20T18:17:48.138Z" },
{ url = "https://files.pythonhosted.org/packages/bd/df/0867edd9ec26eb2e5eee7674a55f82c23ec27dd1d38d2d401f0e308eb920/wasmtime-41.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad7e866430313eb2ee07c85811e524344884489d00896f3b2246b65553fe322c", size = 7732024, upload-time = "2026-01-20T18:17:50.207Z" },
{ url = "https://files.pythonhosted.org/packages/bb/48/b748a2e70478feabc5c876d90e90a39f4aba35378f5ee822f607e8f29c69/wasmtime-41.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e0ea44584f60dcfa620af82d4fc2589248bcf64a93905b54ac3144242113b48a", size = 6800017, upload-time = "2026-01-20T18:17:51.7Z" },
{ url = "https://files.pythonhosted.org/packages/14/29/43656c3a464d437d62421de16f2de2db645647bab0a0153deea30bfdade4/wasmtime-41.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dabb20a2751f01b835095013426a76091bd0bdb36ca9bcfc49c910b78347438", size = 6840763, upload-time = "2026-01-20T18:17:53.125Z" },
{ url = "https://files.pythonhosted.org/packages/9f/09/4608b65fa35ce5fc1479e138293a1166b4ea817cfa9a79f019ab6d7013d8/wasmtime-41.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d9627dfc5625b4947ea35c819561da358838fe76f65bda8ffe01ce34df8b32b1", size = 7754016, upload-time = "2026-01-20T18:17:55.346Z" },
{ url = "https://files.pythonhosted.org/packages/f4/9d/236bb367270579e4f628fb7b04fe93541151df7953006f3766607fc667c9/wasmtime-41.0.0-py3-none-win_amd64.whl", hash = "sha256:4f29171d73b71f232b6fe86cba77526fee84139f1590071af5facba401b0c9eb", size = 6325764, upload-time = "2026-01-20T18:17:57.034Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/bba9c0368c377250ab24fd005a7a1e9076121778c1e83b1bcc092ab84f86/wasmtime-41.0.0-py3-none-win_arm64.whl", hash = "sha256:0c4bcaba055e78fc161f497b85f39f1d35d475f0341b1e0259fa0a4b49e223e8", size = 5392238, upload-time = "2026-01-20T18:17:59.052Z" },
]
[[package]] [[package]]
name = "websocket-client" name = "websocket-client"
version = "1.9.0" version = "1.9.0"
@@ -2769,14 +2790,14 @@ wheels = [
[[package]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.6" version = "3.1.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "markupsafe" }, { name = "markupsafe" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, { url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
] ]
[[package]] [[package]]