Compare commits

..
Author SHA1 Message Date
Vlad Stan f33eb29bd9 chore: code clean-up 2026-02-09 13:28:25 +02:00
Vlad Stan 271ddfee46 refactor:
- private methods to the bottom
- do not raise but return status
2026-02-09 13:18:52 +02:00
Vlad Stan 648a657922 feat: download specific release 2026-02-09 12:40:36 +02:00
Vlad Stan cf251b1f33 chode: lint 2026-02-09 08:54:59 +02:00
Vlad Stan f94c4e432e fix: auth 2026-02-09 08:53:33 +02:00
Vlad Stan 6360c49ed0 feat: configure settings 2026-02-09 08:53:33 +02:00
Vlad Stan bfa35d9c69 refactor: extract methods and move them to the bottom 2026-02-09 08:53:33 +02:00
Vlad Stan 561d0976b3 fix: logging 2026-02-09 08:53:33 +02:00
Vlad Stan d40515e1ba fix: no logs 2026-02-09 08:53:33 +02:00
Vlad Stan 766fe3b5c6 feat: try to capture logs 2026-02-09 08:53:33 +02:00
Vlad Stan 82940a91b6 fix: better paths 2026-02-09 08:53:33 +02:00
Vlad Stan 5bd524660f feat: experiment node from python 2026-02-09 08:53:33 +02:00
Vlad Stan be2bb929c0 doc: add todos for missing doc 2026-02-09 08:53:33 +02:00
blackcoffeexbt 7d34f459fa Include error messages from Spark API in error message to users when payments from Spark fail 2026-02-05 12:26:17 +00:00
Arc 11d16620dd fundle 2026-01-29 05:02:31 +00:00
Arc c99bb28963 Merge branch 'origindev' into sparkwallet 2026-01-29 04:57:29 +00:00
arcbtc 2bd398b59c fixes 2026-01-23 00:41:18 +00:00
arcbtc 0e69aa536d bundle 2026-01-21 23:58:25 +00:00
arcbtc 984fc05caa Merge remote-tracking branch 'origin/dev' into sparkwallet 2026-01-21 23:57:42 +00:00
Arc 213cde5ec6 make 2026-01-14 12:12:10 +00:00
Arc 2cd694c36e Merge remote-tracking branch 'origin/dev' into sparkwallet 2026-01-14 12:10:37 +00:00
Arc fb9cb6f2ed adds invoice stream 2026-01-14 12:05:59 +00:00
Arc 2852caeb42 moved to own repo 2026-01-13 13:36:28 +00:00
Arc 34fa193dc5 make 2026-01-10 15:51:39 +00:00
Arc 0f38fcfaa9 Needed longer wait time + poll 2026-01-10 15:48:13 +00:00
Arc 2cac4e6b4d sending kinda working 2026-01-10 15:39:35 +00:00
Arc 9983af4eec graceful shutdown 2026-01-10 15:26:43 +00:00
Arc a061dda5c1 make 2026-01-10 14:50:19 +00:00
Arc 6b233cc067 working 2026-01-10 14:22:17 +00:00
37 changed files with 568 additions and 483 deletions
+3 -1
View File
@@ -55,7 +55,7 @@ LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# 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
# 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
BOLTZ_CLIENT_CERT="/home/bob/.boltz/tls.cert"
# TODO: add Spark
# StrikeWallet
STRIKE_API_ENDPOINT=https://api.strike.me/v1
STRIKE_API_KEY=YOUR_STRIKE_API_KEY
+7 -11
View File
@@ -44,19 +44,15 @@ jobs:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
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
- name: Set up Python 3.10
uses: actions/setup-python@v5
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
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
pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
+7 -11
View File
@@ -56,19 +56,15 @@ jobs:
if: github.repository == 'lnbits/lnbits'
runs-on: ubuntu-24.04
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
- name: Set up Python 3.10
uses: actions/setup-python@v5
- name: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
with:
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
pypi_token: ${{ secrets.PYPI_API_KEY }}
appimage:
needs: [ release ]
+1 -1
View File
@@ -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. |
| **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
+3 -1
View File
@@ -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))
[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)
**Environment variables**
@@ -338,6 +338,8 @@ Configure in the admin UI or via env vars:
<a id="strike"></a>
## TODO: add Spark
## 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 users behalf once scopes are granted. Requires a Strike business account, registered OAuth client, minimal scopes, and login/logout redirect URLs.
-2
View File
@@ -66,7 +66,6 @@ from .middleware import (
add_first_install_middleware,
add_ip_block_middleware,
add_ratelimit_middleware,
add_route_access_middleware,
)
from .tasks import internal_invoice_listener, invoice_listener, run_interval
@@ -193,7 +192,6 @@ def create_app() -> FastAPI:
# adds security middleware
add_ip_block_middleware(app)
add_route_access_middleware(app)
add_ratelimit_middleware(app)
register_exception_handlers(app)
-1
View File
@@ -204,7 +204,6 @@ async def get_user_from_account(
super_user=account.is_super_user,
fiat_providers=account.fiat_providers,
has_password=account.password_hash is not None,
ui_customization=account.ui_customization or {},
)
-8
View File
@@ -848,11 +848,3 @@ async def m042_index_accounts(db: Connection):
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")
-2
View File
@@ -187,7 +187,6 @@ class Account(AccountId):
pubkey: str | None = None
email: str | None = None
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
created_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] = []
has_password: bool = False
extra: UserExtra = UserExtra()
ui_customization: dict = Field(default_factory=dict)
@property
def wallet_ids(self) -> list[str]:
+1 -17
View File
@@ -467,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email)
@auth_router.patch("")
@auth_router.put("/update")
async def update(
data: UpdateUser, account: Account = Depends(check_account_exists)
) -> User | None:
@@ -483,22 +483,6 @@ async def update(
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")
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
if not settings.first_install:
-3
View File
@@ -98,9 +98,6 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
"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,
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
-84
View File
@@ -1,6 +1,5 @@
import asyncio
import json
import re
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
@@ -19,53 +18,6 @@ from lnbits.core.models import AuditEntry
from lnbits.helpers import normalize_path, template_renderer
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:
# 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)
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):
@app.middleware("http")
async def first_install_middleware(request: Request, call_next):
+13 -8
View File
@@ -252,11 +252,6 @@ class ThemesSettings(LNbitsSettings):
)
lnbits_show_home_page_elements: bool = Field(default=True)
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_color: str = Field(default="warning")
lnbits_theme_options: list[str] = Field(
@@ -421,9 +416,6 @@ class SecuritySettings(LNbitsSettings):
lnbits_rate_limit_unit: str = Field(default="minute")
lnbits_allowed_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(
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)
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):
lntips_api_endpoint: str | None = Field(default=None)
lntips_api_key: str | None = Field(default=None)
@@ -702,6 +705,7 @@ class FundingSourcesSettings(
PhoenixdFundingSource,
OpenNodeFundingSource,
SparkFundingSource,
SparkL2FundingSource,
LnTipsFundingSource,
NWCFundingSource,
BreezSdkFundingSource,
@@ -1029,6 +1033,7 @@ class SuperUserSettings(LNbitsSettings):
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
-21
View File
@@ -363,18 +363,6 @@ window.localisation.en = {
watchdog: 'Watchdog',
server_logs: 'Server Logs',
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_tools: 'Security tools',
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_default_wallet_name: 'Default Wallet Name',
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',
denomination: 'Denomination',
denomination_hint: 'The name for the FakeWallet token',
-3
View File
@@ -154,9 +154,6 @@ window._lnbitsApi = {
name: name
})
},
updateUiCustomization(data = {}) {
return this.request('patch', '/api/v1/auth/ui', null, data)
},
resetWalletKeys(wallet) {
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
res => {
+1 -2
View File
@@ -14,8 +14,7 @@ window.LNbits = {
fiat_providers: data.fiat_providers || [],
super_user: data.super_user,
extra: data.extra ?? {},
hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
hasPassword: data.has_password ?? false
}
const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
@@ -228,6 +228,51 @@ window.app.component('lnbits-admin-funding-sources', {
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',
'Nostr Wallet Connect',
@@ -8,58 +8,11 @@ window.app.component('lnbits-admin-security', {
serverlogEnabled: false,
nostrAcceptedUrl: '',
formAllowedIPs: '',
formCallbackUrlRule: '',
routeOptions: [],
routeOptionsFiltered: [],
routeOptionsLoading: false
formCallbackUrlRule: ''
}
},
created() {
this.loadOpenApiRoutes()
},
created() {},
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() {
const allowedIPs = this.formAllowedIPs.trim()
const allowed_ips = this.formData.lnbits_allowed_ips
@@ -69,17 +69,6 @@ window.app.component('lnbits-header', {
window.location = '/users'
} catch (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)
}
}
@@ -1,16 +1,5 @@
window.app.component('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: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
@@ -19,7 +8,6 @@ window.app.component('lnbits-language-dropdown', {
this.g.locale = newValue
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
this.$emit('language-changed', newValue)
}
},
data() {
@@ -9,10 +9,6 @@ window.app.component('lnbits-theme', {
'g.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) {
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
},
@@ -127,13 +123,6 @@ window.app.component('lnbits-theme', {
if (this.g.mobileSimple === true) {
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()
}
})
+14 -32
View File
@@ -39,15 +39,6 @@ window.PageAccount = {
color: 'pink-3'
}
],
defaultSiteCustomisation: {
locale: 'en',
themeChoice: 'salvador',
bgimageChoice: '',
gradientChoice: true,
darkChoice: true,
borderChoice: 'hard-border',
reactionChoice: 'confettiBothSides'
},
reactionOptions: [
'None',
'confettiBothSides',
@@ -222,18 +213,26 @@ window.PageAccount = {
}
},
methods: {
activeLanguage(lang) {
return window.i18n.global.locale === lang
},
changeLanguage(newValue) {
window.i18n.global.locale = newValue
this.$q.localStorage.set('lnbits.lang', newValue)
},
async updateAccount() {
try {
const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
user_id: this.g.user.id,
username: this.g.user.username,
email: this.g.user.email,
extra: this.g.user.extra
})
const {data} = await LNbits.api.request(
'PUT',
'/api/v1/auth/update',
null,
{
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.hasUsername = !!data.username
Quasar.Notify.create({
@@ -681,23 +680,6 @@ window.PageAccount = {
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)
}
}
},
+1 -22
View File
@@ -8,9 +8,7 @@ window.PageAdmin = {
lnbits_exchange_rate_providers: [],
lnbits_audit_exclude_paths: [],
lnbits_audit_include_paths: [],
lnbits_audit_http_response_codes: [],
lnbits_route_access_whitelist: [],
lnbits_route_access_blacklist: []
lnbits_audit_http_response_codes: []
},
isSuperUser: false,
needsRestart: false
@@ -71,25 +69,6 @@ window.PageAdmin = {
.catch(LNbits.utils.notifyApiError)
},
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, [
'is_super_user',
'lnbits_allowed_funding_sources',
@@ -296,56 +296,6 @@
</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">
<p v-text="$t('rate_limiter')"></p>
<div class="row q-col-gutter-md">
@@ -86,42 +86,6 @@
></q-input>
</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="col-12 col-md-6">
<p><span v-text="$t('ui_custom_badge')"></span></p>
@@ -71,11 +71,8 @@
<span>OFFLINE</span>
</q-badge>
<lnbits-language-dropdown
@language-changed="handleLanguageChanged({locale: $event})"
></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
<lnbits-language-dropdown></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<template v-slot:label>
<q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
@@ -1,11 +1,5 @@
<template id="lnbits-language-dropdown">
<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-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
<q-list v-for="(lang, index) in langs" :key="index">
<q-item
clickable
+17 -44
View File
@@ -333,16 +333,6 @@
class="q-mb-md"
>
</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
v-model="g.user.external_id"
:label="$t('external_id')"
@@ -390,11 +380,22 @@
<span v-text="$t('language')"></span>
</div>
<div class="col-8">
<lnbits-language-dropdown
@language-changed="
siteCustomisationChanged({locale: $event})
"
/>
<lnbits-language-dropdown />
</div>
</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>
@@ -406,9 +407,7 @@
<q-btn
v-for="theme in themeOptions"
:key="theme.name"
@click="
siteCustomisationChanged({themeChoice: theme.name})
"
@click="g.themeChoice = theme.name"
:color="theme.color"
dense
flat
@@ -428,9 +427,6 @@
<q-input
v-model="g.bgimageChoice"
:label="$t('background_image')"
@update:model-value="
siteCustomisationChanged({bgimageChoice: $event})
"
>
<q-tooltip
><span v-text="$t('background_image')"></span
@@ -449,9 +445,6 @@
round
icon="gradient"
v-model="g.gradientChoice"
@update:model-value="
siteCustomisationChanged({gradientChoice: $event})
"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
@@ -470,9 +463,6 @@
flat
round
v-model="g.darkChoice"
@update:model-value="
siteCustomisationChanged({darkChoice: $event})
"
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
size="sm"
>
@@ -491,9 +481,6 @@
v-model="g.borderChoice"
:options="borderOptions"
label="Borders"
@update:model-value="
siteCustomisationChanged({borderChoice: $event})
"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
@@ -521,9 +508,6 @@
v-model="g.reactionChoice"
:options="reactionOptions"
label="Reactions"
@update:model-value="
siteCustomisationChanged({reactionChoice: $event})
"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
@@ -531,17 +515,6 @@
</q-select>
</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 name="notifications">
<q-card-section>
-13
View File
@@ -171,19 +171,6 @@
><span v-text="$t('camera_tooltip')"></span
></q-tooltip>
</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
v-if="$q.screen.gt.md"
:wallet_id="this.g.wallet.id"
+2
View File
@@ -20,6 +20,7 @@ from .corelightning import CoreLightningWallet as CLightningWallet
from .corelightningrest import CoreLightningRestWallet
from .eclair import EclairWallet
from .fake import FakeWallet
from .lightspark import LightsparkSparkWallet
from .lnbits import LNbitsWallet
from .lndgrpc import LndWallet
from .lndrest import LndRestWallet
@@ -69,6 +70,7 @@ __all__ = [
"FakeWallet",
"LNPayWallet",
"LNbitsWallet",
"LightsparkSparkWallet",
"LnTipsWallet",
"LndRestWallet",
"LndWallet",
+424
View File
@@ -0,0 +1,424 @@
import asyncio
import hashlib
import json
import os
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 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:
logger.warning(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:
return InvoiceResponse(
ok=False,
error_message="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", None),
)
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 as exc:
logger.warning(exc)
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:
return PaymentResponse(
ok=False,
error_message="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 as exc:
logger.warning(exc)
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 as exc:
logger.warning(exc)
return PaymentPendingStatus()
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 _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 _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 _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)
if zip_path.is_file():
os.remove(zip_path)
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.")
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.")
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
Generated
+1 -1
View File
@@ -4638,4 +4638,4 @@ migration = ["psycopg2-binary"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.13"
content-hash = "f63a5be62359f837513e01a8b0e5282a3b4509ea97439ba0c8226ccf3c158ceb"
content-hash = "6c17d723baa390c36ecf6f861166878f2619acd47f878cf9253c5614ba25b9ea"
+1
View File
@@ -9,6 +9,7 @@ readme = "README.md"
dependencies = [
"bech32==1.2.0",
"click==8.3.1",
"ecdsa==0.19.1",
"fastapi==0.116.1",
"starlette==0.47.1",
"httpx==0.27.2",
+9 -3
View File
@@ -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)
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
user_data = response.json()
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"
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
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 0
json_data = data.dict()
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
data = response.json()
Generated
+2
View File
@@ -1256,6 +1256,7 @@ dependencies = [
{ name = "bech32" },
{ name = "bolt11" },
{ name = "click" },
{ name = "ecdsa" },
{ name = "embit" },
{ name = "fastapi" },
{ name = "fastapi-sso" },
@@ -1339,6 +1340,7 @@ requires-dist = [
{ name = "breez-sdk", marker = "extra == 'breez'", specifier = "==0.8.0" },
{ name = "breez-sdk-liquid", marker = "extra == 'breez'", specifier = "==0.11.11" },
{ name = "click", specifier = "==8.3.1" },
{ name = "ecdsa", specifier = "==0.19.1" },
{ name = "embit", specifier = "==0.8.0" },
{ name = "fastapi", specifier = "==0.116.1" },
{ name = "fastapi-sso", specifier = "==0.19.0" },