Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962a09bb30 | ||
|
|
92c86eb837 | ||
|
|
dadff18a9c | ||
|
|
87c1684a63 | ||
|
|
91354f8be4 | ||
|
|
5f5d45e89f | ||
|
|
52bb125a25 | ||
|
|
2ca1ed897c |
+1
-3
@@ -55,7 +55,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
|
|||||||
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
# which fundingsources are allowed in the admin ui
|
# which fundingsources are allowed in the admin ui
|
||||||
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet, SparkWallet, LightsparkSparkWallet"
|
# LNBITS_ALLOWED_FUNDING_SOURCES="VoidWallet, FakeWallet, CoreLightningWallet, CoreLightningRestWallet, LndRestWallet, EclairWallet, LndWallet, LnTipsWallet, LNPayWallet, LNbitsWallet, BlinkWallet, AlbyWallet, ZBDWallet, PhoenixdWallet, OpenNodeWallet, NWCWallet, BreezSdkWallet, BoltzWallet, StrikeWallet, CLNRestWallet"
|
||||||
|
|
||||||
# uvicorn variable, allow https behind a proxy
|
# uvicorn variable, allow https behind a proxy
|
||||||
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
# IMPORTANT: this also needs the webserver to be configured to forward the headers
|
||||||
@@ -187,8 +187,6 @@ 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"
|
||||||
|
|
||||||
# TODO: add Spark
|
|
||||||
|
|
||||||
# StrikeWallet
|
# StrikeWallet
|
||||||
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
STRIKE_API_ENDPOINT=https://api.strike.me/v1
|
||||||
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
|
||||||
|
|||||||
@@ -44,15 +44,19 @@ jobs:
|
|||||||
if: github.repository == 'lnbits/lnbits'
|
if: github.repository == 'lnbits/lnbits'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies for building secp256k1
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build and publish to pypi
|
- name: Set up Python 3.10
|
||||||
uses: JRubics/poetry-publish@v1.15
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
python-version: "3.10"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
- name: Build the project
|
||||||
|
run: uv build
|
||||||
|
- name: Publish to pypi
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||||
|
run: uv publish
|
||||||
|
|
||||||
appimage:
|
appimage:
|
||||||
needs: [ release ]
|
needs: [ release ]
|
||||||
|
|||||||
@@ -56,15 +56,19 @@ jobs:
|
|||||||
if: github.repository == 'lnbits/lnbits'
|
if: github.repository == 'lnbits/lnbits'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies for building secp256k1
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build and publish to pypi
|
- name: Set up Python 3.10
|
||||||
uses: JRubics/poetry-publish@v1.15
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
python-version: "3.10"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
- name: Build the project
|
||||||
|
run: uv build
|
||||||
|
- name: Publish to pypi
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||||
|
run: uv publish
|
||||||
|
|
||||||
appimage:
|
appimage:
|
||||||
needs: [ release ]
|
needs: [ release ]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Below is a side-by-side comparison of Lightning funding sources you can use with
|
|||||||
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
|
| **LN.tips** | Custodial/Self-Custodial | Depends on provider | Medium | ❌ | Low | Provider-managed | Moderate | Low | Transaction fees may apply | Medium | Simple hosted service; use LN.tips API as your backend. |
|
||||||
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
|
| **Fake Wallet** | Testing (simulated) | ❌ | Low | ❌ | N/A | N/A | Easy | Low | None (test only) | N/A | For testing only; mints accounting units in LNbits (no real sats, unit name configurable). |
|
||||||
|
|
||||||
## TODO: add Spark
|
---
|
||||||
|
|
||||||
### Notes for readers
|
### Notes for readers
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ A backend wallet is selected and configured entirely through LNbits environment
|
|||||||
|
|
||||||
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
### CLNRest (using [runes](https://docs.corelightning.org/reference/lightning-createrune))
|
||||||
|
|
||||||
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
|
[Core Lightning REST API docs](https://docs.corelightning.org/docs/rest)
|
||||||
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
Should also work with the [Rust version of CLNRest](https://github.com/daywalker90/clnrest-rs)
|
||||||
|
|
||||||
**Environment variables**
|
**Environment variables**
|
||||||
@@ -338,8 +338,6 @@ Configure in the admin UI or via env vars:
|
|||||||
|
|
||||||
<a id="strike"></a>
|
<a id="strike"></a>
|
||||||
|
|
||||||
## TODO: add Spark
|
|
||||||
|
|
||||||
## Strike (alpha)
|
## Strike (alpha)
|
||||||
|
|
||||||
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
Custodial provider integrated via **Strike OAuth Connect** (OAuth 2.0 / OIDC). Authenticate a Strike user in your app, then call Strike APIs on the user’s behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from .middleware import (
|
|||||||
add_first_install_middleware,
|
add_first_install_middleware,
|
||||||
add_ip_block_middleware,
|
add_ip_block_middleware,
|
||||||
add_ratelimit_middleware,
|
add_ratelimit_middleware,
|
||||||
|
add_route_access_middleware,
|
||||||
)
|
)
|
||||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# adds security middleware
|
# adds security middleware
|
||||||
add_ip_block_middleware(app)
|
add_ip_block_middleware(app)
|
||||||
|
add_route_access_middleware(app)
|
||||||
add_ratelimit_middleware(app)
|
add_ratelimit_middleware(app)
|
||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ async def get_user_from_account(
|
|||||||
super_user=account.is_super_user,
|
super_user=account.is_super_user,
|
||||||
fiat_providers=account.fiat_providers,
|
fiat_providers=account.fiat_providers,
|
||||||
has_password=account.password_hash is not None,
|
has_password=account.password_hash is not None,
|
||||||
|
ui_customization=account.ui_customization or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -848,3 +848,11 @@ async def m042_index_accounts(db: Connection):
|
|||||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||||
|
"""
|
||||||
|
Adds ui_customization column to accounts.
|
||||||
|
Used for server side persistence of UI customization settings.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class Account(AccountId):
|
|||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
ui_customization: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -288,6 +289,7 @@ class User(BaseModel):
|
|||||||
fiat_providers: list[str] = []
|
fiat_providers: list[str] = []
|
||||||
has_password: bool = False
|
has_password: bool = False
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
ui_customization: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_ids(self) -> list[str]:
|
def wallet_ids(self) -> list[str]:
|
||||||
|
|||||||
@@ -467,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
|||||||
return _auth_success_response(account.username, user_id, account.email)
|
return _auth_success_response(account.username, user_id, account.email)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/update")
|
@auth_router.patch("")
|
||||||
async def update(
|
async def update(
|
||||||
data: UpdateUser, account: Account = Depends(check_account_exists)
|
data: UpdateUser, account: Account = Depends(check_account_exists)
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
@@ -483,6 +483,22 @@ async def update(
|
|||||||
return await get_user_from_account(account)
|
return await get_user_from_account(account)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.patch("/ui")
|
||||||
|
async def update_ui_customization(
|
||||||
|
req: Request, account: Account = Depends(check_account_exists)
|
||||||
|
) -> Account:
|
||||||
|
ui_customization = await req.json()
|
||||||
|
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
|
||||||
|
|
||||||
|
if len(account.ui_customization or {}) > 1000 * 1024:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
|
||||||
|
)
|
||||||
|
|
||||||
|
await update_user_account(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/first_install")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
|||||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||||
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||||
|
"WALLET_FEATURED_BUTTON_LABEL": settings.lnbits_wallet_featured_button_label,
|
||||||
|
"WALLET_FEATURED_BUTTON_URL": settings.lnbits_wallet_featured_button_url,
|
||||||
|
"WALLET_FEATURED_BUTTON_ICON": settings.lnbits_wallet_featured_button_icon,
|
||||||
"LNBITS_VERSION": settings.version,
|
"LNBITS_VERSION": settings.version,
|
||||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||||
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -18,6 +19,53 @@ from lnbits.core.models import AuditEntry
|
|||||||
from lnbits.helpers import normalize_path, template_renderer
|
from lnbits.helpers import normalize_path, template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
_LOCALHOST_IPS = {"127.0.0.1", "::1"}
|
||||||
|
_PUBLIC_ASSET_PATHS = {
|
||||||
|
"/favicon.ico",
|
||||||
|
"/service-worker.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_match_path(path: str) -> str:
|
||||||
|
if not path:
|
||||||
|
return "/"
|
||||||
|
if path != "/" and path.endswith("/"):
|
||||||
|
return path.rstrip("/")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _route_pattern_matches(pattern: str, path: str) -> bool:
|
||||||
|
if not pattern:
|
||||||
|
return False
|
||||||
|
if not pattern.startswith("/"):
|
||||||
|
pattern = f"/{pattern}"
|
||||||
|
pattern = _normalize_match_path(pattern)
|
||||||
|
path = _normalize_match_path(path)
|
||||||
|
if pattern.endswith("*"):
|
||||||
|
prefix = pattern.rstrip("*")
|
||||||
|
return path.startswith(prefix)
|
||||||
|
if pattern == path:
|
||||||
|
return True
|
||||||
|
escaped = re.escape(pattern)
|
||||||
|
escaped = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", escaped)
|
||||||
|
return re.fullmatch(escaped, path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_by_accepted_type(request: Request, msg: str, status_code: HTTPStatus):
|
||||||
|
accept_header = request.headers.get("accept", "")
|
||||||
|
if "text/html" in accept_header.split(","):
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content=template_renderer()
|
||||||
|
.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{"err": msg, "status_code": status_code, "message": msg},
|
||||||
|
)
|
||||||
|
.body,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=status_code, content={"detail": msg})
|
||||||
|
|
||||||
|
|
||||||
class InstalledExtensionMiddleware:
|
class InstalledExtensionMiddleware:
|
||||||
# This middleware class intercepts calls made to the extensions API and:
|
# This middleware class intercepts calls made to the extensions API and:
|
||||||
@@ -235,6 +283,42 @@ def add_ip_block_middleware(app: FastAPI):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
def add_route_access_middleware(app: FastAPI):
|
||||||
|
@app.middleware("http")
|
||||||
|
async def route_access_middleware(request: Request, call_next):
|
||||||
|
if not settings.lnbits_route_access_control_enabled:
|
||||||
|
return await call_next(request)
|
||||||
|
if not request.client:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
content={"detail": "No request client"},
|
||||||
|
)
|
||||||
|
if request.client.host in _LOCALHOST_IPS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path or "/"
|
||||||
|
if "/static/" in path or path in _PUBLIC_ASSET_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
whitelist = settings.lnbits_route_access_whitelist
|
||||||
|
blacklist = settings.lnbits_route_access_blacklist
|
||||||
|
|
||||||
|
if whitelist:
|
||||||
|
if any(_route_pattern_matches(route, path) for route in whitelist):
|
||||||
|
return await call_next(request)
|
||||||
|
return _response_by_accepted_type(
|
||||||
|
request, f"Route not whitelisted: {path}", HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
if blacklist and any(
|
||||||
|
_route_pattern_matches(route, path) for route in blacklist
|
||||||
|
):
|
||||||
|
return _response_by_accepted_type(
|
||||||
|
request, f"Route is blacklisted: {path}", HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def add_first_install_middleware(app: FastAPI):
|
def add_first_install_middleware(app: FastAPI):
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def first_install_middleware(request: Request, call_next):
|
async def first_install_middleware(request: Request, call_next):
|
||||||
|
|||||||
+8
-13
@@ -252,6 +252,11 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
)
|
)
|
||||||
lnbits_show_home_page_elements: bool = Field(default=True)
|
lnbits_show_home_page_elements: bool = Field(default=True)
|
||||||
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
||||||
|
|
||||||
|
lnbits_wallet_featured_button_label: str | None = Field(default=None)
|
||||||
|
lnbits_wallet_featured_button_url: str | None = Field(default=None)
|
||||||
|
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
|
||||||
|
|
||||||
lnbits_custom_badge: str | None = Field(default=None)
|
lnbits_custom_badge: str | None = Field(default=None)
|
||||||
lnbits_custom_badge_color: str = Field(default="warning")
|
lnbits_custom_badge_color: str = Field(default="warning")
|
||||||
lnbits_theme_options: list[str] = Field(
|
lnbits_theme_options: list[str] = Field(
|
||||||
@@ -416,6 +421,9 @@ class SecuritySettings(LNbitsSettings):
|
|||||||
lnbits_rate_limit_unit: str = Field(default="minute")
|
lnbits_rate_limit_unit: str = Field(default="minute")
|
||||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||||
|
lnbits_route_access_control_enabled: bool = Field(default=False)
|
||||||
|
lnbits_route_access_whitelist: list[str] = Field(default=[])
|
||||||
|
lnbits_route_access_blacklist: list[str] = Field(default=[])
|
||||||
lnbits_callback_url_rules: list[str] = Field(
|
lnbits_callback_url_rules: list[str] = Field(
|
||||||
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||||
)
|
)
|
||||||
@@ -589,17 +597,6 @@ class SparkFundingSource(LNbitsSettings):
|
|||||||
spark_token: str | None = Field(default=None)
|
spark_token: str | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
class SparkL2FundingSource(LNbitsSettings):
|
|
||||||
spark_l2_mnemonic: str | None = Field(default=None)
|
|
||||||
spark_l2_network: str = Field(default="MAINNET")
|
|
||||||
spark_l2_internal_sidecar_version: str | None = Field(default="0.1.1")
|
|
||||||
spark_l2_external_endpoint: str | None = Field(default=None)
|
|
||||||
spark_l2_external_api_key: str | None = Field(default=None)
|
|
||||||
spark_l2_pay_wait_ms: int = Field(default=4000, ge=0)
|
|
||||||
spark_l2_pay_poll_ms: int = Field(default=500, ge=0)
|
|
||||||
spark_l2_stream_keepalive_ms: int = Field(default=15000, ge=0)
|
|
||||||
|
|
||||||
|
|
||||||
class LnTipsFundingSource(LNbitsSettings):
|
class LnTipsFundingSource(LNbitsSettings):
|
||||||
lntips_api_endpoint: str | None = Field(default=None)
|
lntips_api_endpoint: str | None = Field(default=None)
|
||||||
lntips_api_key: str | None = Field(default=None)
|
lntips_api_key: str | None = Field(default=None)
|
||||||
@@ -705,7 +702,6 @@ class FundingSourcesSettings(
|
|||||||
PhoenixdFundingSource,
|
PhoenixdFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
SparkL2FundingSource,
|
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
@@ -1033,7 +1029,6 @@ class SuperUserSettings(LNbitsSettings):
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
"LightsparkSparkWallet",
|
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -363,6 +363,18 @@ window.localisation.en = {
|
|||||||
watchdog: 'Watchdog',
|
watchdog: 'Watchdog',
|
||||||
server_logs: 'Server Logs',
|
server_logs: 'Server Logs',
|
||||||
ip_blocker: 'IP Blocker',
|
ip_blocker: 'IP Blocker',
|
||||||
|
route_access_control: 'Route Access Control',
|
||||||
|
route_access_control_enable: 'Enable route access control',
|
||||||
|
route_access_control_hint:
|
||||||
|
'When enabled, non-local requests are restricted. Requests from 127.0.0.1 (and ::1) are always allowed.',
|
||||||
|
route_access_whitelist_label: 'Route Whitelist',
|
||||||
|
route_access_whitelist_hint:
|
||||||
|
'Only allow non-local access to these routes (leave empty to disable whitelist mode).',
|
||||||
|
route_access_blacklist_label: 'Route Blacklist',
|
||||||
|
route_access_blacklist_hint:
|
||||||
|
'Block non-local access to these routes (ignored when a whitelist is set).',
|
||||||
|
route_access_save_confirm:
|
||||||
|
'Are you sure you want to save? This can impact access to LNbits if you are not accessing locally.',
|
||||||
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',
|
||||||
@@ -658,6 +670,15 @@ 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_label: 'Wallet Featured Button Label',
|
||||||
|
wallet_featured_button_label_hint:
|
||||||
|
'Show featured button on the wallet homepage',
|
||||||
|
wallet_featured_button_url: 'Featured Button URL',
|
||||||
|
wallet_featured_button_url_hint:
|
||||||
|
'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_hint:
|
||||||
|
'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',
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ window._lnbitsApi = {
|
|||||||
name: name
|
name: name
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateUiCustomization(data = {}) {
|
||||||
|
return this.request('patch', '/api/v1/auth/ui', null, data)
|
||||||
|
},
|
||||||
resetWalletKeys(wallet) {
|
resetWalletKeys(wallet) {
|
||||||
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
|
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
|
||||||
res => {
|
res => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ window.LNbits = {
|
|||||||
fiat_providers: data.fiat_providers || [],
|
fiat_providers: data.fiat_providers || [],
|
||||||
super_user: data.super_user,
|
super_user: data.super_user,
|
||||||
extra: data.extra ?? {},
|
extra: data.extra ?? {},
|
||||||
hasPassword: data.has_password ?? false
|
hasPassword: data.has_password ?? false,
|
||||||
|
uiCustomization: data.ui_customization || {}
|
||||||
}
|
}
|
||||||
const mapWallet = this.wallet
|
const mapWallet = this.wallet
|
||||||
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
||||||
|
|||||||
@@ -228,51 +228,6 @@ window.app.component('lnbits-admin-funding-sources', {
|
|||||||
spark_token: 'Token'
|
spark_token: 'Token'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'LightsparkSparkWallet',
|
|
||||||
'Spark (L2)',
|
|
||||||
{
|
|
||||||
spark_l2_internal_sidecar_version: {
|
|
||||||
label: 'Internal Sidecar Version (eg: 0.1.1).',
|
|
||||||
hint: 'If specified then the sidecar will be downloaded. Alternatively you can specify an External Sidecar endpoint in the Advanced section.',
|
|
||||||
value: ''
|
|
||||||
},
|
|
||||||
spark_l2_mnemonic: {
|
|
||||||
label: 'Mnemonic',
|
|
||||||
hint: 'Only required if Interna Sidecar version is specified.'
|
|
||||||
},
|
|
||||||
spark_l2_network: {
|
|
||||||
label: 'Network',
|
|
||||||
value: 'MAINNET'
|
|
||||||
},
|
|
||||||
|
|
||||||
spark_l2_external_endpoint: {
|
|
||||||
label: 'External Sidecar Endpoint. ',
|
|
||||||
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: '',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
spark_l2_external_api_key: {
|
|
||||||
label: 'External Sidecar API Key. ',
|
|
||||||
hint: 'API Key for authenticating with the external sidecar if it requires authentication.',
|
|
||||||
value: '',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
|
|
||||||
spark_l2_pay_wait_ms: {
|
|
||||||
label: 'Pay Wait Time (ms)',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
spark_l2_pay_poll_ms: {
|
|
||||||
label: 'Pay Poll Time (ms)',
|
|
||||||
advanced: true
|
|
||||||
},
|
|
||||||
spark_l2_stream_keepalive_ms: {
|
|
||||||
label: 'Stream Keepalive Time (ms)',
|
|
||||||
advanced: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'NWCWallet',
|
'NWCWallet',
|
||||||
'Nostr Wallet Connect',
|
'Nostr Wallet Connect',
|
||||||
|
|||||||
@@ -8,11 +8,58 @@ window.app.component('lnbits-admin-security', {
|
|||||||
serverlogEnabled: false,
|
serverlogEnabled: false,
|
||||||
nostrAcceptedUrl: '',
|
nostrAcceptedUrl: '',
|
||||||
formAllowedIPs: '',
|
formAllowedIPs: '',
|
||||||
formCallbackUrlRule: ''
|
formCallbackUrlRule: '',
|
||||||
|
routeOptions: [],
|
||||||
|
routeOptionsFiltered: [],
|
||||||
|
routeOptionsLoading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {},
|
created() {
|
||||||
|
this.loadOpenApiRoutes()
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async loadOpenApiRoutes() {
|
||||||
|
this.routeOptionsLoading = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/openapi.json')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load OpenAPI spec')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const paths = Object.keys(data.paths || {}).sort()
|
||||||
|
this.routeOptions = paths
|
||||||
|
this.routeOptionsFiltered = paths
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
} finally {
|
||||||
|
this.routeOptionsLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addRouteOption(value, done) {
|
||||||
|
const route = value.trim()
|
||||||
|
if (!route) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.routeOptions.includes(route)) {
|
||||||
|
this.routeOptions.push(route)
|
||||||
|
this.routeOptions.sort()
|
||||||
|
}
|
||||||
|
this.routeOptionsFiltered = this.routeOptions
|
||||||
|
done(route)
|
||||||
|
},
|
||||||
|
filterRouteOptions(val, update) {
|
||||||
|
update(() => {
|
||||||
|
if (!val) {
|
||||||
|
this.routeOptionsFiltered = this.routeOptions
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const needle = val.toLowerCase()
|
||||||
|
this.routeOptionsFiltered = this.routeOptions.filter(route =>
|
||||||
|
route.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
addAllowedIPs() {
|
addAllowedIPs() {
|
||||||
const allowedIPs = this.formAllowedIPs.trim()
|
const allowedIPs = this.formAllowedIPs.trim()
|
||||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||||
|
|||||||
@@ -69,6 +69,17 @@ window.app.component('lnbits-header', {
|
|||||||
window.location = '/users'
|
window.location = '/users'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e)
|
console.warn(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleLanguageChanged(lang) {
|
||||||
|
try {
|
||||||
|
await LNbits.api.updateUiCustomization({locale: lang.locale})
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Language Updated',
|
||||||
|
caption: lang.locale
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
LNbits.utils.notifyApiError(e)
|
LNbits.utils.notifyApiError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
window.app.component('lnbits-language-dropdown', {
|
window.app.component('lnbits-language-dropdown', {
|
||||||
template: '#lnbits-language-dropdown',
|
template: '#lnbits-language-dropdown',
|
||||||
|
computed: {
|
||||||
|
currentLanguage() {
|
||||||
|
return (
|
||||||
|
this.langs.find(lang => lang.value === window.i18n.global.locale) || {
|
||||||
|
value: 'en',
|
||||||
|
label: 'English',
|
||||||
|
display: '🇬🇧 EN'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
activeLanguage(lang) {
|
activeLanguage(lang) {
|
||||||
return window.i18n.global.locale === lang
|
return window.i18n.global.locale === lang
|
||||||
@@ -8,6 +19,7 @@ window.app.component('lnbits-language-dropdown', {
|
|||||||
this.g.locale = newValue
|
this.g.locale = newValue
|
||||||
window.i18n.global.locale = newValue
|
window.i18n.global.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
|
this.$emit('language-changed', newValue)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ window.app.component('lnbits-theme', {
|
|||||||
'g.disclaimerShown'(val) {
|
'g.disclaimerShown'(val) {
|
||||||
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
|
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
|
||||||
},
|
},
|
||||||
|
'g.locale'(val) {
|
||||||
|
this.$q.localStorage.setItem('lnbits.lang', val)
|
||||||
|
window.i18n.global.locale = val
|
||||||
|
},
|
||||||
'g.isFiatPriority'(val) {
|
'g.isFiatPriority'(val) {
|
||||||
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
|
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
|
||||||
},
|
},
|
||||||
@@ -123,6 +127,13 @@ window.app.component('lnbits-theme', {
|
|||||||
if (this.g.mobileSimple === true) {
|
if (this.g.mobileSimple === true) {
|
||||||
document.body.classList.add('mobile-simple')
|
document.body.classList.add('mobile-simple')
|
||||||
}
|
}
|
||||||
|
Object.entries(this.g.user.uiCustomization || {}).forEach(
|
||||||
|
([key, value]) => {
|
||||||
|
if (key in this.g) {
|
||||||
|
this.g[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
this.checkUrlParams()
|
this.checkUrlParams()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ window.PageAccount = {
|
|||||||
color: 'pink-3'
|
color: 'pink-3'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
defaultSiteCustomisation: {
|
||||||
|
locale: 'en',
|
||||||
|
themeChoice: 'salvador',
|
||||||
|
bgimageChoice: '',
|
||||||
|
gradientChoice: true,
|
||||||
|
darkChoice: true,
|
||||||
|
borderChoice: 'hard-border',
|
||||||
|
reactionChoice: 'confettiBothSides'
|
||||||
|
},
|
||||||
reactionOptions: [
|
reactionOptions: [
|
||||||
'None',
|
'None',
|
||||||
'confettiBothSides',
|
'confettiBothSides',
|
||||||
@@ -213,26 +222,18 @@ window.PageAccount = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
activeLanguage(lang) {
|
|
||||||
return window.i18n.global.locale === lang
|
|
||||||
},
|
|
||||||
changeLanguage(newValue) {
|
changeLanguage(newValue) {
|
||||||
window.i18n.global.locale = newValue
|
window.i18n.global.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
},
|
},
|
||||||
async updateAccount() {
|
async updateAccount() {
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
|
||||||
'PUT',
|
user_id: this.g.user.id,
|
||||||
'/api/v1/auth/update',
|
username: this.g.user.username,
|
||||||
null,
|
email: this.g.user.email,
|
||||||
{
|
extra: this.g.user.extra
|
||||||
user_id: this.g.user.id,
|
})
|
||||||
username: this.g.user.username,
|
|
||||||
email: this.g.user.email,
|
|
||||||
extra: this.g.user.extra
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
|
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
|
||||||
this.hasUsername = !!data.username
|
this.hasUsername = !!data.username
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
@@ -680,6 +681,23 @@ window.PageAccount = {
|
|||||||
l => l.name !== label.name
|
l => l.name !== label.name
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async siteCustomisationChanged(options = {}) {
|
||||||
|
try {
|
||||||
|
Object.entries(options || {}).forEach(([key, value]) => {
|
||||||
|
if (key in this.g) {
|
||||||
|
this.g[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await LNbits.api.updateUiCustomization(options)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'UI Customization updated.'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ window.PageAdmin = {
|
|||||||
lnbits_exchange_rate_providers: [],
|
lnbits_exchange_rate_providers: [],
|
||||||
lnbits_audit_exclude_paths: [],
|
lnbits_audit_exclude_paths: [],
|
||||||
lnbits_audit_include_paths: [],
|
lnbits_audit_include_paths: [],
|
||||||
lnbits_audit_http_response_codes: []
|
lnbits_audit_http_response_codes: [],
|
||||||
|
lnbits_route_access_whitelist: [],
|
||||||
|
lnbits_route_access_blacklist: []
|
||||||
},
|
},
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
needsRestart: false
|
needsRestart: false
|
||||||
@@ -69,6 +71,25 @@ window.PageAdmin = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
updateSettings() {
|
updateSettings() {
|
||||||
|
if (this.shouldConfirmRouteAccess()) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(this.$t('route_access_save_confirm'))
|
||||||
|
.onOk(() => this.persistSettings())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.persistSettings()
|
||||||
|
},
|
||||||
|
shouldConfirmRouteAccess() {
|
||||||
|
const fields = [
|
||||||
|
'lnbits_route_access_control_enabled',
|
||||||
|
'lnbits_route_access_whitelist',
|
||||||
|
'lnbits_route_access_blacklist'
|
||||||
|
]
|
||||||
|
return fields.some(
|
||||||
|
field => !_.isEqual(this.settings[field], this.formData[field])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
persistSettings() {
|
||||||
const data = _.omit(this.formData, [
|
const data = _.omit(this.formData, [
|
||||||
'is_super_user',
|
'is_super_user',
|
||||||
'lnbits_allowed_funding_sources',
|
'lnbits_allowed_funding_sources',
|
||||||
|
|||||||
@@ -296,6 +296,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-12">
|
||||||
|
<p v-text="$t('route_access_control')"></p>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-toggle
|
||||||
|
v-model="formData.lnbits_route_access_control_enabled"
|
||||||
|
:label="$t('route_access_control_enable')"
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
class="text-caption text-grey-6 q-mt-xs"
|
||||||
|
v-text="$t('route_access_control_hint')"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
use-input
|
||||||
|
input-debounce="0"
|
||||||
|
new-value-mode="add-unique"
|
||||||
|
@new-value="addRouteOption"
|
||||||
|
:options="routeOptionsFiltered"
|
||||||
|
:loading="routeOptionsLoading"
|
||||||
|
@filter="filterRouteOptions"
|
||||||
|
v-model="formData.lnbits_route_access_whitelist"
|
||||||
|
:label="$t('route_access_whitelist_label')"
|
||||||
|
:hint="$t('route_access_whitelist_hint')"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
use-input
|
||||||
|
input-debounce="0"
|
||||||
|
new-value-mode="add-unique"
|
||||||
|
@new-value="addRouteOption"
|
||||||
|
:options="routeOptionsFiltered"
|
||||||
|
:loading="routeOptionsLoading"
|
||||||
|
@filter="filterRouteOptions"
|
||||||
|
v-model="formData.lnbits_route_access_blacklist"
|
||||||
|
:label="$t('route_access_blacklist_label')"
|
||||||
|
:hint="$t('route_access_blacklist_hint')"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-12">
|
<div class="col-12 col-md-12">
|
||||||
<p v-text="$t('rate_limiter')"></p>
|
<p v-text="$t('rate_limiter')"></p>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
|
|||||||
@@ -86,6 +86,42 @@
|
|||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p>
|
||||||
|
<span v-text="$t('wallet_featured_button_label')"></span>
|
||||||
|
</p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_label"
|
||||||
|
label="Loop to Onchain"
|
||||||
|
:hint="$t('wallet_featured_button_label_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p><span v-text="$t('wallet_featured_button_url')"></span></p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_url"
|
||||||
|
label="/boltz"
|
||||||
|
:hint="$t('wallet_featured_button_url_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p><span v-text="$t('wallet_featured_button_icon')"></span></p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_icon"
|
||||||
|
label="bolt"
|
||||||
|
:hint="$t('wallet_featured_button_icon_hint')"
|
||||||
|
></q-input>
|
||||||
|
</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-6">
|
<div class="col-12 col-md-6">
|
||||||
<p><span v-text="$t('ui_custom_badge')"></span></p>
|
<p><span v-text="$t('ui_custom_badge')"></span></p>
|
||||||
|
|||||||
@@ -71,8 +71,11 @@
|
|||||||
<span>OFFLINE</span>
|
<span>OFFLINE</span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
|
|
||||||
<lnbits-language-dropdown></lnbits-language-dropdown>
|
<lnbits-language-dropdown
|
||||||
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
|
@language-changed="handleLanguageChanged({locale: $event})"
|
||||||
|
></lnbits-language-dropdown>
|
||||||
|
|
||||||
|
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<q-avatar
|
<q-avatar
|
||||||
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template id="lnbits-language-dropdown">
|
<template id="lnbits-language-dropdown">
|
||||||
<q-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
|
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
|
||||||
|
<template v-slot:label>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="currentLanguage.display"></q-item-label>
|
||||||
|
<q-tooltip><span v-text="currentLanguage.label"></span></q-tooltip>
|
||||||
|
</q-item-section>
|
||||||
|
</template>
|
||||||
<q-list v-for="(lang, index) in langs" :key="index">
|
<q-list v-for="(lang, index) in langs" :key="index">
|
||||||
<q-item
|
<q-item
|
||||||
clickable
|
clickable
|
||||||
|
|||||||
@@ -333,6 +333,16 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="g.user.extra.visible_wallet_count"
|
||||||
|
:label="$t('visible_wallet_count')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
class="q-mb-md"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="g.user.external_id"
|
v-model="g.user.external_id"
|
||||||
:label="$t('external_id')"
|
:label="$t('external_id')"
|
||||||
@@ -380,22 +390,11 @@
|
|||||||
<span v-text="$t('language')"></span>
|
<span v-text="$t('language')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<lnbits-language-dropdown />
|
<lnbits-language-dropdown
|
||||||
</div>
|
@language-changed="
|
||||||
</div>
|
siteCustomisationChanged({locale: $event})
|
||||||
<div class="row q-mb-md">
|
"
|
||||||
<div class="col-4">
|
/>
|
||||||
<span v-text="$t('visible_wallet_count')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
v-model="g.user.extra.visible_wallet_count"
|
|
||||||
:label="$t('visible_wallet_count')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="number"
|
|
||||||
class="q-mb-md"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -407,7 +406,9 @@
|
|||||||
<q-btn
|
<q-btn
|
||||||
v-for="theme in themeOptions"
|
v-for="theme in themeOptions"
|
||||||
:key="theme.name"
|
:key="theme.name"
|
||||||
@click="g.themeChoice = theme.name"
|
@click="
|
||||||
|
siteCustomisationChanged({themeChoice: theme.name})
|
||||||
|
"
|
||||||
:color="theme.color"
|
:color="theme.color"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@@ -427,6 +428,9 @@
|
|||||||
<q-input
|
<q-input
|
||||||
v-model="g.bgimageChoice"
|
v-model="g.bgimageChoice"
|
||||||
:label="$t('background_image')"
|
:label="$t('background_image')"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({bgimageChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('background_image')"></span
|
><span v-text="$t('background_image')"></span
|
||||||
@@ -445,6 +449,9 @@
|
|||||||
round
|
round
|
||||||
icon="gradient"
|
icon="gradient"
|
||||||
v-model="g.gradientChoice"
|
v-model="g.gradientChoice"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({gradientChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('toggle_gradient')"></span
|
><span v-text="$t('toggle_gradient')"></span
|
||||||
@@ -463,6 +470,9 @@
|
|||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
v-model="g.darkChoice"
|
v-model="g.darkChoice"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({darkChoice: $event})
|
||||||
|
"
|
||||||
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
|
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -481,6 +491,9 @@
|
|||||||
v-model="g.borderChoice"
|
v-model="g.borderChoice"
|
||||||
:options="borderOptions"
|
:options="borderOptions"
|
||||||
label="Borders"
|
label="Borders"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({borderChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('border_choices')"></span
|
><span v-text="$t('border_choices')"></span
|
||||||
@@ -508,6 +521,9 @@
|
|||||||
v-model="g.reactionChoice"
|
v-model="g.reactionChoice"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
label="Reactions"
|
label="Reactions"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({reactionChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('payment_reactions')"></span
|
><span v-text="$t('payment_reactions')"></span
|
||||||
@@ -515,6 +531,17 @@
|
|||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn
|
||||||
|
@click="
|
||||||
|
siteCustomisationChanged(defaultSiteCustomisation)
|
||||||
|
"
|
||||||
|
:label="$t('reset_defaults')"
|
||||||
|
filled
|
||||||
|
color="primary"
|
||||||
|
class="float-right q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="notifications">
|
<q-tab-panel name="notifications">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
|
|||||||
@@ -171,6 +171,19 @@
|
|||||||
><span v-text="$t('camera_tooltip')"></span
|
><span v-text="$t('camera_tooltip')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<div
|
||||||
|
v-if="WALLET_FEATURED_BUTTON_URL"
|
||||||
|
class="float-right q-mt-sm q-ml-sm"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
:label="WALLET_FEATURED_BUTTON_LABEL"
|
||||||
|
:icon="WALLET_FEATURED_BUTTON_ICON || undefined"
|
||||||
|
size="sm"
|
||||||
|
:to="WALLET_FEATURED_BUTTON_URL"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
<lnbits-update-balance
|
<lnbits-update-balance
|
||||||
v-if="$q.screen.gt.md"
|
v-if="$q.screen.gt.md"
|
||||||
:wallet_id="this.g.wallet.id"
|
:wallet_id="this.g.wallet.id"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from .corelightning import CoreLightningWallet as CLightningWallet
|
|||||||
from .corelightningrest import CoreLightningRestWallet
|
from .corelightningrest import CoreLightningRestWallet
|
||||||
from .eclair import EclairWallet
|
from .eclair import EclairWallet
|
||||||
from .fake import FakeWallet
|
from .fake import FakeWallet
|
||||||
from .lightspark import LightsparkSparkWallet
|
|
||||||
from .lnbits import LNbitsWallet
|
from .lnbits import LNbitsWallet
|
||||||
from .lndgrpc import LndWallet
|
from .lndgrpc import LndWallet
|
||||||
from .lndrest import LndRestWallet
|
from .lndrest import LndRestWallet
|
||||||
@@ -70,7 +69,6 @@ __all__ = [
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
"LightsparkSparkWallet",
|
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import uuid
|
|
||||||
from collections.abc import AsyncGenerator
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from bolt11 import decode as bolt11_decode
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from lnbits.helpers import download_url, normalize_endpoint
|
|
||||||
from lnbits.settings import settings
|
|
||||||
|
|
||||||
from .base import (
|
|
||||||
InvoiceResponse,
|
|
||||||
PaymentFailedStatus,
|
|
||||||
PaymentPendingStatus,
|
|
||||||
PaymentResponse,
|
|
||||||
PaymentStatus,
|
|
||||||
PaymentSuccessStatus,
|
|
||||||
StatusResponse,
|
|
||||||
Wallet,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SparkSidecarError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class LightsparkSparkWallet(Wallet):
|
|
||||||
"""
|
|
||||||
Spark L2 funding source via a local sidecar service.
|
|
||||||
|
|
||||||
Required settings/env:
|
|
||||||
- SPARK_L2_ENDPOINT (default http://127.0.0.1:8765)
|
|
||||||
Optional:
|
|
||||||
- SPARK_L2_API_KEY
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self._status = "Initializing"
|
|
||||||
self._sidecar_path = Path(settings.lnbits_data_folder, "light_spark")
|
|
||||||
|
|
||||||
self.pending_invoices: list[str] = []
|
|
||||||
|
|
||||||
self.endpoint = "http://127.0.0.1:8765"
|
|
||||||
self._api_key = uuid.uuid4().hex
|
|
||||||
if settings.spark_l2_internal_sidecar_version:
|
|
||||||
self._sidecar_version = settings.spark_l2_internal_sidecar_version
|
|
||||||
self.sidecar_task = asyncio.create_task(self._start_sidecar())
|
|
||||||
logger.info(f"Internal Spark sidecar ({self._sidecar_version}).")
|
|
||||||
elif settings.spark_l2_external_endpoint:
|
|
||||||
self.endpoint = normalize_endpoint(
|
|
||||||
cast(str, settings.spark_l2_external_endpoint)
|
|
||||||
)
|
|
||||||
self._api_key = settings.spark_l2_external_api_key
|
|
||||||
logger.info(f"Using external Spark sidecar endpoint: {self.endpoint}")
|
|
||||||
else:
|
|
||||||
logger.error(
|
|
||||||
"No Spark sidecar configuration found. Please set either "
|
|
||||||
"spark_l2_internal_sidecar_version or spark_l2_external_endpoint."
|
|
||||||
)
|
|
||||||
|
|
||||||
headers = {"User-Agent": settings.user_agent, "X-Api-Key": self._api_key}
|
|
||||||
|
|
||||||
self.client = httpx.AsyncClient(
|
|
||||||
base_url=self.endpoint,
|
|
||||||
headers=headers,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def cleanup(self):
|
|
||||||
try:
|
|
||||||
await self.client.aclose()
|
|
||||||
self.sidecar_task.cancel()
|
|
||||||
except RuntimeError as e:
|
|
||||||
logger.warning(f"Error closing wallet connection: {e}")
|
|
||||||
|
|
||||||
async def _request(
|
|
||||||
self, method: str, path: str, json_data: dict[str, Any] | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
error_message = None
|
|
||||||
try:
|
|
||||||
r = await self.client.request(method, path, json=json_data)
|
|
||||||
r.raise_for_status()
|
|
||||||
j = r.json()
|
|
||||||
except (httpx.RequestError, httpx.HTTPStatusError, json.JSONDecodeError) as exc:
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None:
|
|
||||||
try:
|
|
||||||
error_json = exc.response.json()
|
|
||||||
if "error" in error_json:
|
|
||||||
error_message = error_json["error"]
|
|
||||||
except Exception as json_exc:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to parse Spark error response as JSON: {json_exc}"
|
|
||||||
)
|
|
||||||
raise SparkSidecarError(
|
|
||||||
error_message or f"Spark sidecar request error: '{exc}'"
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if error_message or j.get("error"):
|
|
||||||
raise SparkSidecarError(
|
|
||||||
error_message or f"Spark sidecar error: {j['error']}"
|
|
||||||
)
|
|
||||||
return j
|
|
||||||
|
|
||||||
async def status(self) -> StatusResponse:
|
|
||||||
try:
|
|
||||||
res = await self._request("POST", "/v1/balance")
|
|
||||||
balance_msat = res.get("balance_msat")
|
|
||||||
if balance_msat is not None:
|
|
||||||
return StatusResponse(None, int(balance_msat))
|
|
||||||
balance_sats = res.get("balance_sats")
|
|
||||||
if balance_sats is None:
|
|
||||||
return StatusResponse("Spark sidecar: missing balance.", 0)
|
|
||||||
return StatusResponse(None, int(balance_sats) * 1000)
|
|
||||||
except Exception as e:
|
|
||||||
return StatusResponse(f"Spark sidecar status error: {e}", 0)
|
|
||||||
|
|
||||||
async def create_invoice(
|
|
||||||
self,
|
|
||||||
amount: int,
|
|
||||||
memo: str | None = None,
|
|
||||||
description_hash: bytes | None = None,
|
|
||||||
unhashed_description: bytes | None = None,
|
|
||||||
**kwargs,
|
|
||||||
) -> InvoiceResponse:
|
|
||||||
expiry = kwargs.get("expiry")
|
|
||||||
expiry_secs = int(expiry) if expiry else None
|
|
||||||
|
|
||||||
description_hash_hex = None
|
|
||||||
if description_hash:
|
|
||||||
description_hash_hex = description_hash.hex()
|
|
||||||
elif unhashed_description:
|
|
||||||
description_hash_hex = hashlib.sha256(unhashed_description).hexdigest()
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = {
|
|
||||||
"amount_sats": int(amount),
|
|
||||||
"memo": (memo or "") if not description_hash_hex else None,
|
|
||||||
"description_hash": description_hash_hex,
|
|
||||||
"expiry_seconds": expiry_secs,
|
|
||||||
}
|
|
||||||
res = await self._request("POST", "/v1/invoices", payload)
|
|
||||||
bolt11 = res.get("payment_request")
|
|
||||||
checking_id = res.get("checking_id")
|
|
||||||
if not bolt11 or not checking_id:
|
|
||||||
raise SparkSidecarError(
|
|
||||||
"Spark sidecar invoice response missing fields."
|
|
||||||
)
|
|
||||||
self.pending_invoices.append(checking_id)
|
|
||||||
|
|
||||||
return InvoiceResponse(
|
|
||||||
ok=True,
|
|
||||||
payment_request=bolt11,
|
|
||||||
checking_id=checking_id,
|
|
||||||
preimage=res.get("preimage"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return InvoiceResponse(ok=False, error_message=str(e))
|
|
||||||
|
|
||||||
async def pay_invoice(self, bolt11: str, fee_limit_msat: int) -> PaymentResponse:
|
|
||||||
try:
|
|
||||||
max_fee_sats = (int(fee_limit_msat) + 999) // 1000
|
|
||||||
|
|
||||||
payment_hash = None
|
|
||||||
try:
|
|
||||||
payment_hash = bolt11_decode(bolt11).payment_hash
|
|
||||||
except Exception:
|
|
||||||
payment_hash = None
|
|
||||||
payload = {
|
|
||||||
"bolt11": bolt11,
|
|
||||||
"max_fee_sats": max_fee_sats,
|
|
||||||
"payment_hash": payment_hash,
|
|
||||||
}
|
|
||||||
res = await self._request("POST", "/v1/payments", payload)
|
|
||||||
checking_id = payment_hash or res.get("checking_id")
|
|
||||||
if not checking_id:
|
|
||||||
raise SparkSidecarError(
|
|
||||||
"Spark sidecar payment response missing checking_id."
|
|
||||||
)
|
|
||||||
status = res.get("status")
|
|
||||||
fee_msat = res.get("fee_msat")
|
|
||||||
ok = None
|
|
||||||
if status:
|
|
||||||
ok = self._map_payment_ok(status)
|
|
||||||
return PaymentResponse(
|
|
||||||
ok=ok,
|
|
||||||
checking_id=checking_id,
|
|
||||||
fee_msat=int(fee_msat) if fee_msat is not None else None,
|
|
||||||
preimage=res.get("preimage"),
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return PaymentResponse(ok=False, error_message=str(e))
|
|
||||||
|
|
||||||
async def get_invoice_status(self, checking_id: str) -> PaymentStatus:
|
|
||||||
try:
|
|
||||||
res = await self._request("GET", f"/v1/invoices/{checking_id}")
|
|
||||||
status = res.get("status")
|
|
||||||
if not status:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
return self._map_invoice_status(status)
|
|
||||||
except Exception:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
|
|
||||||
async def get_payment_status(self, checking_id: str) -> PaymentStatus:
|
|
||||||
try:
|
|
||||||
res = await self._request("GET", f"/v1/payments/{checking_id}")
|
|
||||||
status = res.get("status")
|
|
||||||
fee_msat = res.get("fee_msat")
|
|
||||||
preimage = res.get("preimage")
|
|
||||||
if not status:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
mapped = self._map_payment_status(status)
|
|
||||||
if mapped.success:
|
|
||||||
return PaymentSuccessStatus(
|
|
||||||
fee_msat=int(fee_msat) if fee_msat is not None else None,
|
|
||||||
preimage=preimage,
|
|
||||||
)
|
|
||||||
if mapped.failed:
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
except Exception:
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
|
|
||||||
def _map_invoice_status(self, status: str) -> PaymentStatus:
|
|
||||||
success = {
|
|
||||||
"LIGHTNING_PAYMENT_RECEIVED",
|
|
||||||
"TRANSFER_COMPLETED",
|
|
||||||
"PAYMENT_PREIMAGE_RECOVERED",
|
|
||||||
}
|
|
||||||
failed = {
|
|
||||||
"TRANSFER_FAILED",
|
|
||||||
"PAYMENT_PREIMAGE_RECOVERING_FAILED",
|
|
||||||
"REFUND_SIGNING_FAILED",
|
|
||||||
"REFUND_SIGNING_COMMITMENTS_QUERYING_FAILED",
|
|
||||||
"TRANSFER_CREATION_FAILED",
|
|
||||||
}
|
|
||||||
if status in success:
|
|
||||||
return PaymentSuccessStatus()
|
|
||||||
if status in failed:
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
|
|
||||||
def _map_payment_status(self, status: str) -> PaymentStatus:
|
|
||||||
success = {
|
|
||||||
"LIGHTNING_PAYMENT_SUCCEEDED",
|
|
||||||
"TRANSFER_COMPLETED",
|
|
||||||
"PREIMAGE_PROVIDED",
|
|
||||||
}
|
|
||||||
failed = {
|
|
||||||
"LIGHTNING_PAYMENT_FAILED",
|
|
||||||
"TRANSFER_FAILED",
|
|
||||||
"PREIMAGE_PROVIDING_FAILED",
|
|
||||||
"USER_TRANSFER_VALIDATION_FAILED",
|
|
||||||
"USER_SWAP_RETURN_FAILED",
|
|
||||||
}
|
|
||||||
if status in success:
|
|
||||||
return PaymentSuccessStatus()
|
|
||||||
if status in failed:
|
|
||||||
return PaymentFailedStatus()
|
|
||||||
return PaymentPendingStatus()
|
|
||||||
|
|
||||||
def _map_payment_ok(self, status: str) -> bool | None:
|
|
||||||
mapped = self._map_payment_status(status)
|
|
||||||
if mapped.success:
|
|
||||||
return True
|
|
||||||
if mapped.failed:
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _poll_pending_invoices(self) -> AsyncGenerator[str, None]:
|
|
||||||
while settings.lnbits_running:
|
|
||||||
for invoice in list(self.pending_invoices):
|
|
||||||
try:
|
|
||||||
status = await self.get_invoice_status(invoice)
|
|
||||||
if status.paid:
|
|
||||||
yield invoice
|
|
||||||
self.pending_invoices.remove(invoice)
|
|
||||||
elif status.failed:
|
|
||||||
self.pending_invoices.remove(invoice)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(f"could not get status of invoice {invoice}: '{exc}' ")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
async def paid_invoices_stream(self) -> AsyncGenerator[str, None]:
|
|
||||||
stream_path = "/v1/invoices/stream"
|
|
||||||
while settings.lnbits_running:
|
|
||||||
try:
|
|
||||||
async with self.client.stream("GET", stream_path, timeout=None) as r:
|
|
||||||
if r.status_code in {404, 405}:
|
|
||||||
logger.warning(
|
|
||||||
"Spark sidecar invoice stream not available, "
|
|
||||||
"falling back to polling."
|
|
||||||
)
|
|
||||||
async for checking_id in self._poll_pending_invoices():
|
|
||||||
yield checking_id
|
|
||||||
return
|
|
||||||
r.raise_for_status()
|
|
||||||
logger.info("connected to Spark sidecar invoice stream.")
|
|
||||||
async for line in r.aiter_lines():
|
|
||||||
if not line or not line.startswith("data:"):
|
|
||||||
continue
|
|
||||||
data = json.loads(line[5:].strip())
|
|
||||||
checking_id = data.get("checking_id")
|
|
||||||
if not checking_id:
|
|
||||||
continue
|
|
||||||
yield checking_id
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
"lost connection to Spark sidecar invoice stream: "
|
|
||||||
f"'{exc}' retrying in 5 seconds"
|
|
||||||
)
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
async def _start_sidecar(self):
|
|
||||||
logger.info("Starting Spark sidecar")
|
|
||||||
node_path = shutil.which("node")
|
|
||||||
if not node_path:
|
|
||||||
logger.error("Node.js not found in PATH, cannot start Spark sidecar")
|
|
||||||
return
|
|
||||||
logger.info(f"Node.js found: {node_path}")
|
|
||||||
|
|
||||||
repo, version = "spark_sidecar", self._sidecar_version
|
|
||||||
node_modules_path = Path(self._sidecar_path, f"{repo}-{version}")
|
|
||||||
await self._prepare_sidecar(repo, version, node_modules_path)
|
|
||||||
|
|
||||||
await self._start_sidecar_process(node_path, node_modules_path)
|
|
||||||
|
|
||||||
async def _prepare_sidecar(self, repo: str, version: str, node_modules_path: Path):
|
|
||||||
if not Path(node_modules_path, "package.json").is_file():
|
|
||||||
await self._download_sidecar(repo, version)
|
|
||||||
else:
|
|
||||||
logger.info("Spark sidecar already downloaded.")
|
|
||||||
|
|
||||||
if not Path(node_modules_path, "node_modules").is_dir():
|
|
||||||
self._install_sidecar_packages(node_modules_path)
|
|
||||||
else:
|
|
||||||
logger.info("Spark sidecar npm dependencies already installed.")
|
|
||||||
|
|
||||||
def _install_sidecar_packages(self, node_modules_path: Path):
|
|
||||||
logger.info(f"Installing Spark sidecar npm dependencies {node_modules_path}")
|
|
||||||
npm_path = shutil.which("npm")
|
|
||||||
if not npm_path:
|
|
||||||
logger.error("npm not found in PATH, cannot start Spark sidecar")
|
|
||||||
return
|
|
||||||
logger.info(f"npm found: {npm_path}")
|
|
||||||
|
|
||||||
result = subprocess.run( # noqa: S603
|
|
||||||
[npm_path, "install"],
|
|
||||||
cwd=str(node_modules_path),
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
shell=False,
|
|
||||||
check=True, # raises an exception if npm fails
|
|
||||||
)
|
|
||||||
logger.info("Spark sidecar npm dependencies installed.")
|
|
||||||
logger.info("npm install output:")
|
|
||||||
logger.info(result.stdout)
|
|
||||||
logger.error(result.stderr)
|
|
||||||
|
|
||||||
async def _start_sidecar_process(self, node_path: str, node_modules_path: Path):
|
|
||||||
logger.info("Starting Spark sidecar node process.")
|
|
||||||
|
|
||||||
env = {
|
|
||||||
"SPARK_NETWORK": settings.spark_l2_network,
|
|
||||||
"SPARK_SIDECAR_API_KEY": self._api_key or "",
|
|
||||||
"SPARK_PAY_WAIT_MS": str(settings.spark_l2_pay_wait_ms),
|
|
||||||
"SPARK_MNEMONIC": str(settings.spark_l2_mnemonic),
|
|
||||||
}
|
|
||||||
|
|
||||||
process = subprocess.Popen( # noqa: S603
|
|
||||||
[node_path, "server.mjs"],
|
|
||||||
env=env,
|
|
||||||
cwd=str(node_modules_path),
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
shell=False,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Started Spark sidecar node process.")
|
|
||||||
await asyncio.to_thread(self._log_process_output, process)
|
|
||||||
|
|
||||||
async def _download_sidecar(self, repo: str, version: str):
|
|
||||||
zip_path = Path(self._sidecar_path, f"{repo}.zip")
|
|
||||||
logger.info(f"⏳ Downloading Spark sidecar to {zip_path}")
|
|
||||||
Path(zip_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
await asyncio.to_thread(
|
|
||||||
download_url,
|
|
||||||
f"https://github.com/lnbits/{repo}/archive/refs/tags/v{version}.zip",
|
|
||||||
zip_path,
|
|
||||||
)
|
|
||||||
logger.info("✅ Downloaded Spark sidecar.")
|
|
||||||
|
|
||||||
logger.info("⏳ Extracting Spark sidecar.")
|
|
||||||
|
|
||||||
shutil.unpack_archive(
|
|
||||||
zip_path,
|
|
||||||
self._sidecar_path,
|
|
||||||
)
|
|
||||||
logger.info("✅ Extracted Spark sidecar.")
|
|
||||||
shutil.rmtree(zip_path, ignore_errors=True)
|
|
||||||
# todo: remove zip
|
|
||||||
|
|
||||||
def _log_process_output(self, process: subprocess.Popen):
|
|
||||||
if process.stdout:
|
|
||||||
for line in process.stdout:
|
|
||||||
logger.warning(f"[Lightspark]: {line}", end="")
|
|
||||||
else:
|
|
||||||
logger.error(" No output captured for Spark sidecar.")
|
|
||||||
Generated
+1
-1
@@ -4638,4 +4638,4 @@ migration = ["psycopg2-binary"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = ">=3.10,<3.13"
|
python-versions = ">=3.10,<3.13"
|
||||||
content-hash = "6c17d723baa390c36ecf6f861166878f2619acd47f878cf9253c5614ba25b9ea"
|
content-hash = "f63a5be62359f837513e01a8b0e5282a3b4509ea97439ba0c8226ccf3c158ceb"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ readme = "README.md"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bech32==1.2.0",
|
"bech32==1.2.0",
|
||||||
"click==8.3.1",
|
"click==8.3.1",
|
||||||
"ecdsa==0.19.1",
|
|
||||||
"fastapi==0.116.1",
|
"fastapi==0.116.1",
|
||||||
"starlette==0.47.1",
|
"starlette==0.47.1",
|
||||||
"httpx==0.27.2",
|
"httpx==0.27.2",
|
||||||
|
|||||||
@@ -2016,9 +2016,7 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
|||||||
]
|
]
|
||||||
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
|
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
|
||||||
assert data.extra
|
assert data.extra
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_data = response.json()
|
user_data = response.json()
|
||||||
assert len(user_data["extra"]["labels"]) == 2
|
assert len(user_data["extra"]["labels"]) == 2
|
||||||
@@ -2028,18 +2026,14 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
|||||||
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
|
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
|
||||||
|
|
||||||
data.extra.labels = []
|
data.extra.labels = []
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_data = response.json()
|
user_data = response.json()
|
||||||
assert len(user_data["extra"]["labels"]) == 0
|
assert len(user_data["extra"]["labels"]) == 0
|
||||||
|
|
||||||
json_data = data.dict()
|
json_data = data.dict()
|
||||||
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=json_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
@@ -1256,7 +1256,6 @@ dependencies = [
|
|||||||
{ name = "bech32" },
|
{ name = "bech32" },
|
||||||
{ name = "bolt11" },
|
{ name = "bolt11" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
{ name = "ecdsa" },
|
|
||||||
{ name = "embit" },
|
{ name = "embit" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastapi-sso" },
|
{ name = "fastapi-sso" },
|
||||||
@@ -1340,7 +1339,6 @@ requires-dist = [
|
|||||||
{ name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" },
|
{ name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" },
|
||||||
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" },
|
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" },
|
||||||
{ name = "click", specifier = "==8.3.1" },
|
{ name = "click", specifier = "==8.3.1" },
|
||||||
{ name = "ecdsa", specifier = "==0.19.1" },
|
|
||||||
{ name = "embit", specifier = "==0.8.0" },
|
{ name = "embit", specifier = "==0.8.0" },
|
||||||
{ name = "fastapi", specifier = "==0.116.1" },
|
{ name = "fastapi", specifier = "==0.116.1" },
|
||||||
{ name = "fastapi-sso", specifier = "==0.19.0" },
|
{ name = "fastapi-sso", specifier = "==0.19.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user