Compare commits

...
20 Commits
Author SHA1 Message Date
Arc 962a09bb30 fundle 2026-01-29 18:21:05 +00:00
Arc 92c86eb837 make 2026-01-29 18:17:07 +00:00
Arc dadff18a9c feat: route whitelist/blacklist
security feature middleware to block/allow routes
2026-01-29 18:11:28 +00:00
Arc 87c1684a63 init 2026-01-29 17:45:44 +00:00
Vlad StanandGitHub 91354f8be4 feat: wallet featured button (#3740) 2026-01-29 10:49:19 +02:00
Vlad StanandGitHub 5f5d45e89f [feat] persist user ui customization (#3743) 2026-01-29 10:35:05 +02:00
dni ⚡andGitHub 52bb125a25 chore: remove ecdsa in favor of coincurve (#3746) 2026-01-29 10:02:16 +02:00
dni ⚡andGitHub 2ca1ed897c CI: use uv for publishing on pypi (#3745) 2026-01-29 08:54:42 +01:00
Vlad StanandGitHub 631f4e8455 [feat] admin impersonate user (#3741) 2026-01-28 16:59:23 +01:00
Vlad StanandGitHub b22f88b264 fix: DB migration conflict (#3739) 2026-01-27 08:31:38 +02:00
dni ⚡andGitHub c08bc02930 chore: update python packages (#3707) 2026-01-26 08:54:37 +01:00
Vlad StanandGitHub e020a7c5a0 feat: allow text upload (#3738) 2026-01-26 09:43:52 +02:00
Vlad StanandGitHub d2e8914835 fix: better validation error messages (#3736) 2026-01-21 14:28:57 +02:00
dni ⚡andGitHub 5e79ca35ab chore: remove windowMixin from components and pages (#3632) 2026-01-21 11:26:45 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4690d178a2 chore(deps): bump cryptography from 42.0.8 to 44.0.1 (#3735)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 11:02:28 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c5825aa4c3 chore(deps): bump aiohttp from 3.12.15 to 3.13.3 (#3734)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:27:12 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebe0c4e3c3 chore(deps): bump urllib3 from 2.5.0 to 2.6.3 (#3733)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 17:26:45 +02:00
Djuri BaarsandGitHub 383bdb6312 [fix] Fix missing timezone information 2026-01-20 17:26:02 +02:00
a947686d79 [feat] Update extension builder for 1.4.1 (#3725)
Co-authored-by: dni  <office@dnilabs.com>
2026-01-20 14:47:54 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
28e9d32b44 chore(deps): bump werkzeug from 3.1.3 to 3.1.5 (#3732)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 14:46:06 +02:00
92 changed files with 2558 additions and 1626 deletions
+11 -7
View File
@@ -44,15 +44,19 @@ 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: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
+11 -7
View File
@@ -56,15 +56,19 @@ 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: Build and publish to pypi
uses: JRubics/poetry-publish@v1.15
- name: Set up Python 3.10
uses: actions/setup-python@v5
with:
pypi_token: ${{ secrets.PYPI_API_KEY }}
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Build the project
run: uv build
- name: Publish to pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
run: uv publish
appimage:
needs: [ release ]
+2
View File
@@ -66,6 +66,7 @@ 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
@@ -192,6 +193,7 @@ 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,6 +204,7 @@ 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,3 +848,11 @@ 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")
+21 -1
View File
@@ -269,10 +269,30 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try:
github_releases = await cls.fetch_github_releases(org, repo)
return [
extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases
]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e:
logger.warning(e)
return []
+25
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import random
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
@@ -55,6 +57,29 @@ class DataField(BaseModel):
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
+2
View File
@@ -187,6 +187,7 @@ 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))
@@ -288,6 +289,7 @@ 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]:
+26 -12
View File
@@ -3,6 +3,7 @@ import io
from uuid import uuid4
from fastapi import UploadFile
from loguru import logger
from PIL import Image
from lnbits.core.crud.assets import create_asset, get_user_assets_count
@@ -29,17 +30,7 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
)
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
thumb_buffer = thumbnail_from_bytes(contents)
asset = Asset(
id=uuid4().hex,
@@ -48,9 +39,32 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
is_public=is_public,
name=file.filename or "unnamed",
size_bytes=len(contents),
thumbnail_base64=base64.b64encode(thumb_buffer.getvalue()).decode("utf-8"),
thumbnail_base64=(
base64.b64encode(thumb_buffer.getvalue()).decode("utf-8")
if thumb_buffer
else None
),
data=contents,
)
await create_asset(asset)
return asset
def thumbnail_from_bytes(contents: bytes) -> io.BytesIO | None:
try:
image = Image.open(io.BytesIO(contents))
thumbnail_width = min(256, settings.lnbits_asset_thumbnail_width)
thumbnail_height = min(256, settings.lnbits_asset_thumbnail_height)
image.thumbnail((thumbnail_width, thumbnail_height))
# Save thumbnail to an in-memory buffer
thumb_buffer = io.BytesIO()
thumbnail_format = settings.lnbits_asset_thumbnail_format or "PNG"
image.save(thumb_buffer, format=thumbnail_format)
thumb_buffer.seek(0)
return thumb_buffer
except Exception as exc:
logger.warning(f"Failed to create thumbnail: {exc}")
return None
+44 -11
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import (
camel_to_words,
download_url,
lowercase_first_letter,
snake_to_camel,
)
from lnbits.settings import settings
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
shutil.copytree(
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
rederer = _render_file(
template_path,
{
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.owner_data.fields],
"all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
},
"client_data": {
"name": data.client_data.name,
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
],
"db_fields": [field.field_to_db() for field in data.client_data.fields],
"all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
},
"settings_data": {
"enabled": data.settings_data.enabled,
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
def _is_excluded_dir(path: str) -> bool:
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
+4 -4
View File
@@ -57,11 +57,11 @@ async def api_get_asset(
@asset_router.get(
"/{asset_id}/binary",
name="Get user asset binary",
summary="Get user asset binary data by ID",
"/{asset_id}/data",
name="Get user asset data",
summary="Get user asset data data by ID",
)
async def api_get_asset_binary(
async def api_get_asset_data(
asset_id: str,
user_id: str | None = Depends(optional_user_id),
) -> Response:
+77 -2
View File
@@ -4,9 +4,10 @@ import json
from collections.abc import Callable
from http import HTTPStatus
from time import time
from typing import Annotated
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi_sso.sso.base import OpenID, SSOBase
from loguru import logger
@@ -29,6 +30,7 @@ from lnbits.core.services.users import update_user_account
from lnbits.decorators import (
access_token_payload,
check_account_exists,
check_admin,
check_user_exists,
)
from lnbits.helpers import (
@@ -120,6 +122,63 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
return _auth_success_response(account.username, account.id, account.email)
@auth_router.post("/impersonate", description="Login via the User ID of another user")
async def impersonate_user(
data: LoginUsr,
user: User = Depends(check_admin),
cookie_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not cookie_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED, "Only cookie based impersonation is allowed."
)
if data.usr == user.id:
raise HTTPException(HTTPStatus.FORBIDDEN, "You cannot impersonate yourself.")
if settings.is_admin_user(data.usr):
# this check includes the superuser
raise HTTPException(
HTTPStatus.FORBIDDEN, "You cannot impersonate another admin user."
)
account = await get_account(data.usr)
if not account:
raise HTTPException(HTTPStatus.UNAUTHORIZED, "User ID does not exist.")
response = _auth_success_response(account.username, account.id, account.email)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"admin_access_token", cookie_access_token, httponly=True, max_age=max_age
)
response.set_cookie("is_lnbits_user_impersonated", "true", max_age=max_age)
return response
@auth_router.delete(
"/impersonate", description="Stop impersonation and go back to admin"
)
async def stop_impersonate_user(
user: User = Depends(check_user_exists),
admin_access_token: Annotated[str | None, Cookie()] = None,
) -> JSONResponse:
if not admin_access_token:
raise HTTPException(
HTTPStatus.UNAUTHORIZED,
"No admin access token found to stop impersonation.",
)
response = JSONResponse(
{"access_token": admin_access_token, "token_type": "bearer"}
)
max_age = settings.auth_token_expire_minutes * 60
response.set_cookie(
"cookie_access_token", admin_access_token, httponly=True, max_age=max_age
)
response.delete_cookie("admin_access_token")
response.delete_cookie("is_access_token_expired")
response.delete_cookie("is_lnbits_user_impersonated")
return response
@auth_router.get("/acl")
async def api_get_user_acls(
request: Request,
@@ -408,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
return _auth_success_response(account.username, user_id, account.email)
@auth_router.put("/update")
@auth_router.patch("")
async def update(
data: UpdateUser, account: Account = Depends(check_account_exists)
) -> User | None:
@@ -424,6 +483,22 @@ 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:
+18 -31
View File
@@ -42,54 +42,38 @@ async def robots():
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
"/extensions/builder/preview/{ext_id}/{resource}",
name="extensions builder",
dependencies=[Depends(check_extension_builder)],
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
resource: str | None = None,
page_name: str | None = None,
user: User = Depends(check_user_exists),
) -> HTMLResponse:
) -> FileResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path(
file_name = "index"
if page_name == "public_page":
file_name = "public_page"
resource_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
"static",
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
file_ext = ".vue" if resource == "template" else ".js"
file_full_path = Path(resource_path, file_name + file_ext)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"status_code": 404,
"message": f"Extension {ext_id} not found, refresh Preview.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
},
)
if not file_full_path.is_file():
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
@@ -208,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
)
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index(
request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse:
+7 -2
View File
@@ -337,7 +337,8 @@ class Database(Compat):
f = "%Y-%m-%d %H:%M:%S.%f"
if "." not in value:
f = "%Y-%m-%d %H:%M:%S"
return datetime.strptime(value, f)
# Parse and add UTC timezone info
return datetime.strptime(value, f).replace(tzinfo=timezone.utc)
dbapi_connection.run_async(
lambda connection: connection.set_type_codec(
@@ -756,7 +757,11 @@ def dict_to_model(_row: dict, model: type[TModel]) -> TModel: # noqa: C901
if DB_TYPE == SQLITE:
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
else:
_dict[key] = value
# Ensure PostgreSQL datetime values have timezone info
if isinstance(value, datetime) and value.tzinfo is None:
_dict[key] = value.replace(tzinfo=timezone.utc)
else:
_dict[key] = value
continue
if issubclass(type_, BaseModel):
_dict[key] = dict_to_submodel(type_, value)
+1 -1
View File
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
logger.error(f"RequestValidationError: {exc!s}")
return render_html_error(request, exc) or JSONResponse(
status_code=HTTPStatus.BAD_REQUEST,
content={"detail": str(exc)},
content={"detail": [dict(e) for e in exc.errors()]},
)
@app.exception_handler(HTTPException)
+11
View File
@@ -98,6 +98,9 @@ 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,
@@ -391,6 +394,14 @@ def camel_to_snake(name: str) -> str:
return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
+84
View File
@@ -1,5 +1,6 @@
import asyncio
import json
import re
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
@@ -18,6 +19,53 @@ 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:
@@ -235,6 +283,42 @@ 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):
+12
View File
@@ -252,6 +252,11 @@ 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(
@@ -306,6 +311,10 @@ class AssetSettings(LNbitsSettings):
"heic",
"heif",
"heics",
"text/plain",
"text/json" "text/xml",
"application/json",
"application/pdf",
]
)
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
@@ -412,6 +421,9 @@ 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+)?"]
)
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
+23
View File
@@ -363,6 +363,18 @@ 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',
@@ -658,6 +670,15 @@ 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',
@@ -695,6 +716,8 @@ window.localisation.en = {
allow_creation_user: 'Allow creation of new users',
allow_creation_user_desc: 'Allow creation of new users on the index page',
new_user_not_allowed: 'Registration is disabled.',
start_user_impersonation: 'Impersonate this user',
stop_user_impersonation: 'Stop User Impersonation',
components: 'Components',
long_running_endpoints: 'Top 5 Long Running Endpoints',
http_request_methods: 'HTTP Request Methods',
+16
View File
@@ -123,6 +123,19 @@ window._lnbitsApi = {
url: '/api/v1/auth/logout'
})
},
impersonateUser(usr) {
return axios({
method: 'POST',
url: '/api/v1/auth/impersonate',
data: {usr}
})
},
stopImpersonation() {
return axios({
method: 'DELETE',
url: '/api/v1/auth/impersonate'
})
},
getAuthenticatedUser() {
return this.request('get', '/api/v1/auth')
},
@@ -141,6 +154,9 @@ 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 => {
+2 -1
View File
@@ -14,7 +14,8 @@ window.LNbits = {
fiat_providers: data.fiat_providers || [],
super_user: data.super_user,
extra: data.extra ?? {},
hasPassword: data.has_password ?? false
hasPassword: data.has_password ?? false,
uiCustomization: data.ui_customization || {}
}
const mapWallet = this.wallet
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
-27
View File
@@ -33,23 +33,7 @@ window.app.component('lnbits-extension-rating', {
}
})
window.app.component('lnbits-fsat', {
template: '<span>{{ fsat }}</span>',
props: {
amount: {
type: Number,
default: 0
}
},
computed: {
fsat() {
return LNbits.utils.formatSat(this.amount)
}
}
})
window.app.component('lnbits-manage', {
mixins: [window.windowMixin],
template: '#lnbits-manage',
computed: {
showAdmin() {
@@ -81,10 +65,8 @@ window.app.component('lnbits-manage', {
})
window.app.component('lnbits-payment-details', {
mixins: [window.windowMixin],
template: '#lnbits-payment-details',
props: ['payment'],
mixins: [window.windowMixin],
computed: {
hasPreimage() {
return (
@@ -128,7 +110,6 @@ window.app.component('lnbits-payment-details', {
})
window.app.component('lnbits-lnurlpay-success-action', {
mixins: [window.windowMixin],
template: '#lnbits-lnurlpay-success-action',
props: ['payment', 'success_action'],
data() {
@@ -148,7 +129,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
window.app.component('lnbits-notifications-btn', {
template: '#lnbits-notifications-btn',
mixins: [window.windowMixin],
props: ['pubkey'],
data() {
return {
@@ -319,7 +299,6 @@ window.app.component('lnbits-notifications-btn', {
window.app.component('lnbits-dynamic-fields', {
template: '#lnbits-dynamic-fields',
mixins: [window.windowMixin],
props: ['options', 'modelValue'],
data() {
return {
@@ -352,7 +331,6 @@ window.app.component('lnbits-dynamic-fields', {
window.app.component('lnbits-dynamic-chips', {
template: '#lnbits-dynamic-chips',
mixins: [window.windowMixin],
props: ['modelValue'],
data() {
return {
@@ -383,7 +361,6 @@ window.app.component('lnbits-dynamic-chips', {
window.app.component('lnbits-update-balance', {
template: '#lnbits-update-balance',
mixins: [window.windowMixin],
props: ['wallet_id', 'small_btn'],
computed: {
admin() {
@@ -422,7 +399,6 @@ window.app.component('lnbits-update-balance', {
window.app.component('user-id-only', {
template: '#user-id-only',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authAction: String,
@@ -466,7 +442,6 @@ window.app.component('user-id-only', {
window.app.component('username-password', {
template: '#username-password',
mixins: [window.windowMixin],
props: {
allowed_new_users: Boolean,
authMethods: Array,
@@ -704,7 +679,6 @@ window.app.component('lnbits-stat', {
window.app.component('lnbits-node-qrcode', {
props: ['info'],
mixins: [window.windowMixin],
template: `
<q-card class="my-card">
<q-card-section>
@@ -783,7 +757,6 @@ window.app.component('lnbits-node-info', {
showDialog: false
}
},
mixins: [window.windowMixin],
methods: {
shortenNodeId(nodeId) {
return nodeId
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-assets-config', {
props: ['form-data'],
template: '#lnbits-admin-assets-config',
mixins: [window.windowMixin],
data() {
return {
newAllowedAssetMimeType: '',
@@ -17,6 +16,7 @@ window.app.component('lnbits-admin-assets-config', {
this.newAllowedAssetMimeType
)
this.newAllowedAssetMimeType = ''
this.formData.touch = null
}
},
removeAllowedAssetMimeType(type) {
@@ -24,18 +24,21 @@ window.app.component('lnbits-admin-assets-config', {
if (index !== -1) {
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
}
this.formData.touch = null
},
addNewNoLimitUser() {
if (this.newNoLimitUser) {
this.removeNoLimitUser(this.newNoLimitUser)
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
this.newNoLimitUser = ''
this.formData.touch = null
}
},
removeNoLimitUser(user) {
if (user) {
this.formData.lnbits_assets_no_limit_users =
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
this.formData.touch = null
}
}
}
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-audit', {
props: ['form-data'],
template: '#lnbits-admin-audit',
mixins: [window.windowMixin],
data() {
return {
formAddIncludePath: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-exchange-providers', {
props: ['form-data'],
template: '#lnbits-admin-exchange-providers',
mixins: [window.windowMixin],
data() {
return {
exchangeData: {
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-extensions', {
props: ['form-data'],
template: '#lnbits-admin-extensions',
mixins: [window.windowMixin],
data() {
return {
formAddExtensionsManifest: ''
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-fiat-providers', {
props: ['form-data'],
template: '#lnbits-admin-fiat-providers',
mixins: [window.windowMixin],
data() {
return {
formAddStripeUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-admin-funding-sources', {
template: '#lnbits-admin-funding-sources',
mixins: [window.windowMixin],
props: ['form-data', 'allowed-funding-sources'],
methods: {
getFundingSourceLabel(item) {
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-funding', {
props: ['is-super-user', 'form-data', 'settings'],
template: '#lnbits-admin-funding',
mixins: [window.windowMixin],
data() {
return {
auditData: []
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-notifications', {
props: ['form-data'],
template: '#lnbits-admin-notifications',
mixins: [window.windowMixin],
data() {
return {
nostrNotificationIdentifier: '',
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-security', {
props: ['form-data'],
template: '#lnbits-admin-security',
mixins: [window.windowMixin],
data() {
return {
logs: [],
@@ -9,11 +8,58 @@ window.app.component('lnbits-admin-security', {
serverlogEnabled: false,
nostrAcceptedUrl: '',
formAllowedIPs: '',
formCallbackUrlRule: ''
formCallbackUrlRule: '',
routeOptions: [],
routeOptionsFiltered: [],
routeOptionsLoading: false
}
},
created() {},
created() {
this.loadOpenApiRoutes()
},
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
@@ -1,5 +1,4 @@
window.app.component('lnbits-admin-server', {
props: ['form-data'],
template: '#lnbits-admin-server',
mixins: [window.windowMixin]
template: '#lnbits-admin-server'
})
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-site-customisation', {
props: ['form-data'],
template: '#lnbits-admin-site-customisation',
mixins: [window.windowMixin],
data() {
return {
lnbits_theme_options: [
@@ -1,7 +1,6 @@
window.app.component('lnbits-admin-users', {
props: ['form-data'],
template: '#lnbits-admin-users',
mixins: [window.windowMixin],
data() {
return {
formAddUser: '',
@@ -1,6 +1,5 @@
window.app.component('lnbits-disclaimer', {
template: '#lnbits-disclaimer',
mixins: [window.windowMixin],
computed: {
showDisclaimer() {
return !g.disclaimerShown && g.isUserAuthorized
+1 -2
View File
@@ -1,4 +1,3 @@
window.app.component('lnbits-drawer', {
template: '#lnbits-drawer',
mixins: [window.windowMixin]
template: '#lnbits-drawer'
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-error', {
template: '#lnbits-error',
mixins: [window.windowMixin],
props: ['dynamic', 'code', 'message'],
computed: {
isExtension() {
@@ -1,6 +1,5 @@
window.app.component('lnbits-footer', {
template: '#lnbits-footer',
mixins: [window.windowMixin],
computed: {
version() {
return this.LNBITS_VERSION || 'unknown version'
@@ -1,4 +1,3 @@
window.app.component('lnbits-header-wallets', {
template: '#lnbits-header-wallets',
mixins: [window.windowMixin]
template: '#lnbits-header-wallets'
})
+23 -1
View File
@@ -1,6 +1,5 @@
window.app.component('lnbits-header', {
template: '#lnbits-header',
mixins: [window.windowMixin],
computed: {
hasServiceFeeMax() {
return (
@@ -61,5 +60,28 @@ window.app.component('lnbits-header', {
return 'User'
}
}
},
methods: {
async stopImpersonation() {
try {
await LNbits.api.stopImpersonation()
LNbits.utils.restoreLocalStorage('impersonation')
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,6 +1,5 @@
window.app.component('lnbits-home-logos', {
template: '#lnbits-home-logos',
mixins: [window.windowMixin],
data() {
return {
logos: [
@@ -1,7 +1,6 @@
window.app.component('lnbits-label-selector', {
template: '#lnbits-label-selector',
props: ['labels'],
mixins: [window.windowMixin],
data() {
return {
labelFilter: '',
@@ -1,6 +1,16 @@
window.app.component('lnbits-language-dropdown', {
template: '#lnbits-language-dropdown',
mixins: [window.windowMixin],
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
@@ -9,6 +19,7 @@ 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() {
@@ -1,5 +1,4 @@
window.app.component('lnbits-manage-extension-list', {
mixins: [window.windowMixin],
template: '#lnbits-manage-extension-list',
data() {
return {
@@ -1,6 +1,5 @@
window.app.component('lnbits-manage-wallet-list', {
template: '#lnbits-manage-wallet-list',
mixins: [window.windowMixin],
data() {
return {
activeWalletId: null
@@ -1,7 +1,6 @@
window.app.component('lnbits-payment-list', {
template: '#lnbits-payment-list',
props: ['wallet', 'paymentFilter'],
mixins: [window.windowMixin],
data() {
return {
payments: [],
@@ -1,6 +1,5 @@
window.app.component('lnbits-qrcode-lnurl', {
template: '#lnbits-qrcode-lnurl',
mixins: [window.windowMixin],
props: {
url: {
required: true,
@@ -1,5 +1,4 @@
window.app.component('lnbits-qrcode', {
mixins: [window.windowMixin],
template: '#lnbits-qrcode',
components: {
QrcodeVue: QrcodeVue.default
+11 -1
View File
@@ -1,5 +1,4 @@
window.app.component('lnbits-theme', {
mixins: [window.windowMixin],
watch: {
'g.walletFlip'(val) {
this.$q.localStorage.setItem('lnbits.walletFlip', val)
@@ -10,6 +9,10 @@ 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)
},
@@ -124,6 +127,13 @@ 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()
}
})
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-api-docs', {
template: '#lnbits-wallet-api-docs',
mixins: [window.windowMixin],
methods: {
resetKeys() {
LNbits.utils
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-charts', {
template: '#lnbits-wallet-charts',
mixins: [window.windowMixin],
props: ['paymentFilter', 'chartConfig'],
data() {
return {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-icon', {
template: '#lnbits-wallet-icon',
mixins: [window.windowMixin],
data() {
return {
icon: {
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-new', {
template: '#lnbits-wallet-new',
mixins: [window.windowMixin],
data() {
return {
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-paylinks', {
template: '#lnbits-wallet-paylinks',
mixins: [window.windowMixin],
data() {
return {
storedPaylinks: []
@@ -1,6 +1,5 @@
window.app.component('lnbits-wallet-share', {
template: '#lnbits-wallet-share',
mixins: [window.windowMixin],
computed: {
walletApprovedShares() {
return this.g.wallet.extra.shared_with.filter(
+1
View File
@@ -15,6 +15,7 @@ window.g = Vue.reactive({
wallet: null,
isPublicPage: true,
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'),
offline: !navigator.onLine,
hasCamera: false,
visibleDrawer: false,
+5
View File
@@ -106,6 +106,11 @@ const routes = [
name: 'ExtensionsBuilder',
component: PageExtensionBuilder
},
{
path: '/extensions/builder/preview',
name: 'ExtensionsBuilderPreview',
component: PageExtensionBuilderPreview
},
{
path: '/extensions',
name: 'Extensions',
+33 -16
View File
@@ -1,6 +1,5 @@
window.PageAccount = {
template: '#page-account',
mixins: [window.windowMixin],
data() {
return {
untouchedUser: null,
@@ -40,6 +39,15 @@ window.PageAccount = {
color: 'pink-3'
}
],
defaultSiteCustomisation: {
locale: 'en',
themeChoice: 'salvador',
bgimageChoice: '',
gradientChoice: true,
darkChoice: true,
borderChoice: 'hard-border',
reactionChoice: 'confettiBothSides'
},
reactionOptions: [
'None',
'confettiBothSides',
@@ -214,26 +222,18 @@ 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(
'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
}
)
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
})
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
this.hasUsername = !!data.username
Quasar.Notify.create({
@@ -610,7 +610,7 @@ window.PageAccount = {
}
},
copyAssetLinkToClipboard(asset) {
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/binary`
const assetUrl = `${window.location.origin}/api/v1/assets/${asset.id}/data`
this.utils.copyText(assetUrl)
},
addUserLabel() {
@@ -681,6 +681,23 @@ 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)
}
}
},
+22 -2
View File
@@ -1,6 +1,5 @@
window.PageAdmin = {
template: '#page-admin',
mixins: [windowMixin],
data() {
return {
tab: 'funding',
@@ -9,7 +8,9 @@ window.PageAdmin = {
lnbits_exchange_rate_providers: [],
lnbits_audit_exclude_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,
needsRestart: false
@@ -70,6 +71,25 @@ 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',
-1
View File
@@ -1,6 +1,5 @@
window.PageAudit = {
template: '#page-audit',
mixins: [window.windowMixin],
data() {
return {
chartsReady: false,
+1 -2
View File
@@ -1,4 +1,3 @@
window.PageError = {
template: '#page-error',
mixins: [window.windowMixin]
template: '#page-error'
}
-1
View File
@@ -1,6 +1,5 @@
window.PageExtensions = {
template: '#page-extensions',
mixins: [window.windowMixin],
data() {
return {
extbuilderEnabled: false,
+18 -7
View File
@@ -1,6 +1,5 @@
window.PageExtensionBuilder = {
template: '#page-extension-builder',
mixins: [windowMixin],
data() {
return {
step: 1,
@@ -288,11 +287,10 @@ window.PageExtensionBuilder = {
`/api/v1/extension/${stub_ext_id}/releases`
)
this.extensionStubVersions = data.sort((a, b) =>
a.version < b.version ? 1 : -1
)
this.extensionData.stub_version = this.extensionStubVersions[0]
? this.extensionStubVersions[0].version
this.extensionStubVersions = data
const validVersions = data.filter(v => v.is_version_compatible)
this.extensionData.stub_version = validVersions[0]
? validVersions[0].version
: ''
} catch (error) {
LNbits.utils.notifyApiError(error)
@@ -311,7 +309,20 @@ window.PageExtensionBuilder = {
iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top'
}
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
let componentName =
'Page' +
this.extensionData.id
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
if (previewPageName === 'public_page') componentName += 'Public'
iframe.src =
`/extensions/builder/preview?` +
`ext_id=${this.extensionData.id}` +
`&page=${previewPageName}` +
`&component=${componentName}`
},
initBasicData() {
this.extensionData.owner_data.fields = [
@@ -0,0 +1,46 @@
window.PageExtensionBuilderPreview = {
template: '#page-extension-builder-preview',
mixins: [windowMixin],
watch: {
name: 'reload'
},
data() {
return {
extId: '',
pageName: '',
componentName: null
}
},
methods: {
async reload() {
await LNbits.utils.loadTemplate(
`/extensions/builder/preview/${this.extId}/template?page_name=${this.pageName}`
)
await LNbits.utils.loadScript(
`/extensions/builder/preview/${this.extId}/component?page_name=${this.pageName}`
)
this._component = window[this.componentName]
console.log(
'LNbits preview reloaded componentName:',
this.componentName,
!!this._component
)
this.$forceUpdate()
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search)
this.extId = urlParams.get('ext_id') || ''
this.pageName = urlParams.get('page') || ''
this.componentName = urlParams.get('component') || ''
await this.reload()
},
render() {
if (this._component) {
return Vue.h(this._component)
}
return Vue.h('div', 'Loading...')
}
}
-1
View File
@@ -1,6 +1,5 @@
window.PageFirstInstall = {
template: '#page-first-install',
mixins: [window.windowMixin],
data() {
return {
loginData: {
-1
View File
@@ -1,6 +1,5 @@
window.PageHome = {
template: '#page-home',
mixins: [window.windowMixin],
data() {
return {
lnurl: '',
-1
View File
@@ -1,6 +1,5 @@
window.PageNodePublic = {
template: '#page-node-public',
mixins: [window.windowMixin],
data() {
return {
enabled: false,
-1
View File
@@ -1,5 +1,4 @@
window.PageNode = {
mixins: [window.windowMixin],
template: '#page-node',
config: {
globalProperties: {
-1
View File
@@ -1,6 +1,5 @@
window.PagePayments = {
template: '#page-payments',
mixins: [window.windowMixin],
data() {
return {
payments: [],
+14 -1
View File
@@ -1,6 +1,5 @@
window.PageUsers = {
template: '#page-users',
mixins: [window.windowMixin],
data() {
return {
paymentsWallet: {},
@@ -439,6 +438,20 @@ window.PageUsers = {
this.activeUser.show = false
}
},
async impersonateUser(user_id) {
try {
await LNbits.api.impersonateUser(user_id)
LNbits.utils.backupLocalStorage('impersonation', true)
this.$q.localStorage.setItem('lnbits.disclaimerShown', true)
window.location = '/wallet'
} catch (error) {
console.warn(error)
Quasar.Notify.create({
type: 'warning',
message: 'Failed to impersonate user!'
})
}
},
async showWalletPayments(walletId) {
this.activeUser.show = false
await this.fetchWallets(this.users[0].id)
-1
View File
@@ -1,6 +1,5 @@
window.PageWallet = {
template: '#page-wallet',
mixins: [window.windowMixin],
data() {
return {
parse: {
-1
View File
@@ -1,6 +1,5 @@
window.PageWallets = {
template: '#page-wallets',
mixins: [window.windowMixin],
data() {
return {
user: null,
+52 -11
View File
@@ -67,6 +67,29 @@ window._lnbitsUtils = {
}
})
},
backupLocalStorage(backupKey, cleanup = false) {
const lnbitsEntries =
Object.entries(Quasar.LocalStorage.getAll()).filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
) || []
Quasar.LocalStorage.setItem(`lnbits.${backupKey}`, lnbitsEntries)
if (cleanup) {
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
}
},
restoreLocalStorage(backupKey) {
Object.entries(Quasar.LocalStorage.getAll())
.filter(
([k, v]) => k.startsWith('lnbits.') && k !== `lnbits.${backupKey}`
)
.forEach(([k, v]) => Quasar.LocalStorage.remove(k))
const lnbitsEntries =
Quasar.LocalStorage.getItem(`lnbits.${backupKey}`) || []
lnbitsEntries.forEach(([k, v]) => Quasar.LocalStorage.setItem(k, v))
Quasar.LocalStorage.remove(`lnbits.${backupKey}`)
},
async digestMessage(message) {
const msgUint8 = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
@@ -114,6 +137,13 @@ window._lnbitsUtils = {
formatMsat(value) {
return this.formatSat(value / 1000)
},
parseJSONSafe(str) {
try {
return JSON.parse(str)
} catch (e) {
return null
}
},
notifyApiError(error) {
if (!error.response) {
return console.error(error)
@@ -123,17 +153,28 @@ window._lnbitsUtils = {
401: 'warning',
500: 'negative'
}
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message:
error.response.data.message || error.response.data.detail || null,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null
})
let messages = error.response.data.detail
if (messages) {
messages = Array.isArray(messages)
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
: (messages = [messages])
} else {
messages = [error.response.data.message || error.response.data.detail]
}
messages.forEach(message =>
Quasar.Notify.create({
timeout: 5000,
type: types[error.response.status] || 'warning',
message,
caption:
[error.response.status, ' ', error.response.statusText]
.join('')
.toUpperCase() || null,
icon: null,
closeBtn: true
})
)
},
search(data, q, field, separator) {
try {
+2
View File
@@ -45,6 +45,8 @@
"js/pages/error.js",
"js/pages/home.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js",
"js/pages/first-install.js",
"js/pages/payments.js",
@@ -296,6 +296,56 @@
</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,6 +86,42 @@
></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>
+18 -2
View File
@@ -71,9 +71,11 @@
<span>OFFLINE</span>
</q-badge>
<lnbits-language-dropdown></lnbits-language-dropdown>
<lnbits-language-dropdown
@language-changed="handleLanguageChanged({locale: $event})"
></lnbits-language-dropdown>
<q-btn-dropdown v-if="g.user" flat rounded size="sm" class="q-pl-sm">
<q-btn-dropdown v-if="g.user" flat rounded size="md" class="q-pl-sm">
<template v-slot:label>
<q-avatar
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
@@ -136,6 +138,20 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="g.isUserImpersonated"
@click="stopImpersonation"
rounded
size="sm"
class="q-pl-sm"
color="negative"
icon="face_retouching_off"
label="Stop"
>
<q-tooltip
><span v-text="$t('stop_user_impersonation')"></span
></q-tooltip>
</q-btn>
</q-toolbar>
</q-header>
</template>
@@ -1,5 +1,11 @@
<template id="lnbits-language-dropdown">
<q-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
<template v-slot:label>
<q-item-section>
<q-item-label v-text="currentLanguage.display"></q-item-label>
<q-tooltip><span v-text="currentLanguage.label"></span></q-tooltip>
</q-item-section>
</template>
<q-list v-for="(lang, index) in langs" :key="index">
<q-item
clickable
+45 -18
View File
@@ -333,6 +333,16 @@
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')"
@@ -380,22 +390,11 @@
<span v-text="$t('language')"></span>
</div>
<div class="col-8">
<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>
<lnbits-language-dropdown
@language-changed="
siteCustomisationChanged({locale: $event})
"
/>
</div>
</div>
@@ -407,7 +406,9 @@
<q-btn
v-for="theme in themeOptions"
:key="theme.name"
@click="g.themeChoice = theme.name"
@click="
siteCustomisationChanged({themeChoice: theme.name})
"
:color="theme.color"
dense
flat
@@ -427,6 +428,9 @@
<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
@@ -445,6 +449,9 @@
round
icon="gradient"
v-model="g.gradientChoice"
@update:model-value="
siteCustomisationChanged({gradientChoice: $event})
"
>
<q-tooltip
><span v-text="$t('toggle_gradient')"></span
@@ -463,6 +470,9 @@
flat
round
v-model="g.darkChoice"
@update:model-value="
siteCustomisationChanged({darkChoice: $event})
"
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
size="sm"
>
@@ -481,6 +491,9 @@
v-model="g.borderChoice"
:options="borderOptions"
label="Borders"
@update:model-value="
siteCustomisationChanged({borderChoice: $event})
"
>
<q-tooltip
><span v-text="$t('border_choices')"></span
@@ -508,6 +521,9 @@
v-model="g.reactionChoice"
:options="reactionOptions"
label="Reactions"
@update:model-value="
siteCustomisationChanged({reactionChoice: $event})
"
>
<q-tooltip
><span v-text="$t('payment_reactions')"></span
@@ -515,6 +531,17 @@
</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>
@@ -941,7 +968,7 @@
v-if="props.row.thumbnail_base64"
target="_blank"
style="color: inherit"
:href="`/api/v1/assets/${props.row.id}/binary`"
:href="`/api/v1/assets/${props.row.id}/data`"
>
<q-img
:src="
+14 -1
View File
@@ -656,7 +656,20 @@
</q-btn>
<span v-text="shortify(props.row.id)"></span>
</q-td>
<q-td v-text="props.row.username"></q-td>
<q-td>
<q-btn
icon="face"
size="sm"
flat
class="cursor-pointer q-mr-xs"
@click="impersonateUser(props.row.id)"
>
<q-tooltip
><span v-text="$t('start_user_impersonation')"></span
></q-tooltip>
</q-btn>
<span v-text="props.row.username"></span>
</q-td>
<q-td v-text="props.row.email"></q-td>
+13
View File
@@ -171,6 +171,19 @@
><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
@@ -98,6 +98,8 @@
"js/pages/error.js",
"js/pages/home.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js",
"js/pages/first-install.js",
"js/pages/payments.js",
Generated
+823 -772
View File
File diff suppressed because it is too large Load Diff
+37 -59
View File
@@ -8,31 +8,30 @@ urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbit
readme = "README.md"
dependencies = [
"bech32==1.2.0",
"click==8.2.1",
"ecdsa==0.19.1",
"click==8.3.1",
"fastapi==0.116.1",
"starlette==0.47.1",
"httpx==0.27.0",
"httpx==0.27.2",
"jinja2==3.1.6",
"lnurl==0.8.3",
"pydantic==1.10.22",
"pydantic==1.10.26",
"pyqrcode==1.2.1",
"shortuuid==1.0.13",
"sse-starlette==2.3.6",
"typing-extensions==4.14.0",
"uvicorn==0.34.3",
"typing-extensions==4.15.0",
"uvicorn==0.40.0",
"sqlalchemy==1.4.54",
"aiosqlite==0.21.0",
"asyncpg==0.30.0",
"uvloop==0.21.0",
"aiosqlite==0.22.1",
"asyncpg==0.31.0",
"uvloop==0.22.1",
"websockets==15.0.1",
"loguru==0.7.3",
"grpcio==1.69.0",
"protobuf==5.29.5",
"pyln-client==25.5",
"pywebpush==2.0.3",
"grpcio==1.76.0",
"protobuf==6.33.2",
"pyln-client==25.12",
"pywebpush==2.2.0",
"slowapi==0.1.9",
"websocket-client==1.8.0",
"websocket-client==1.9.0",
"pycryptodomex==3.23.0",
"packaging==25.0",
"bolt11==2.1.1",
@@ -42,14 +41,14 @@ dependencies = [
# needed for boltz, lnurldevice, watchonly extensions
"embit==0.8.0",
# needed for scheduler extension
"python-crontab==3.2.0",
"pynostr==0.6.2",
"python-multipart==0.0.20",
"python-crontab==3.3.0",
"pynostr==0.7.0",
"python-multipart==0.0.21",
"filetype==1.2.0",
"nostr-sdk==0.42.1",
"bcrypt==4.3.0",
"nostr-sdk==0.44.0",
"bcrypt==5.0.0",
"jsonpath-ng==1.7.0",
"pillow>=12.0.0",
"pillow>=12.1.0",
"python-dotenv>=1.2.1",
"greenlet (>=3.3.0,<4.0.0)",
]
@@ -60,30 +59,30 @@ lnbits-cli = "lnbits.commands:main"
[project.optional-dependencies]
breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
liquid = ["wallycore==1.4.0"]
migration = ["psycopg2-binary==2.9.10"]
liquid = ["wallycore==1.5.1"]
migration = ["psycopg2-binary==2.9.11"]
[dependency-groups]
dev = [
"black>=25.1.0,<26.0.0",
"black>=25.12.0,<26.0.0",
"mypy==1.17.1",
"types-protobuf>=6.30.2.20250516,<7.0.0",
"pre-commit>=4.2.0,<5.0.0",
"openapi-spec-validator>=0.7.1,<1.0.0",
"ruff>=0.12.0,<1.0.0",
"types-passlib>=1.7.7.20240327,<2.0.0",
"openai>=1.39.0,<2.0.0",
"json5>=0.12.0,<1.0.0",
"types-protobuf>=6.32.1.20251210,<7.0.0",
"pre-commit>=4.5.1,<5.0.0",
"openapi-spec-validator>=0.7.2,<1.0.0",
"ruff>=0.14.10,<1.0.0",
"types-passlib>=1.7.7.20250602,<2.0.0",
"openai>=2.14.0",
"json5>=0.13.0,<1.0.0",
"asgi-lifespan>=2.1.0,<3.0.0",
"anyio>=4.7.0,<5.0.0",
"pytest>=8.3.4,<9.0.0",
"pytest-cov>=6.0.0,<7.0.0",
"anyio>=4.12.1",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"pytest-md>=0.2.0,<0.3.0",
"pytest-httpserver>=1.1.0,<2.0.0",
"pytest-mock>=3.14.0,<4.0.0",
"types-mock>=5.1.0.20240425,<6.0.0",
"mock>=5.1.0,<6.0.0",
"grpcio-tools>=1.69.0,<2.0.0"
"pytest-httpserver>=1.1.3,<2.0.0",
"pytest-mock>=3.15.1,<4.0.0",
"types-mock>=5.2.0.20250924,<6.0.0",
"mock>=5.2.0,<6.0.0",
"grpcio-tools>=1.76.0,<2.0.0"
]
[tool.poetry]
@@ -92,27 +91,6 @@ packages = [
{include = "lnbits/py.typed"},
]
[tool.poetry.group.dev.dependencies]
black = "^25.1.0"
mypy = "^1.17.1"
types-protobuf = "^6.30.2.20250516"
pre-commit = "^4.2.0"
openapi-spec-validator = "^0.7.1"
ruff = "^0.12.0"
types-passlib = "^1.7.7.20240327"
openai = "^1.39.0"
json5 = "^0.12.0"
asgi-lifespan = "^2.1.0"
anyio = "^4.7.0"
pytest = "^8.3.4"
pytest-cov = "^6.0.0"
pytest-md = "^0.2.0"
pytest-httpserver = "^1.1.0"
pytest-mock = "^3.14.0"
types-mock = "^5.1.0.20240425"
mock = "^5.1.0"
grpcio-tools = "^1.69.0"
[tool.pyright]
include = [
"lnbits",
+2 -2
View File
@@ -245,7 +245,7 @@ async def test_create_invoice_validates_used_currency(
)
assert response.status_code == 400
res_data = response.json()
assert "The provided unit is not supported" in res_data["detail"]
assert "The provided unit is not supported" in res_data["detail"][0]["msg"]
# check POST /api/v1/payments: invoice creation for internal payments only
@@ -823,7 +823,7 @@ async def test_api_payments_pay_lnurl(client, adminkey_headers_from):
"/api/v1/payments/lnurl", json=lnurl_data, headers=adminkey_headers_from
)
assert response.status_code == 400
assert "value_error.url.scheme" in response.json()["detail"]
assert "invalid or missing URL scheme" in response.json()["detail"][0]["msg"]
################################ Labels ################################
+195 -10
View File
@@ -2016,9 +2016,7 @@ async def test_api_update_user_labels(http_client: AsyncClient):
]
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
assert data.extra
response = await http_client.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
assert response.status_code == 200
user_data = response.json()
assert len(user_data["extra"]["labels"]) == 2
@@ -2028,21 +2026,208 @@ 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.put(
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
)
response = await http_client.patch("/api/v1/auth?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.put(
"/api/v1/auth/update?usr=" + user.id, json=json_data
)
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
assert response.status_code == 400
data = response.json()
assert (
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)""" in data["detail"]
"""string does not match regex "([A-Za-z0-9 ._-]{1,100}$)"""
in data["detail"][0]["msg"]
)
@pytest.mark.anyio
async def test_impersonate_user_success(http_client: AsyncClient, admin_user: User):
tiny_id = shortuuid.uuid()[:8]
user_id = uuid4().hex
account = Account(
id=user_id,
username=f"u_{tiny_id}",
email=f"u_{tiny_id}@lnbits.com",
)
account.hash_password("secret1234")
await create_user_account(account)
# Login as admin to get access token
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
assert response.status_code == 200
admin_token = response.json()["access_token"]
# Impersonate the user
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": user_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# Check impersonation cookies
assert "cookie_access_token" in response.cookies
assert "admin_access_token" in response.cookies
assert response.cookies.get("is_lnbits_user_impersonated") == "true"
response = await http_client.delete("/api/v1/auth/impersonate")
assert "cookie_access_token" in response.cookies
assert response.cookies.get("cookie_access_token") == admin_token
assert "admin_access_token" not in response.cookies
assert "is_lnbits_user_impersonated" not in response.cookies
@pytest.mark.anyio
async def test_impersonate_user_no_cookie(http_client: AsyncClient, admin_user: User):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
user_id = uuid4().hex
http_client.cookies.clear()
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": user_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "Only cookie based impersonation is allowed."
@pytest.mark.anyio
async def test_impersonate_user_self(http_client: AsyncClient, admin_user: User):
# Admin tries to impersonate themselves
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": admin_user.id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "You cannot impersonate yourself."
@pytest.mark.anyio
async def test_impersonate_user_admin_target(
http_client: AsyncClient, admin_user: User, settings: Settings
):
other_admin_id = uuid4().hex
tiny_id = shortuuid.uuid()[:8]
account = Account(
id=other_admin_id, username=f"u_{tiny_id}", email=f"u_{tiny_id}@lnbits.com"
)
account.hash_password("secret1234")
await create_user_account(account)
settings.lnbits_admin_users.append(other_admin_id)
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": other_admin_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "You cannot impersonate another admin user."
@pytest.mark.anyio
async def test_impersonate_user_nonexistent(
http_client: AsyncClient, admin_user: User, settings: Settings
):
http_client.cookies.clear()
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
fake_id = "deadbeef"
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": fake_id},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 401
assert response.json()["detail"] == "User ID does not exist."
@pytest.mark.anyio
async def test_impersonate_user_invalid_data(
http_client: AsyncClient, admin_user: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={"usr": None},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
assert "type_error.none.not_allowed" in str(response.json()["detail"])
@pytest.mark.anyio
async def test_impersonate_user_missing_usr_field(
http_client: AsyncClient, admin_user: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": admin_user.username, "password": "secret1234"}
)
admin_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == 400
assert "value_error.missing" in str(response.json()["detail"])
@pytest.mark.anyio
async def test_impersonate_user_by_non_admin(http_client: AsyncClient, user_alan: User):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
alan_token = response.json()["access_token"]
response = await http_client.post(
"/api/v1/auth/impersonate",
json={},
headers={"Authorization": f"Bearer {alan_token}"},
)
assert response.status_code == 403
assert response.json()["detail"] == "User not authorized. No admin privileges."
@pytest.mark.anyio
async def test_stop_impersonate_user_by_non_admin(
http_client: AsyncClient, user_alan: User
):
response = await http_client.post(
"/api/v1/auth", json={"username": user_alan.username, "password": "secret1234"}
)
response = await http_client.delete("/api/v1/auth/impersonate")
assert response.status_code == 401
assert (
response.json()["detail"]
== "No admin access token found to stop impersonation."
)
+5 -5
View File
@@ -14,9 +14,9 @@ from lnbits.core.crud import (
delete_account,
get_account_by_username,
get_payment,
get_user,
update_payment,
)
from lnbits.core.crud.users import get_user_from_account
from lnbits.core.models import Account, CreateInvoice, PaymentState, User
from lnbits.core.models.users import UpdateSuperuserPassword
from lnbits.core.services import create_user_account, update_wallet_balance
@@ -114,10 +114,10 @@ async def user_alan():
@pytest.fixture(scope="session")
async def admin_user():
username = "admin"
account = await get_account_by_username(username)
if account:
return await get_user_from_account(account)
username = "admin_" + uuid4().hex[:8]
user = await get_user(ADMIN_USER_ID)
if user:
return user
account = Account(
id=ADMIN_USER_ID,
+22 -1
View File
@@ -1,4 +1,4 @@
from datetime import date
from datetime import date, timezone
import pytest
@@ -8,6 +8,9 @@ from lnbits.core.crud import (
get_wallet,
get_wallet_for_key,
)
from lnbits.core.crud.payments import get_payment
from lnbits.core.models import CreateInvoice
from lnbits.core.services.payments import create_wallet_invoice
from lnbits.db import POSTGRES
@@ -18,6 +21,24 @@ async def test_date_conversion(db):
assert row and isinstance(row.get("now"), date)
@pytest.mark.anyio
async def test_payment_datetime_fields_have_timezone(app, to_user):
"""Test that Payment datetime fields always have UTC timezone info."""
wallet = await create_wallet(user_id=to_user.id, wallet_name="test_tz_wallet")
invoice_data = CreateInvoice(amount=10, memo="timezone_test", out=False)
invoice = await create_wallet_invoice(wallet.id, invoice_data)
payment = await get_payment(invoice.checking_id)
assert payment is not None
# All datetime fields should have UTC timezone info
assert payment.time.tzinfo == timezone.utc
assert payment.created_at.tzinfo == timezone.utc
assert payment.updated_at.tzinfo == timezone.utc
if payment.expiry:
assert payment.expiry.tzinfo == timezone.utc
# make test to create wallet and delete wallet
@pytest.mark.anyio
async def test_create_wallet_and_delete_wallet(app, to_user):
+60 -20
View File
@@ -100,8 +100,8 @@ def insert_to_pg(query, data):
logger.error(exc)
logger.error(f"Failed to insert {d}")
else:
logger.error("query:", query)
logger.error("data:", d)
logger.error("query: " + query)
logger.error("data: " + str(d))
raise ValueError(f"Failed to insert {d}") from exc
connection.commit()
@@ -125,6 +125,7 @@ def migrate_ext(file: str):
migrate_db(file, schema)
logger.info(f"✅ Migrated ext: {schema}")
except Exception as exc:
logger.error(exc)
logger.error(f"🛑 Failed to migrate extension {schema}: {exc}")
@@ -134,8 +135,8 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
exclude_tables = []
assert os.path.isfile(file), f"{file} does not exist!"
cursor = get_sqlite_cursor(file)
tables = cursor.execute(
sqlite_cursor = get_sqlite_cursor(file)
tables = sqlite_cursor.execute(
"""
SELECT name FROM sqlite_master
WHERE type='table' AND name not like 'sqlite?_%' escape '?'
@@ -151,16 +152,18 @@ def migrate_db(file: str, schema: str, exclude_tables: list[str] | None = None):
if exclude_tables and table_name in exclude_tables:
continue
columns = cursor.execute(f"PRAGMA table_info({table_name})").fetchall()
columns = build_table_columns(file, schema, table_name)
q = build_insert_query(schema, table_name, columns)
data = cursor.execute(f"SELECT * FROM {table_name};").fetchall()
data = sqlite_cursor.execute(f"SELECT * FROM {table_name};").fetchall()
if len(data) == 0:
logger.warning(f"🛑 You sneaky dev! Table {table_name} is empty!")
logger.warning(f"⚠️ You sneaky dev! Table {table_name} is empty!")
continue
insert_to_pg(q, data)
cursor.close()
logger.info(f"✅ Migrated table '{schema}.{table_name}' successfully")
sqlite_cursor.close()
def build_insert_query(schema, table_name, columns):
@@ -174,6 +177,30 @@ def build_insert_query(schema, table_name, columns):
"""
def build_table_columns(file: str, schema: str, table_name: str):
sqlite_cursor = get_sqlite_cursor(file)
pg_cursor = get_postgres_cursor()
sqlite_columns = sqlite_cursor.execute(
f"PRAGMA table_info({table_name})"
).fetchall()
pg_cursor.execute(
f"""
SELECT table_name, column_name, udt_name FROM information_schema.columns
WHERE table_schema = '{schema}'AND table_name = '{table_name}';"""
)
pg_columns = pg_cursor.fetchall()
columns = []
for sqlite_col in sqlite_columns:
for pg_col in pg_columns:
if sqlite_col[1].lower() == pg_col[1].lower():
columns.append((sqlite_col[0], sqlite_col[1], pg_col[2]))
break
sqlite_cursor.close()
return columns
def build_on_conflict_query_statement(schema, table_name, columns):
unique_cols = table_unique_columns(schema, table_name)
if len(unique_cols) == 0:
@@ -191,23 +218,36 @@ def build_on_conflict_query_statement(schema, table_name, columns):
def table_unique_columns(schema, table_name):
cursor = get_postgres_cursor()
query = f"""
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid
AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = '{schema}.{table_name}'::regclass
AND i.indisunique;
SELECT
array_agg(a.attname ORDER BY a.attnum) AS columns,
i.indisprimary as is_primary,
i.indexrelid::regclass AS index_name,
COUNT(*) AS column_count,
(COUNT(*) = 1) AS is_individual
FROM pg_index i
JOIN pg_attribute a
ON a.attrelid = i.indrelid
AND a.attnum = ANY (i.indkey)
WHERE i.indrelid = '{schema}.{table_name}'::regclass
AND i.indisunique
GROUP BY i.indexrelid;
"""
cursor.execute(query)
columns = [row[0] for row in cursor.fetchall()]
rows = cursor.fetchall()
columns = [row[0] for row in rows if not row[1]] # exclude primary keys
if len(columns) == 0:
# use primary keys if no unique keys found
columns = [row[0] for row in rows if row[1]]
cursor.close()
return columns
if len(columns) == 0:
return []
return columns[0]
def to_column_type(column_type):
if column_type == "TIMESTAMP":
def to_column_type(column_type: str):
if column_type.upper() == "TIMESTAMP":
return "to_timestamp(%s)"
if column_type in ["BOOLEAN", "BOOL"]:
if column_type.upper() in ["BOOLEAN", "BOOL"]:
return "%s::boolean"
return "%s"
@@ -255,7 +295,7 @@ parser.add_argument(
args = parser.parse_args()
logger.info("Selected path: ", args.sqlite_path)
logger.info("Selected path: " + args.sqlite_path)
if os.path.isdir(args.sqlite_path):
exclude_tables = ["dbversions"]
Generated
+516 -532
View File
File diff suppressed because it is too large Load Diff