Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
648a657922 | ||
|
|
cf251b1f33 | ||
|
|
f94c4e432e | ||
|
|
6360c49ed0 | ||
|
|
bfa35d9c69 | ||
|
|
561d0976b3 | ||
|
|
d40515e1ba | ||
|
|
766fe3b5c6 | ||
|
|
82940a91b6 | ||
|
|
5bd524660f | ||
|
|
be2bb929c0 | ||
|
|
7d34f459fa | ||
|
|
11d16620dd | ||
|
|
c99bb28963 | ||
|
|
2bd398b59c | ||
|
|
0e69aa536d | ||
|
|
984fc05caa | ||
|
|
213cde5ec6 | ||
|
|
2cd694c36e | ||
|
|
fb9cb6f2ed | ||
|
|
2852caeb42 | ||
|
|
34fa193dc5 | ||
|
|
0f38fcfaa9 | ||
|
|
2cac4e6b4d | ||
|
|
9983af4eec | ||
|
|
a061dda5c1 | ||
|
|
6b233cc067 |
+3
-1
@@ -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"
|
# 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"
|
||||||
|
|
||||||
# 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,6 +187,8 @@ 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,19 +44,15 @@ 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: Set up Python 3.10
|
- name: Build and publish to pypi
|
||||||
uses: actions/setup-python@v5
|
uses: JRubics/poetry-publish@v1.15
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||||
- 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,19 +56,15 @@ 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: Set up Python 3.10
|
- name: Build and publish to pypi
|
||||||
uses: actions/setup-python@v5
|
uses: JRubics/poetry-publish@v1.15
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
||||||
- 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,6 +338,8 @@ 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,7 +66,6 @@ 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
|
||||||
|
|
||||||
@@ -193,7 +192,6 @@ 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,7 +204,6 @@ 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,11 +848,3 @@ 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,7 +187,6 @@ 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))
|
||||||
@@ -289,7 +288,6 @@ 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.patch("")
|
@auth_router.put("/update")
|
||||||
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,22 +483,6 @@ 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,9 +98,6 @@ 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,6 +1,5 @@
|
|||||||
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
|
||||||
@@ -19,53 +18,6 @@ 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:
|
||||||
@@ -283,42 +235,6 @@ 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):
|
||||||
|
|||||||
+13
-8
@@ -252,11 +252,6 @@ 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(
|
||||||
@@ -421,9 +416,6 @@ 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+)?"]
|
||||||
)
|
)
|
||||||
@@ -597,6 +589,17 @@ 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)
|
||||||
@@ -702,6 +705,7 @@ class FundingSourcesSettings(
|
|||||||
PhoenixdFundingSource,
|
PhoenixdFundingSource,
|
||||||
OpenNodeFundingSource,
|
OpenNodeFundingSource,
|
||||||
SparkFundingSource,
|
SparkFundingSource,
|
||||||
|
SparkL2FundingSource,
|
||||||
LnTipsFundingSource,
|
LnTipsFundingSource,
|
||||||
NWCFundingSource,
|
NWCFundingSource,
|
||||||
BreezSdkFundingSource,
|
BreezSdkFundingSource,
|
||||||
@@ -1029,6 +1033,7 @@ 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,18 +363,6 @@ 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',
|
||||||
@@ -670,15 +658,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_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,9 +154,6 @@ 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,8 +14,7 @@ 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,6 +228,51 @@ 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,58 +8,11 @@ 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,17 +69,6 @@ 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,16 +1,5 @@
|
|||||||
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
|
||||||
@@ -19,7 +8,6 @@ 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,10 +9,6 @@ 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)
|
||||||
},
|
},
|
||||||
@@ -127,13 +123,6 @@ 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,15 +39,6 @@ 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',
|
||||||
@@ -222,18 +213,26 @@ 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('PATCH', '/api/v1/auth', null, {
|
const {data} = await LNbits.api.request(
|
||||||
user_id: this.g.user.id,
|
'PUT',
|
||||||
username: this.g.user.username,
|
'/api/v1/auth/update',
|
||||||
email: this.g.user.email,
|
null,
|
||||||
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({
|
||||||
@@ -681,23 +680,6 @@ 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,9 +8,7 @@ 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
|
||||||
@@ -71,25 +69,6 @@ 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,56 +296,6 @@
|
|||||||
</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,42 +86,6 @@
|
|||||||
></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,11 +71,8 @@
|
|||||||
<span>OFFLINE</span>
|
<span>OFFLINE</span>
|
||||||
</q-badge>
|
</q-badge>
|
||||||
|
|
||||||
<lnbits-language-dropdown
|
<lnbits-language-dropdown></lnbits-language-dropdown>
|
||||||
@language-changed="handleLanguageChanged({locale: $event})"
|
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
|
||||||
></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,11 +1,5 @@
|
|||||||
<template id="lnbits-language-dropdown">
|
<template id="lnbits-language-dropdown">
|
||||||
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
|
<q-btn-dropdown dense flat rounded size="sm" icon="language" 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,16 +333,6 @@
|
|||||||
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')"
|
||||||
@@ -390,11 +380,22 @@
|
|||||||
<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 />
|
||||||
@language-changed="
|
</div>
|
||||||
siteCustomisationChanged({locale: $event})
|
</div>
|
||||||
"
|
<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>
|
||||||
|
|
||||||
@@ -406,9 +407,7 @@
|
|||||||
<q-btn
|
<q-btn
|
||||||
v-for="theme in themeOptions"
|
v-for="theme in themeOptions"
|
||||||
:key="theme.name"
|
:key="theme.name"
|
||||||
@click="
|
@click="g.themeChoice = theme.name"
|
||||||
siteCustomisationChanged({themeChoice: theme.name})
|
|
||||||
"
|
|
||||||
:color="theme.color"
|
:color="theme.color"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@@ -428,9 +427,6 @@
|
|||||||
<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
|
||||||
@@ -449,9 +445,6 @@
|
|||||||
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
|
||||||
@@ -470,9 +463,6 @@
|
|||||||
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"
|
||||||
>
|
>
|
||||||
@@ -491,9 +481,6 @@
|
|||||||
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
|
||||||
@@ -521,9 +508,6 @@
|
|||||||
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
|
||||||
@@ -531,17 +515,6 @@
|
|||||||
</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,19 +171,6 @@
|
|||||||
><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,6 +20,7 @@ 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
|
||||||
@@ -69,6 +70,7 @@ __all__ = [
|
|||||||
"FakeWallet",
|
"FakeWallet",
|
||||||
"LNPayWallet",
|
"LNPayWallet",
|
||||||
"LNbitsWallet",
|
"LNbitsWallet",
|
||||||
|
"LightsparkSparkWallet",
|
||||||
"LnTipsWallet",
|
"LnTipsWallet",
|
||||||
"LndRestWallet",
|
"LndRestWallet",
|
||||||
"LndWallet",
|
"LndWallet",
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
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 = "f63a5be62359f837513e01a8b0e5282a3b4509ea97439ba0c8226ccf3c158ceb"
|
content-hash = "6c17d723baa390c36ecf6f861166878f2619acd47f878cf9253c5614ba25b9ea"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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,7 +2016,9 @@ 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.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
response = await http_client.put(
|
||||||
|
"/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
|
||||||
@@ -2026,14 +2028,18 @@ 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.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
response = await http_client.put(
|
||||||
|
"/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.patch("/api/v1/auth?usr=" + user.id, json=json_data)
|
response = await http_client.put(
|
||||||
|
"/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,6 +1256,7 @@ 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" },
|
||||||
@@ -1339,6 +1340,7 @@ 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