Compare commits
20
Commits
dev
...
route_guard
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
962a09bb30 | ||
|
|
92c86eb837 | ||
|
|
dadff18a9c | ||
|
|
87c1684a63 | ||
|
|
91354f8be4 | ||
|
|
5f5d45e89f | ||
|
|
52bb125a25 | ||
|
|
2ca1ed897c | ||
|
|
631f4e8455 | ||
|
|
b22f88b264 | ||
|
|
c08bc02930 | ||
|
|
e020a7c5a0 | ||
|
|
d2e8914835 | ||
|
|
5e79ca35ab | ||
|
|
4690d178a2 | ||
|
|
c5825aa4c3 | ||
|
|
ebe0c4e3c3 | ||
|
|
383bdb6312 | ||
|
|
a947686d79 | ||
|
|
28e9d32b44 |
@@ -44,15 +44,19 @@ jobs:
|
|||||||
if: github.repository == 'lnbits/lnbits'
|
if: github.repository == 'lnbits/lnbits'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies for building secp256k1
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build and publish to pypi
|
- name: Set up Python 3.10
|
||||||
uses: JRubics/poetry-publish@v1.15
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
python-version: "3.10"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
- name: Build the project
|
||||||
|
run: uv build
|
||||||
|
- name: Publish to pypi
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||||
|
run: uv publish
|
||||||
|
|
||||||
appimage:
|
appimage:
|
||||||
needs: [ release ]
|
needs: [ release ]
|
||||||
|
|||||||
@@ -56,15 +56,19 @@ jobs:
|
|||||||
if: github.repository == 'lnbits/lnbits'
|
if: github.repository == 'lnbits/lnbits'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies for building secp256k1
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y build-essential automake libtool libffi-dev libgmp-dev
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Build and publish to pypi
|
- name: Set up Python 3.10
|
||||||
uses: JRubics/poetry-publish@v1.15
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
pypi_token: ${{ secrets.PYPI_API_KEY }}
|
python-version: "3.10"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
- name: Build the project
|
||||||
|
run: uv build
|
||||||
|
- name: Publish to pypi
|
||||||
|
env:
|
||||||
|
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}
|
||||||
|
run: uv publish
|
||||||
|
|
||||||
appimage:
|
appimage:
|
||||||
needs: [ release ]
|
needs: [ release ]
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ from .middleware import (
|
|||||||
add_first_install_middleware,
|
add_first_install_middleware,
|
||||||
add_ip_block_middleware,
|
add_ip_block_middleware,
|
||||||
add_ratelimit_middleware,
|
add_ratelimit_middleware,
|
||||||
|
add_route_access_middleware,
|
||||||
)
|
)
|
||||||
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
from .tasks import internal_invoice_listener, invoice_listener, run_interval
|
||||||
|
|
||||||
@@ -192,6 +193,7 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
# adds security middleware
|
# adds security middleware
|
||||||
add_ip_block_middleware(app)
|
add_ip_block_middleware(app)
|
||||||
|
add_route_access_middleware(app)
|
||||||
add_ratelimit_middleware(app)
|
add_ratelimit_middleware(app)
|
||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ async def get_user_from_account(
|
|||||||
super_user=account.is_super_user,
|
super_user=account.is_super_user,
|
||||||
fiat_providers=account.fiat_providers,
|
fiat_providers=account.fiat_providers,
|
||||||
has_password=account.password_hash is not None,
|
has_password=account.password_hash is not None,
|
||||||
|
ui_customization=account.ui_customization or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -848,3 +848,11 @@ async def m042_index_accounts(db: Connection):
|
|||||||
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
CREATE INDEX IF NOT EXISTS idx_accounts_{index} ON accounts ("{index}");
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def m043_add_ui_customization_to_accounts(db: Connection):
|
||||||
|
"""
|
||||||
|
Adds ui_customization column to accounts.
|
||||||
|
Used for server side persistence of UI customization settings.
|
||||||
|
"""
|
||||||
|
await db.execute("ALTER TABLE accounts ADD COLUMN ui_customization TEXT")
|
||||||
|
|||||||
@@ -269,10 +269,30 @@ class ExtensionRelease(BaseModel):
|
|||||||
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
|
||||||
try:
|
try:
|
||||||
github_releases = await cls.fetch_github_releases(org, repo)
|
github_releases = await cls.fetch_github_releases(org, repo)
|
||||||
return [
|
extension_releases = [
|
||||||
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
ExtensionRelease.from_github_release(f"{org}/{repo}", r)
|
||||||
for r in github_releases
|
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:
|
except Exception as e:
|
||||||
logger.warning(e)
|
logger.warning(e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -55,6 +57,29 @@ class DataField(BaseModel):
|
|||||||
field_type += ' = "sat"'
|
field_type += ' = "sat"'
|
||||||
return f"{field_name}: {field_type}"
|
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:
|
def field_to_js(self) -> str:
|
||||||
field_name = camel_to_snake(self.name)
|
field_name = camel_to_snake(self.name)
|
||||||
default_value = "null"
|
default_value = "null"
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class Account(AccountId):
|
|||||||
pubkey: str | None = None
|
pubkey: str | None = None
|
||||||
email: str | None = None
|
email: str | None = None
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
ui_customization: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||||
@@ -288,6 +289,7 @@ class User(BaseModel):
|
|||||||
fiat_providers: list[str] = []
|
fiat_providers: list[str] = []
|
||||||
has_password: bool = False
|
has_password: bool = False
|
||||||
extra: UserExtra = UserExtra()
|
extra: UserExtra = UserExtra()
|
||||||
|
ui_customization: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_ids(self) -> list[str]:
|
def wallet_ids(self) -> list[str]:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import io
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
from loguru import logger
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from lnbits.core.crud.assets import create_asset, get_user_assets_count
|
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."
|
f"File limit of {settings.lnbits_max_asset_size_mb}MB exceeded."
|
||||||
)
|
)
|
||||||
|
|
||||||
image = Image.open(io.BytesIO(contents))
|
thumb_buffer = thumbnail_from_bytes(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)
|
|
||||||
|
|
||||||
asset = Asset(
|
asset = Asset(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
@@ -48,9 +39,32 @@ async def create_user_asset(user_id: str, file: UploadFile, is_public: bool) ->
|
|||||||
is_public=is_public,
|
is_public=is_public,
|
||||||
name=file.filename or "unnamed",
|
name=file.filename or "unnamed",
|
||||||
size_bytes=len(contents),
|
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,
|
data=contents,
|
||||||
)
|
)
|
||||||
|
|
||||||
await create_asset(asset)
|
await create_asset(asset)
|
||||||
return 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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from lnbits.helpers import (
|
|||||||
camel_to_words,
|
camel_to_words,
|
||||||
download_url,
|
download_url,
|
||||||
lowercase_first_letter,
|
lowercase_first_letter,
|
||||||
|
snake_to_camel,
|
||||||
)
|
)
|
||||||
from lnbits.settings import settings
|
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)
|
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
|
||||||
shutil.rmtree(ext_build_dir, True)
|
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
|
return ext_build_dir
|
||||||
|
|
||||||
|
|
||||||
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
|
||||||
parsed_data = _parse_extension_data(data)
|
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()
|
template_path = Path(ext_stub_dir, py_file).as_posix()
|
||||||
rederer = _render_file(template_path, parsed_data)
|
rederer = _render_file(template_path, parsed_data)
|
||||||
with open(template_path, "w", encoding="utf-8") as f:
|
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)
|
_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(
|
rederer = _render_file(
|
||||||
template_path, {"preview": data.preview_action, **parsed_data}
|
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",
|
"settingsFormDialog.data",
|
||||||
ext_stub_dir,
|
ext_stub_dir,
|
||||||
)
|
)
|
||||||
template_path = Path(
|
template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
|
||||||
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
|
|
||||||
).as_posix()
|
|
||||||
rederer = _render_file(
|
rederer = _render_file(
|
||||||
template_path,
|
template_path,
|
||||||
{
|
{
|
||||||
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
|
|||||||
"publicClientData",
|
"publicClientData",
|
||||||
ext_stub_dir,
|
ext_stub_dir,
|
||||||
)
|
)
|
||||||
public_template_path = Path(
|
public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
|
||||||
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
|
public_component_path = Path(ext_stub_dir, "static", "public_page.js")
|
||||||
)
|
|
||||||
template_path = public_template_path.as_posix()
|
template_path = public_template_path.as_posix()
|
||||||
if not data.public_page.has_public_page:
|
if not data.public_page.has_public_page:
|
||||||
public_template_path.unlink(missing_ok=True)
|
public_template_path.unlink(missing_ok=True)
|
||||||
|
public_component_path.unlink(missing_ok=True)
|
||||||
else:
|
else:
|
||||||
rederer = _render_file(
|
rederer = _render_file(
|
||||||
template_path,
|
template_path,
|
||||||
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
|
|||||||
|
|
||||||
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
|
||||||
extension_dir_path = extension_dir.as_posix()
|
extension_dir_path = extension_dir.as_posix()
|
||||||
|
# the order of fields is important, do not chage
|
||||||
rename_values = {
|
rename_values = {
|
||||||
"extension_builder_stub_name": data.name,
|
"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_short_description": data.short_description or "",
|
||||||
"extension_builder_stub": data.id,
|
"extension_builder_stub": data.id,
|
||||||
"OwnerData": data.owner_data.name,
|
"OwnerData": data.owner_data.name,
|
||||||
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
|
|||||||
directory=extension_dir_path,
|
directory=extension_dir_path,
|
||||||
old_text=old_text,
|
old_text=old_text,
|
||||||
new_text=new_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(
|
_rename_files_and_dirs_in_directory(
|
||||||
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
|
|||||||
"owner_data": {
|
"owner_data": {
|
||||||
"name": data.owner_data.name,
|
"name": data.owner_data.name,
|
||||||
"editable": data.owner_data.editable,
|
"editable": data.owner_data.editable,
|
||||||
|
"fields": [field.name for field in data.owner_data.fields],
|
||||||
"js_fields": [
|
"js_fields": [
|
||||||
field.field_to_js()
|
field.field_to_js()
|
||||||
for field in data.owner_data.fields
|
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],
|
"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],
|
"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": {
|
"client_data": {
|
||||||
"name": data.client_data.name,
|
"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],
|
"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],
|
"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": {
|
"settings_data": {
|
||||||
"enabled": data.settings_data.enabled,
|
"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}")
|
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:
|
for excluded_dir in excluded_dirs:
|
||||||
if path.startswith(excluded_dir):
|
if path.startswith(excluded_dir):
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -57,11 +57,11 @@ async def api_get_asset(
|
|||||||
|
|
||||||
|
|
||||||
@asset_router.get(
|
@asset_router.get(
|
||||||
"/{asset_id}/binary",
|
"/{asset_id}/data",
|
||||||
name="Get user asset binary",
|
name="Get user asset data",
|
||||||
summary="Get user asset binary data by ID",
|
summary="Get user asset data data by ID",
|
||||||
)
|
)
|
||||||
async def api_get_asset_binary(
|
async def api_get_asset_data(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
user_id: str | None = Depends(optional_user_id),
|
user_id: str | None = Depends(optional_user_id),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import json
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from time import time
|
from time import time
|
||||||
|
from typing import Annotated
|
||||||
from uuid import uuid4
|
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.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi_sso.sso.base import OpenID, SSOBase
|
from fastapi_sso.sso.base import OpenID, SSOBase
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -29,6 +30,7 @@ from lnbits.core.services.users import update_user_account
|
|||||||
from lnbits.decorators import (
|
from lnbits.decorators import (
|
||||||
access_token_payload,
|
access_token_payload,
|
||||||
check_account_exists,
|
check_account_exists,
|
||||||
|
check_admin,
|
||||||
check_user_exists,
|
check_user_exists,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import (
|
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)
|
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")
|
@auth_router.get("/acl")
|
||||||
async def api_get_user_acls(
|
async def api_get_user_acls(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -408,7 +467,7 @@ async def reset_password(data: ResetUserPassword) -> JSONResponse:
|
|||||||
return _auth_success_response(account.username, user_id, account.email)
|
return _auth_success_response(account.username, user_id, account.email)
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/update")
|
@auth_router.patch("")
|
||||||
async def update(
|
async def update(
|
||||||
data: UpdateUser, account: Account = Depends(check_account_exists)
|
data: UpdateUser, account: Account = Depends(check_account_exists)
|
||||||
) -> User | None:
|
) -> User | None:
|
||||||
@@ -424,6 +483,22 @@ async def update(
|
|||||||
return await get_user_from_account(account)
|
return await get_user_from_account(account)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.patch("/ui")
|
||||||
|
async def update_ui_customization(
|
||||||
|
req: Request, account: Account = Depends(check_account_exists)
|
||||||
|
) -> Account:
|
||||||
|
ui_customization = await req.json()
|
||||||
|
account.ui_customization = {**(account.ui_customization or {}), **ui_customization}
|
||||||
|
|
||||||
|
if len(account.ui_customization or {}) > 1000 * 1024:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.BAD_REQUEST, "UI customization too large. Drop some fields."
|
||||||
|
)
|
||||||
|
|
||||||
|
await update_user_account(account)
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
@auth_router.put("/first_install")
|
@auth_router.put("/first_install")
|
||||||
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
async def first_install(data: UpdateSuperuserPassword) -> JSONResponse:
|
||||||
if not settings.first_install:
|
if not settings.first_install:
|
||||||
|
|||||||
@@ -42,54 +42,38 @@ async def robots():
|
|||||||
|
|
||||||
|
|
||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
"/extensions/builder/preview/{ext_id}",
|
"/extensions/builder/preview/{ext_id}/{resource}",
|
||||||
name="extensions builder",
|
name="extensions builder",
|
||||||
dependencies=[Depends(check_extension_builder)],
|
dependencies=[Depends(check_extension_builder)],
|
||||||
)
|
)
|
||||||
async def extensions_builder_preview(
|
async def extensions_builder_preview(
|
||||||
request: Request,
|
|
||||||
ext_id: str,
|
ext_id: str,
|
||||||
|
resource: str | None = None,
|
||||||
page_name: str | None = None,
|
page_name: str | None = None,
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
) -> HTMLResponse:
|
) -> FileResponse:
|
||||||
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
|
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",
|
"extension_builder_stub",
|
||||||
ext_id,
|
ext_id,
|
||||||
working_dir_name,
|
working_dir_name,
|
||||||
ext_id,
|
ext_id,
|
||||||
"templates",
|
"static",
|
||||||
ext_id,
|
|
||||||
html_file_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
html_file_full_path = Path(
|
file_ext = ".vue" if resource == "template" else ".js"
|
||||||
settings.extension_builder_working_dir_path, html_file_path
|
file_full_path = Path(resource_path, file_name + file_ext)
|
||||||
)
|
|
||||||
|
|
||||||
if not html_file_full_path.is_file():
|
if not file_full_path.is_file():
|
||||||
return template_renderer().TemplateResponse(
|
raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
|
||||||
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(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
response = FileResponse(file_full_path.absolute().as_posix())
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
@@ -208,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
|
|||||||
@generic_router.get(
|
@generic_router.get(
|
||||||
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
"/extensions/builder", dependencies=[Depends(check_extension_builder)]
|
||||||
)
|
)
|
||||||
|
@generic_router.get(
|
||||||
|
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
|
||||||
|
)
|
||||||
async def index(
|
async def index(
|
||||||
request: Request, user: User = Depends(check_user_exists)
|
request: Request, user: User = Depends(check_user_exists)
|
||||||
) -> HTMLResponse:
|
) -> HTMLResponse:
|
||||||
|
|||||||
+7
-2
@@ -337,7 +337,8 @@ class Database(Compat):
|
|||||||
f = "%Y-%m-%d %H:%M:%S.%f"
|
f = "%Y-%m-%d %H:%M:%S.%f"
|
||||||
if "." not in value:
|
if "." not in value:
|
||||||
f = "%Y-%m-%d %H:%M:%S"
|
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(
|
dbapi_connection.run_async(
|
||||||
lambda connection: connection.set_type_codec(
|
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:
|
if DB_TYPE == SQLITE:
|
||||||
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
_dict[key] = datetime.fromtimestamp(value, timezone.utc)
|
||||||
else:
|
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
|
continue
|
||||||
if issubclass(type_, BaseModel):
|
if issubclass(type_, BaseModel):
|
||||||
_dict[key] = dict_to_submodel(type_, value)
|
_dict[key] = dict_to_submodel(type_, value)
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ def register_exception_handlers(app: FastAPI): # noqa: C901
|
|||||||
logger.error(f"RequestValidationError: {exc!s}")
|
logger.error(f"RequestValidationError: {exc!s}")
|
||||||
return render_html_error(request, exc) or JSONResponse(
|
return render_html_error(request, exc) or JSONResponse(
|
||||||
status_code=HTTPStatus.BAD_REQUEST,
|
status_code=HTTPStatus.BAD_REQUEST,
|
||||||
content={"detail": str(exc)},
|
content={"detail": [dict(e) for e in exc.errors()]},
|
||||||
)
|
)
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
|
|||||||
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
"LNBITS_SERVICE_FEE_WALLET": settings.lnbits_service_fee_wallet,
|
||||||
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
"LNBITS_SHOW_HOME_PAGE_ELEMENTS": settings.lnbits_show_home_page_elements,
|
||||||
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
"LNBITS_THEME_OPTIONS": settings.lnbits_theme_options,
|
||||||
|
"WALLET_FEATURED_BUTTON_LABEL": settings.lnbits_wallet_featured_button_label,
|
||||||
|
"WALLET_FEATURED_BUTTON_URL": settings.lnbits_wallet_featured_button_url,
|
||||||
|
"WALLET_FEATURED_BUTTON_ICON": settings.lnbits_wallet_featured_button_icon,
|
||||||
"LNBITS_VERSION": settings.version,
|
"LNBITS_VERSION": settings.version,
|
||||||
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
"USE_CUSTOM_LOGO": settings.lnbits_custom_logo,
|
||||||
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
"LNBITS_DEFAULT_REACTION": settings.lnbits_default_reaction,
|
||||||
@@ -391,6 +394,14 @@ def camel_to_snake(name: str) -> str:
|
|||||||
return name.lower()
|
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:
|
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
|
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -18,6 +19,53 @@ from lnbits.core.models import AuditEntry
|
|||||||
from lnbits.helpers import normalize_path, template_renderer
|
from lnbits.helpers import normalize_path, template_renderer
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
|
_LOCALHOST_IPS = {"127.0.0.1", "::1"}
|
||||||
|
_PUBLIC_ASSET_PATHS = {
|
||||||
|
"/favicon.ico",
|
||||||
|
"/service-worker.js",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_match_path(path: str) -> str:
|
||||||
|
if not path:
|
||||||
|
return "/"
|
||||||
|
if path != "/" and path.endswith("/"):
|
||||||
|
return path.rstrip("/")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _route_pattern_matches(pattern: str, path: str) -> bool:
|
||||||
|
if not pattern:
|
||||||
|
return False
|
||||||
|
if not pattern.startswith("/"):
|
||||||
|
pattern = f"/{pattern}"
|
||||||
|
pattern = _normalize_match_path(pattern)
|
||||||
|
path = _normalize_match_path(path)
|
||||||
|
if pattern.endswith("*"):
|
||||||
|
prefix = pattern.rstrip("*")
|
||||||
|
return path.startswith(prefix)
|
||||||
|
if pattern == path:
|
||||||
|
return True
|
||||||
|
escaped = re.escape(pattern)
|
||||||
|
escaped = re.sub(r"\\\{[^/]+\\\}", r"[^/]+", escaped)
|
||||||
|
return re.fullmatch(escaped, path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_by_accepted_type(request: Request, msg: str, status_code: HTTPStatus):
|
||||||
|
accept_header = request.headers.get("accept", "")
|
||||||
|
if "text/html" in accept_header.split(","):
|
||||||
|
return HTMLResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content=template_renderer()
|
||||||
|
.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"error.html",
|
||||||
|
{"err": msg, "status_code": status_code, "message": msg},
|
||||||
|
)
|
||||||
|
.body,
|
||||||
|
)
|
||||||
|
return JSONResponse(status_code=status_code, content={"detail": msg})
|
||||||
|
|
||||||
|
|
||||||
class InstalledExtensionMiddleware:
|
class InstalledExtensionMiddleware:
|
||||||
# This middleware class intercepts calls made to the extensions API and:
|
# This middleware class intercepts calls made to the extensions API and:
|
||||||
@@ -235,6 +283,42 @@ def add_ip_block_middleware(app: FastAPI):
|
|||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
def add_route_access_middleware(app: FastAPI):
|
||||||
|
@app.middleware("http")
|
||||||
|
async def route_access_middleware(request: Request, call_next):
|
||||||
|
if not settings.lnbits_route_access_control_enabled:
|
||||||
|
return await call_next(request)
|
||||||
|
if not request.client:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=HTTPStatus.FORBIDDEN,
|
||||||
|
content={"detail": "No request client"},
|
||||||
|
)
|
||||||
|
if request.client.host in _LOCALHOST_IPS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path or "/"
|
||||||
|
if "/static/" in path or path in _PUBLIC_ASSET_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
whitelist = settings.lnbits_route_access_whitelist
|
||||||
|
blacklist = settings.lnbits_route_access_blacklist
|
||||||
|
|
||||||
|
if whitelist:
|
||||||
|
if any(_route_pattern_matches(route, path) for route in whitelist):
|
||||||
|
return await call_next(request)
|
||||||
|
return _response_by_accepted_type(
|
||||||
|
request, f"Route not whitelisted: {path}", HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
if blacklist and any(
|
||||||
|
_route_pattern_matches(route, path) for route in blacklist
|
||||||
|
):
|
||||||
|
return _response_by_accepted_type(
|
||||||
|
request, f"Route is blacklisted: {path}", HTTPStatus.FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
def add_first_install_middleware(app: FastAPI):
|
def add_first_install_middleware(app: FastAPI):
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def first_install_middleware(request: Request, call_next):
|
async def first_install_middleware(request: Request, call_next):
|
||||||
|
|||||||
@@ -252,6 +252,11 @@ class ThemesSettings(LNbitsSettings):
|
|||||||
)
|
)
|
||||||
lnbits_show_home_page_elements: bool = Field(default=True)
|
lnbits_show_home_page_elements: bool = Field(default=True)
|
||||||
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
lnbits_default_wallet_name: str = Field(default="LNbits wallet")
|
||||||
|
|
||||||
|
lnbits_wallet_featured_button_label: str | None = Field(default=None)
|
||||||
|
lnbits_wallet_featured_button_url: str | None = Field(default=None)
|
||||||
|
lnbits_wallet_featured_button_icon: str | None = Field(default=None)
|
||||||
|
|
||||||
lnbits_custom_badge: str | None = Field(default=None)
|
lnbits_custom_badge: str | None = Field(default=None)
|
||||||
lnbits_custom_badge_color: str = Field(default="warning")
|
lnbits_custom_badge_color: str = Field(default="warning")
|
||||||
lnbits_theme_options: list[str] = Field(
|
lnbits_theme_options: list[str] = Field(
|
||||||
@@ -306,6 +311,10 @@ class AssetSettings(LNbitsSettings):
|
|||||||
"heic",
|
"heic",
|
||||||
"heif",
|
"heif",
|
||||||
"heics",
|
"heics",
|
||||||
|
"text/plain",
|
||||||
|
"text/json" "text/xml",
|
||||||
|
"application/json",
|
||||||
|
"application/pdf",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
lnbits_asset_thumbnail_width: int = Field(default=128, ge=0)
|
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_rate_limit_unit: str = Field(default="minute")
|
||||||
lnbits_allowed_ips: list[str] = Field(default=[])
|
lnbits_allowed_ips: list[str] = Field(default=[])
|
||||||
lnbits_blocked_ips: list[str] = Field(default=[])
|
lnbits_blocked_ips: list[str] = Field(default=[])
|
||||||
|
lnbits_route_access_control_enabled: bool = Field(default=False)
|
||||||
|
lnbits_route_access_whitelist: list[str] = Field(default=[])
|
||||||
|
lnbits_route_access_blacklist: list[str] = Field(default=[])
|
||||||
lnbits_callback_url_rules: list[str] = Field(
|
lnbits_callback_url_rules: list[str] = Field(
|
||||||
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
default=["https?://([a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})(:\\d+)?"]
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
-10
File diff suppressed because one or more lines are too long
@@ -363,6 +363,18 @@ window.localisation.en = {
|
|||||||
watchdog: 'Watchdog',
|
watchdog: 'Watchdog',
|
||||||
server_logs: 'Server Logs',
|
server_logs: 'Server Logs',
|
||||||
ip_blocker: 'IP Blocker',
|
ip_blocker: 'IP Blocker',
|
||||||
|
route_access_control: 'Route Access Control',
|
||||||
|
route_access_control_enable: 'Enable route access control',
|
||||||
|
route_access_control_hint:
|
||||||
|
'When enabled, non-local requests are restricted. Requests from 127.0.0.1 (and ::1) are always allowed.',
|
||||||
|
route_access_whitelist_label: 'Route Whitelist',
|
||||||
|
route_access_whitelist_hint:
|
||||||
|
'Only allow non-local access to these routes (leave empty to disable whitelist mode).',
|
||||||
|
route_access_blacklist_label: 'Route Blacklist',
|
||||||
|
route_access_blacklist_hint:
|
||||||
|
'Block non-local access to these routes (ignored when a whitelist is set).',
|
||||||
|
route_access_save_confirm:
|
||||||
|
'Are you sure you want to save? This can impact access to LNbits if you are not accessing locally.',
|
||||||
security: 'Security',
|
security: 'Security',
|
||||||
security_tools: 'Security tools',
|
security_tools: 'Security tools',
|
||||||
block_access_hint: 'Block access by IP',
|
block_access_hint: 'Block access by IP',
|
||||||
@@ -658,6 +670,15 @@ window.localisation.en = {
|
|||||||
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
|
ui_site_description_hint: 'Use plain text, Markdown, or raw HTML',
|
||||||
ui_default_wallet_name: 'Default Wallet Name',
|
ui_default_wallet_name: 'Default Wallet Name',
|
||||||
ui_default_theme: 'Default Theme',
|
ui_default_theme: 'Default Theme',
|
||||||
|
wallet_featured_button_label: 'Wallet Featured Button Label',
|
||||||
|
wallet_featured_button_label_hint:
|
||||||
|
'Show featured button on the wallet homepage',
|
||||||
|
wallet_featured_button_url: 'Featured Button URL',
|
||||||
|
wallet_featured_button_url_hint:
|
||||||
|
'On click the button will open this URL. Leave empty to hide the button.',
|
||||||
|
wallet_featured_button_icon: 'Featured Button Icon',
|
||||||
|
wallet_featured_button_icon_hint:
|
||||||
|
'Icon shown on the featured button (check quasar icons)',
|
||||||
lnbits_wallet: 'LNbits wallet',
|
lnbits_wallet: 'LNbits wallet',
|
||||||
denomination: 'Denomination',
|
denomination: 'Denomination',
|
||||||
denomination_hint: 'The name for the FakeWallet token',
|
denomination_hint: 'The name for the FakeWallet token',
|
||||||
@@ -695,6 +716,8 @@ window.localisation.en = {
|
|||||||
allow_creation_user: 'Allow creation of new users',
|
allow_creation_user: 'Allow creation of new users',
|
||||||
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
allow_creation_user_desc: 'Allow creation of new users on the index page',
|
||||||
new_user_not_allowed: 'Registration is disabled.',
|
new_user_not_allowed: 'Registration is disabled.',
|
||||||
|
start_user_impersonation: 'Impersonate this user',
|
||||||
|
stop_user_impersonation: 'Stop User Impersonation',
|
||||||
components: 'Components',
|
components: 'Components',
|
||||||
long_running_endpoints: 'Top 5 Long Running Endpoints',
|
long_running_endpoints: 'Top 5 Long Running Endpoints',
|
||||||
http_request_methods: 'HTTP Request Methods',
|
http_request_methods: 'HTTP Request Methods',
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ window._lnbitsApi = {
|
|||||||
url: '/api/v1/auth/logout'
|
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() {
|
getAuthenticatedUser() {
|
||||||
return this.request('get', '/api/v1/auth')
|
return this.request('get', '/api/v1/auth')
|
||||||
},
|
},
|
||||||
@@ -141,6 +154,9 @@ window._lnbitsApi = {
|
|||||||
name: name
|
name: name
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateUiCustomization(data = {}) {
|
||||||
|
return this.request('patch', '/api/v1/auth/ui', null, data)
|
||||||
|
},
|
||||||
resetWalletKeys(wallet) {
|
resetWalletKeys(wallet) {
|
||||||
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
|
return this.request('put', `/api/v1/wallet/reset/${wallet.id}`).then(
|
||||||
res => {
|
res => {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ window.LNbits = {
|
|||||||
fiat_providers: data.fiat_providers || [],
|
fiat_providers: data.fiat_providers || [],
|
||||||
super_user: data.super_user,
|
super_user: data.super_user,
|
||||||
extra: data.extra ?? {},
|
extra: data.extra ?? {},
|
||||||
hasPassword: data.has_password ?? false
|
hasPassword: data.has_password ?? false,
|
||||||
|
uiCustomization: data.ui_customization || {}
|
||||||
}
|
}
|
||||||
const mapWallet = this.wallet
|
const mapWallet = this.wallet
|
||||||
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
obj.wallets = obj.wallets.map(mapWallet).sort((a, b) => {
|
||||||
|
|||||||
@@ -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', {
|
window.app.component('lnbits-manage', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-manage',
|
template: '#lnbits-manage',
|
||||||
computed: {
|
computed: {
|
||||||
showAdmin() {
|
showAdmin() {
|
||||||
@@ -81,10 +65,8 @@ window.app.component('lnbits-manage', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-payment-details', {
|
window.app.component('lnbits-payment-details', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-payment-details',
|
template: '#lnbits-payment-details',
|
||||||
props: ['payment'],
|
props: ['payment'],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
computed: {
|
computed: {
|
||||||
hasPreimage() {
|
hasPreimage() {
|
||||||
return (
|
return (
|
||||||
@@ -128,7 +110,6 @@ window.app.component('lnbits-payment-details', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.app.component('lnbits-lnurlpay-success-action', {
|
window.app.component('lnbits-lnurlpay-success-action', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-lnurlpay-success-action',
|
template: '#lnbits-lnurlpay-success-action',
|
||||||
props: ['payment', 'success_action'],
|
props: ['payment', 'success_action'],
|
||||||
data() {
|
data() {
|
||||||
@@ -148,7 +129,6 @@ window.app.component('lnbits-lnurlpay-success-action', {
|
|||||||
|
|
||||||
window.app.component('lnbits-notifications-btn', {
|
window.app.component('lnbits-notifications-btn', {
|
||||||
template: '#lnbits-notifications-btn',
|
template: '#lnbits-notifications-btn',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['pubkey'],
|
props: ['pubkey'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -319,7 +299,6 @@ window.app.component('lnbits-notifications-btn', {
|
|||||||
|
|
||||||
window.app.component('lnbits-dynamic-fields', {
|
window.app.component('lnbits-dynamic-fields', {
|
||||||
template: '#lnbits-dynamic-fields',
|
template: '#lnbits-dynamic-fields',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['options', 'modelValue'],
|
props: ['options', 'modelValue'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -352,7 +331,6 @@ window.app.component('lnbits-dynamic-fields', {
|
|||||||
|
|
||||||
window.app.component('lnbits-dynamic-chips', {
|
window.app.component('lnbits-dynamic-chips', {
|
||||||
template: '#lnbits-dynamic-chips',
|
template: '#lnbits-dynamic-chips',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -383,7 +361,6 @@ window.app.component('lnbits-dynamic-chips', {
|
|||||||
|
|
||||||
window.app.component('lnbits-update-balance', {
|
window.app.component('lnbits-update-balance', {
|
||||||
template: '#lnbits-update-balance',
|
template: '#lnbits-update-balance',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['wallet_id', 'small_btn'],
|
props: ['wallet_id', 'small_btn'],
|
||||||
computed: {
|
computed: {
|
||||||
admin() {
|
admin() {
|
||||||
@@ -422,7 +399,6 @@ window.app.component('lnbits-update-balance', {
|
|||||||
|
|
||||||
window.app.component('user-id-only', {
|
window.app.component('user-id-only', {
|
||||||
template: '#user-id-only',
|
template: '#user-id-only',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: {
|
props: {
|
||||||
allowed_new_users: Boolean,
|
allowed_new_users: Boolean,
|
||||||
authAction: String,
|
authAction: String,
|
||||||
@@ -466,7 +442,6 @@ window.app.component('user-id-only', {
|
|||||||
|
|
||||||
window.app.component('username-password', {
|
window.app.component('username-password', {
|
||||||
template: '#username-password',
|
template: '#username-password',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: {
|
props: {
|
||||||
allowed_new_users: Boolean,
|
allowed_new_users: Boolean,
|
||||||
authMethods: Array,
|
authMethods: Array,
|
||||||
@@ -704,7 +679,6 @@ window.app.component('lnbits-stat', {
|
|||||||
|
|
||||||
window.app.component('lnbits-node-qrcode', {
|
window.app.component('lnbits-node-qrcode', {
|
||||||
props: ['info'],
|
props: ['info'],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: `
|
template: `
|
||||||
<q-card class="my-card">
|
<q-card class="my-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
@@ -783,7 +757,6 @@ window.app.component('lnbits-node-info', {
|
|||||||
showDialog: false
|
showDialog: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mixins: [window.windowMixin],
|
|
||||||
methods: {
|
methods: {
|
||||||
shortenNodeId(nodeId) {
|
shortenNodeId(nodeId) {
|
||||||
return nodeId
|
return nodeId
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-assets-config', {
|
window.app.component('lnbits-admin-assets-config', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-assets-config',
|
template: '#lnbits-admin-assets-config',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
newAllowedAssetMimeType: '',
|
newAllowedAssetMimeType: '',
|
||||||
@@ -17,6 +16,7 @@ window.app.component('lnbits-admin-assets-config', {
|
|||||||
this.newAllowedAssetMimeType
|
this.newAllowedAssetMimeType
|
||||||
)
|
)
|
||||||
this.newAllowedAssetMimeType = ''
|
this.newAllowedAssetMimeType = ''
|
||||||
|
this.formData.touch = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeAllowedAssetMimeType(type) {
|
removeAllowedAssetMimeType(type) {
|
||||||
@@ -24,18 +24,21 @@ window.app.component('lnbits-admin-assets-config', {
|
|||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
|
this.formData.lnbits_assets_allowed_mime_types.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
this.formData.touch = null
|
||||||
},
|
},
|
||||||
addNewNoLimitUser() {
|
addNewNoLimitUser() {
|
||||||
if (this.newNoLimitUser) {
|
if (this.newNoLimitUser) {
|
||||||
this.removeNoLimitUser(this.newNoLimitUser)
|
this.removeNoLimitUser(this.newNoLimitUser)
|
||||||
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
|
this.formData.lnbits_assets_no_limit_users.push(this.newNoLimitUser)
|
||||||
this.newNoLimitUser = ''
|
this.newNoLimitUser = ''
|
||||||
|
this.formData.touch = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeNoLimitUser(user) {
|
removeNoLimitUser(user) {
|
||||||
if (user) {
|
if (user) {
|
||||||
this.formData.lnbits_assets_no_limit_users =
|
this.formData.lnbits_assets_no_limit_users =
|
||||||
this.formData.lnbits_assets_no_limit_users.filter(u => u !== user)
|
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', {
|
window.app.component('lnbits-admin-audit', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-audit',
|
template: '#lnbits-admin-audit',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formAddIncludePath: '',
|
formAddIncludePath: '',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-exchange-providers', {
|
window.app.component('lnbits-admin-exchange-providers', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-exchange-providers',
|
template: '#lnbits-admin-exchange-providers',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
exchangeData: {
|
exchangeData: {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-extensions', {
|
window.app.component('lnbits-admin-extensions', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-extensions',
|
template: '#lnbits-admin-extensions',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formAddExtensionsManifest: ''
|
formAddExtensionsManifest: ''
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-fiat-providers', {
|
window.app.component('lnbits-admin-fiat-providers', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-fiat-providers',
|
template: '#lnbits-admin-fiat-providers',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formAddStripeUser: '',
|
formAddStripeUser: '',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-admin-funding-sources', {
|
window.app.component('lnbits-admin-funding-sources', {
|
||||||
template: '#lnbits-admin-funding-sources',
|
template: '#lnbits-admin-funding-sources',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['form-data', 'allowed-funding-sources'],
|
props: ['form-data', 'allowed-funding-sources'],
|
||||||
methods: {
|
methods: {
|
||||||
getFundingSourceLabel(item) {
|
getFundingSourceLabel(item) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-funding', {
|
window.app.component('lnbits-admin-funding', {
|
||||||
props: ['is-super-user', 'form-data', 'settings'],
|
props: ['is-super-user', 'form-data', 'settings'],
|
||||||
template: '#lnbits-admin-funding',
|
template: '#lnbits-admin-funding',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
auditData: []
|
auditData: []
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-notifications', {
|
window.app.component('lnbits-admin-notifications', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-notifications',
|
template: '#lnbits-admin-notifications',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
nostrNotificationIdentifier: '',
|
nostrNotificationIdentifier: '',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-security', {
|
window.app.component('lnbits-admin-security', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-security',
|
template: '#lnbits-admin-security',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
logs: [],
|
logs: [],
|
||||||
@@ -9,11 +8,58 @@ window.app.component('lnbits-admin-security', {
|
|||||||
serverlogEnabled: false,
|
serverlogEnabled: false,
|
||||||
nostrAcceptedUrl: '',
|
nostrAcceptedUrl: '',
|
||||||
formAllowedIPs: '',
|
formAllowedIPs: '',
|
||||||
formCallbackUrlRule: ''
|
formCallbackUrlRule: '',
|
||||||
|
routeOptions: [],
|
||||||
|
routeOptionsFiltered: [],
|
||||||
|
routeOptionsLoading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created() {},
|
created() {
|
||||||
|
this.loadOpenApiRoutes()
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async loadOpenApiRoutes() {
|
||||||
|
this.routeOptionsLoading = true
|
||||||
|
try {
|
||||||
|
const response = await fetch('/openapi.json')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load OpenAPI spec')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const paths = Object.keys(data.paths || {}).sort()
|
||||||
|
this.routeOptions = paths
|
||||||
|
this.routeOptionsFiltered = paths
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
} finally {
|
||||||
|
this.routeOptionsLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addRouteOption(value, done) {
|
||||||
|
const route = value.trim()
|
||||||
|
if (!route) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.routeOptions.includes(route)) {
|
||||||
|
this.routeOptions.push(route)
|
||||||
|
this.routeOptions.sort()
|
||||||
|
}
|
||||||
|
this.routeOptionsFiltered = this.routeOptions
|
||||||
|
done(route)
|
||||||
|
},
|
||||||
|
filterRouteOptions(val, update) {
|
||||||
|
update(() => {
|
||||||
|
if (!val) {
|
||||||
|
this.routeOptionsFiltered = this.routeOptions
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const needle = val.toLowerCase()
|
||||||
|
this.routeOptionsFiltered = this.routeOptions.filter(route =>
|
||||||
|
route.toLowerCase().includes(needle)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
addAllowedIPs() {
|
addAllowedIPs() {
|
||||||
const allowedIPs = this.formAllowedIPs.trim()
|
const allowedIPs = this.formAllowedIPs.trim()
|
||||||
const allowed_ips = this.formData.lnbits_allowed_ips
|
const allowed_ips = this.formData.lnbits_allowed_ips
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
window.app.component('lnbits-admin-server', {
|
window.app.component('lnbits-admin-server', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-server',
|
template: '#lnbits-admin-server'
|
||||||
mixins: [window.windowMixin]
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-site-customisation', {
|
window.app.component('lnbits-admin-site-customisation', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-site-customisation',
|
template: '#lnbits-admin-site-customisation',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
lnbits_theme_options: [
|
lnbits_theme_options: [
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-admin-users', {
|
window.app.component('lnbits-admin-users', {
|
||||||
props: ['form-data'],
|
props: ['form-data'],
|
||||||
template: '#lnbits-admin-users',
|
template: '#lnbits-admin-users',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
formAddUser: '',
|
formAddUser: '',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-disclaimer', {
|
window.app.component('lnbits-disclaimer', {
|
||||||
template: '#lnbits-disclaimer',
|
template: '#lnbits-disclaimer',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
computed: {
|
computed: {
|
||||||
showDisclaimer() {
|
showDisclaimer() {
|
||||||
return !g.disclaimerShown && g.isUserAuthorized
|
return !g.disclaimerShown && g.isUserAuthorized
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
window.app.component('lnbits-drawer', {
|
window.app.component('lnbits-drawer', {
|
||||||
template: '#lnbits-drawer',
|
template: '#lnbits-drawer'
|
||||||
mixins: [window.windowMixin]
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-error', {
|
window.app.component('lnbits-error', {
|
||||||
template: '#lnbits-error',
|
template: '#lnbits-error',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['dynamic', 'code', 'message'],
|
props: ['dynamic', 'code', 'message'],
|
||||||
computed: {
|
computed: {
|
||||||
isExtension() {
|
isExtension() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-footer', {
|
window.app.component('lnbits-footer', {
|
||||||
template: '#lnbits-footer',
|
template: '#lnbits-footer',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
computed: {
|
computed: {
|
||||||
version() {
|
version() {
|
||||||
return this.LNBITS_VERSION || 'unknown version'
|
return this.LNBITS_VERSION || 'unknown version'
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
window.app.component('lnbits-header-wallets', {
|
window.app.component('lnbits-header-wallets', {
|
||||||
template: '#lnbits-header-wallets',
|
template: '#lnbits-header-wallets'
|
||||||
mixins: [window.windowMixin]
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-header', {
|
window.app.component('lnbits-header', {
|
||||||
template: '#lnbits-header',
|
template: '#lnbits-header',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
computed: {
|
computed: {
|
||||||
hasServiceFeeMax() {
|
hasServiceFeeMax() {
|
||||||
return (
|
return (
|
||||||
@@ -61,5 +60,28 @@ window.app.component('lnbits-header', {
|
|||||||
return 'User'
|
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', {
|
window.app.component('lnbits-home-logos', {
|
||||||
template: '#lnbits-home-logos',
|
template: '#lnbits-home-logos',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
logos: [
|
logos: [
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-label-selector', {
|
window.app.component('lnbits-label-selector', {
|
||||||
template: '#lnbits-label-selector',
|
template: '#lnbits-label-selector',
|
||||||
props: ['labels'],
|
props: ['labels'],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
labelFilter: '',
|
labelFilter: '',
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
window.app.component('lnbits-language-dropdown', {
|
window.app.component('lnbits-language-dropdown', {
|
||||||
template: '#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: {
|
methods: {
|
||||||
activeLanguage(lang) {
|
activeLanguage(lang) {
|
||||||
return window.i18n.global.locale === lang
|
return window.i18n.global.locale === lang
|
||||||
@@ -9,6 +19,7 @@ window.app.component('lnbits-language-dropdown', {
|
|||||||
this.g.locale = newValue
|
this.g.locale = newValue
|
||||||
window.i18n.global.locale = newValue
|
window.i18n.global.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
|
this.$emit('language-changed', newValue)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
window.app.component('lnbits-manage-extension-list', {
|
window.app.component('lnbits-manage-extension-list', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-manage-extension-list',
|
template: '#lnbits-manage-extension-list',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-manage-wallet-list', {
|
window.app.component('lnbits-manage-wallet-list', {
|
||||||
template: '#lnbits-manage-wallet-list',
|
template: '#lnbits-manage-wallet-list',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
activeWalletId: null
|
activeWalletId: null
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
window.app.component('lnbits-payment-list', {
|
window.app.component('lnbits-payment-list', {
|
||||||
template: '#lnbits-payment-list',
|
template: '#lnbits-payment-list',
|
||||||
props: ['wallet', 'paymentFilter'],
|
props: ['wallet', 'paymentFilter'],
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
payments: [],
|
payments: [],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-qrcode-lnurl', {
|
window.app.component('lnbits-qrcode-lnurl', {
|
||||||
template: '#lnbits-qrcode-lnurl',
|
template: '#lnbits-qrcode-lnurl',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: {
|
props: {
|
||||||
url: {
|
url: {
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
window.app.component('lnbits-qrcode', {
|
window.app.component('lnbits-qrcode', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#lnbits-qrcode',
|
template: '#lnbits-qrcode',
|
||||||
components: {
|
components: {
|
||||||
QrcodeVue: QrcodeVue.default
|
QrcodeVue: QrcodeVue.default
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
window.app.component('lnbits-theme', {
|
window.app.component('lnbits-theme', {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
watch: {
|
watch: {
|
||||||
'g.walletFlip'(val) {
|
'g.walletFlip'(val) {
|
||||||
this.$q.localStorage.setItem('lnbits.walletFlip', val)
|
this.$q.localStorage.setItem('lnbits.walletFlip', val)
|
||||||
@@ -10,6 +9,10 @@ window.app.component('lnbits-theme', {
|
|||||||
'g.disclaimerShown'(val) {
|
'g.disclaimerShown'(val) {
|
||||||
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
|
this.$q.localStorage.setItem('lnbits.disclaimerShown', val)
|
||||||
},
|
},
|
||||||
|
'g.locale'(val) {
|
||||||
|
this.$q.localStorage.setItem('lnbits.lang', val)
|
||||||
|
window.i18n.global.locale = val
|
||||||
|
},
|
||||||
'g.isFiatPriority'(val) {
|
'g.isFiatPriority'(val) {
|
||||||
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
|
this.$q.localStorage.setItem('lnbits.isFiatPriority', val)
|
||||||
},
|
},
|
||||||
@@ -124,6 +127,13 @@ window.app.component('lnbits-theme', {
|
|||||||
if (this.g.mobileSimple === true) {
|
if (this.g.mobileSimple === true) {
|
||||||
document.body.classList.add('mobile-simple')
|
document.body.classList.add('mobile-simple')
|
||||||
}
|
}
|
||||||
|
Object.entries(this.g.user.uiCustomization || {}).forEach(
|
||||||
|
([key, value]) => {
|
||||||
|
if (key in this.g) {
|
||||||
|
this.g[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
this.checkUrlParams()
|
this.checkUrlParams()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-api-docs', {
|
window.app.component('lnbits-wallet-api-docs', {
|
||||||
template: '#lnbits-wallet-api-docs',
|
template: '#lnbits-wallet-api-docs',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
methods: {
|
methods: {
|
||||||
resetKeys() {
|
resetKeys() {
|
||||||
LNbits.utils
|
LNbits.utils
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-charts', {
|
window.app.component('lnbits-wallet-charts', {
|
||||||
template: '#lnbits-wallet-charts',
|
template: '#lnbits-wallet-charts',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
props: ['paymentFilter', 'chartConfig'],
|
props: ['paymentFilter', 'chartConfig'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-icon', {
|
window.app.component('lnbits-wallet-icon', {
|
||||||
template: '#lnbits-wallet-icon',
|
template: '#lnbits-wallet-icon',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
icon: {
|
icon: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-new', {
|
window.app.component('lnbits-wallet-new', {
|
||||||
template: '#lnbits-wallet-new',
|
template: '#lnbits-wallet-new',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
|
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-paylinks', {
|
window.app.component('lnbits-wallet-paylinks', {
|
||||||
template: '#lnbits-wallet-paylinks',
|
template: '#lnbits-wallet-paylinks',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
storedPaylinks: []
|
storedPaylinks: []
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.app.component('lnbits-wallet-share', {
|
window.app.component('lnbits-wallet-share', {
|
||||||
template: '#lnbits-wallet-share',
|
template: '#lnbits-wallet-share',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
computed: {
|
computed: {
|
||||||
walletApprovedShares() {
|
walletApprovedShares() {
|
||||||
return this.g.wallet.extra.shared_with.filter(
|
return this.g.wallet.extra.shared_with.filter(
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ window.g = Vue.reactive({
|
|||||||
wallet: null,
|
wallet: null,
|
||||||
isPublicPage: true,
|
isPublicPage: true,
|
||||||
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
|
isUserAuthorized: !!Quasar.Cookies.get('is_lnbits_user_authorized'),
|
||||||
|
isUserImpersonated: !!Quasar.Cookies.get('is_lnbits_user_impersonated'),
|
||||||
offline: !navigator.onLine,
|
offline: !navigator.onLine,
|
||||||
hasCamera: false,
|
hasCamera: false,
|
||||||
visibleDrawer: false,
|
visibleDrawer: false,
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ const routes = [
|
|||||||
name: 'ExtensionsBuilder',
|
name: 'ExtensionsBuilder',
|
||||||
component: PageExtensionBuilder
|
component: PageExtensionBuilder
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/extensions/builder/preview',
|
||||||
|
name: 'ExtensionsBuilderPreview',
|
||||||
|
component: PageExtensionBuilderPreview
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/extensions',
|
path: '/extensions',
|
||||||
name: 'Extensions',
|
name: 'Extensions',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageAccount = {
|
window.PageAccount = {
|
||||||
template: '#page-account',
|
template: '#page-account',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
untouchedUser: null,
|
untouchedUser: null,
|
||||||
@@ -40,6 +39,15 @@ window.PageAccount = {
|
|||||||
color: 'pink-3'
|
color: 'pink-3'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
defaultSiteCustomisation: {
|
||||||
|
locale: 'en',
|
||||||
|
themeChoice: 'salvador',
|
||||||
|
bgimageChoice: '',
|
||||||
|
gradientChoice: true,
|
||||||
|
darkChoice: true,
|
||||||
|
borderChoice: 'hard-border',
|
||||||
|
reactionChoice: 'confettiBothSides'
|
||||||
|
},
|
||||||
reactionOptions: [
|
reactionOptions: [
|
||||||
'None',
|
'None',
|
||||||
'confettiBothSides',
|
'confettiBothSides',
|
||||||
@@ -214,26 +222,18 @@ window.PageAccount = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
activeLanguage(lang) {
|
|
||||||
return window.i18n.global.locale === lang
|
|
||||||
},
|
|
||||||
changeLanguage(newValue) {
|
changeLanguage(newValue) {
|
||||||
window.i18n.global.locale = newValue
|
window.i18n.global.locale = newValue
|
||||||
this.$q.localStorage.set('lnbits.lang', newValue)
|
this.$q.localStorage.set('lnbits.lang', newValue)
|
||||||
},
|
},
|
||||||
async updateAccount() {
|
async updateAccount() {
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request('PATCH', '/api/v1/auth', null, {
|
||||||
'PUT',
|
user_id: this.g.user.id,
|
||||||
'/api/v1/auth/update',
|
username: this.g.user.username,
|
||||||
null,
|
email: this.g.user.email,
|
||||||
{
|
extra: this.g.user.extra
|
||||||
user_id: this.g.user.id,
|
})
|
||||||
username: this.g.user.username,
|
|
||||||
email: this.g.user.email,
|
|
||||||
extra: this.g.user.extra
|
|
||||||
}
|
|
||||||
)
|
|
||||||
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
|
this.untouchedUser = JSON.parse(JSON.stringify(this.g.user))
|
||||||
this.hasUsername = !!data.username
|
this.hasUsername = !!data.username
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
@@ -610,7 +610,7 @@ window.PageAccount = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
copyAssetLinkToClipboard(asset) {
|
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)
|
this.utils.copyText(assetUrl)
|
||||||
},
|
},
|
||||||
addUserLabel() {
|
addUserLabel() {
|
||||||
@@ -681,6 +681,23 @@ window.PageAccount = {
|
|||||||
l => l.name !== label.name
|
l => l.name !== label.name
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async siteCustomisationChanged(options = {}) {
|
||||||
|
try {
|
||||||
|
Object.entries(options || {}).forEach(([key, value]) => {
|
||||||
|
if (key in this.g) {
|
||||||
|
this.g[key] = value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await LNbits.api.updateUiCustomization(options)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'UI Customization updated.'
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
LNbits.utils.notifyApiError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageAdmin = {
|
window.PageAdmin = {
|
||||||
template: '#page-admin',
|
template: '#page-admin',
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tab: 'funding',
|
tab: 'funding',
|
||||||
@@ -9,7 +8,9 @@ window.PageAdmin = {
|
|||||||
lnbits_exchange_rate_providers: [],
|
lnbits_exchange_rate_providers: [],
|
||||||
lnbits_audit_exclude_paths: [],
|
lnbits_audit_exclude_paths: [],
|
||||||
lnbits_audit_include_paths: [],
|
lnbits_audit_include_paths: [],
|
||||||
lnbits_audit_http_response_codes: []
|
lnbits_audit_http_response_codes: [],
|
||||||
|
lnbits_route_access_whitelist: [],
|
||||||
|
lnbits_route_access_blacklist: []
|
||||||
},
|
},
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
needsRestart: false
|
needsRestart: false
|
||||||
@@ -70,6 +71,25 @@ window.PageAdmin = {
|
|||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
},
|
},
|
||||||
updateSettings() {
|
updateSettings() {
|
||||||
|
if (this.shouldConfirmRouteAccess()) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog(this.$t('route_access_save_confirm'))
|
||||||
|
.onOk(() => this.persistSettings())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.persistSettings()
|
||||||
|
},
|
||||||
|
shouldConfirmRouteAccess() {
|
||||||
|
const fields = [
|
||||||
|
'lnbits_route_access_control_enabled',
|
||||||
|
'lnbits_route_access_whitelist',
|
||||||
|
'lnbits_route_access_blacklist'
|
||||||
|
]
|
||||||
|
return fields.some(
|
||||||
|
field => !_.isEqual(this.settings[field], this.formData[field])
|
||||||
|
)
|
||||||
|
},
|
||||||
|
persistSettings() {
|
||||||
const data = _.omit(this.formData, [
|
const data = _.omit(this.formData, [
|
||||||
'is_super_user',
|
'is_super_user',
|
||||||
'lnbits_allowed_funding_sources',
|
'lnbits_allowed_funding_sources',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageAudit = {
|
window.PageAudit = {
|
||||||
template: '#page-audit',
|
template: '#page-audit',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
chartsReady: false,
|
chartsReady: false,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
window.PageError = {
|
window.PageError = {
|
||||||
template: '#page-error',
|
template: '#page-error'
|
||||||
mixins: [window.windowMixin]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageExtensions = {
|
window.PageExtensions = {
|
||||||
template: '#page-extensions',
|
template: '#page-extensions',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
extbuilderEnabled: false,
|
extbuilderEnabled: false,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageExtensionBuilder = {
|
window.PageExtensionBuilder = {
|
||||||
template: '#page-extension-builder',
|
template: '#page-extension-builder',
|
||||||
mixins: [windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
step: 1,
|
step: 1,
|
||||||
@@ -288,11 +287,10 @@ window.PageExtensionBuilder = {
|
|||||||
`/api/v1/extension/${stub_ext_id}/releases`
|
`/api/v1/extension/${stub_ext_id}/releases`
|
||||||
)
|
)
|
||||||
|
|
||||||
this.extensionStubVersions = data.sort((a, b) =>
|
this.extensionStubVersions = data
|
||||||
a.version < b.version ? 1 : -1
|
const validVersions = data.filter(v => v.is_version_compatible)
|
||||||
)
|
this.extensionData.stub_version = validVersions[0]
|
||||||
this.extensionData.stub_version = this.extensionStubVersions[0]
|
? validVersions[0].version
|
||||||
? this.extensionStubVersions[0].version
|
|
||||||
: ''
|
: ''
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error)
|
LNbits.utils.notifyApiError(error)
|
||||||
@@ -311,7 +309,20 @@ window.PageExtensionBuilder = {
|
|||||||
iframeDoc.body.style.transform = 'scale(0.8)'
|
iframeDoc.body.style.transform = 'scale(0.8)'
|
||||||
iframeDoc.body.style.transformOrigin = 'center top'
|
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() {
|
initBasicData() {
|
||||||
this.extensionData.owner_data.fields = [
|
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,6 +1,5 @@
|
|||||||
window.PageFirstInstall = {
|
window.PageFirstInstall = {
|
||||||
template: '#page-first-install',
|
template: '#page-first-install',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loginData: {
|
loginData: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageHome = {
|
window.PageHome = {
|
||||||
template: '#page-home',
|
template: '#page-home',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
lnurl: '',
|
lnurl: '',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageNodePublic = {
|
window.PageNodePublic = {
|
||||||
template: '#page-node-public',
|
template: '#page-node-public',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
window.PageNode = {
|
window.PageNode = {
|
||||||
mixins: [window.windowMixin],
|
|
||||||
template: '#page-node',
|
template: '#page-node',
|
||||||
config: {
|
config: {
|
||||||
globalProperties: {
|
globalProperties: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PagePayments = {
|
window.PagePayments = {
|
||||||
template: '#page-payments',
|
template: '#page-payments',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
payments: [],
|
payments: [],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageUsers = {
|
window.PageUsers = {
|
||||||
template: '#page-users',
|
template: '#page-users',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
paymentsWallet: {},
|
paymentsWallet: {},
|
||||||
@@ -439,6 +438,20 @@ window.PageUsers = {
|
|||||||
this.activeUser.show = false
|
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) {
|
async showWalletPayments(walletId) {
|
||||||
this.activeUser.show = false
|
this.activeUser.show = false
|
||||||
await this.fetchWallets(this.users[0].id)
|
await this.fetchWallets(this.users[0].id)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageWallet = {
|
window.PageWallet = {
|
||||||
template: '#page-wallet',
|
template: '#page-wallet',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
parse: {
|
parse: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
window.PageWallets = {
|
window.PageWallets = {
|
||||||
template: '#page-wallets',
|
template: '#page-wallets',
|
||||||
mixins: [window.windowMixin],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
+52
-11
@@ -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) {
|
async digestMessage(message) {
|
||||||
const msgUint8 = new TextEncoder().encode(message)
|
const msgUint8 = new TextEncoder().encode(message)
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
|
||||||
@@ -114,6 +137,13 @@ window._lnbitsUtils = {
|
|||||||
formatMsat(value) {
|
formatMsat(value) {
|
||||||
return this.formatSat(value / 1000)
|
return this.formatSat(value / 1000)
|
||||||
},
|
},
|
||||||
|
parseJSONSafe(str) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(str)
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
notifyApiError(error) {
|
notifyApiError(error) {
|
||||||
if (!error.response) {
|
if (!error.response) {
|
||||||
return console.error(error)
|
return console.error(error)
|
||||||
@@ -123,17 +153,28 @@ window._lnbitsUtils = {
|
|||||||
401: 'warning',
|
401: 'warning',
|
||||||
500: 'negative'
|
500: 'negative'
|
||||||
}
|
}
|
||||||
Quasar.Notify.create({
|
let messages = error.response.data.detail
|
||||||
timeout: 5000,
|
if (messages) {
|
||||||
type: types[error.response.status] || 'warning',
|
messages = Array.isArray(messages)
|
||||||
message:
|
? messages.map(e => e.msg + ` (${e.loc?.join('/')})`)
|
||||||
error.response.data.message || error.response.data.detail || null,
|
: (messages = [messages])
|
||||||
caption:
|
} else {
|
||||||
[error.response.status, ' ', error.response.statusText]
|
messages = [error.response.data.message || error.response.data.detail]
|
||||||
.join('')
|
}
|
||||||
.toUpperCase() || null,
|
|
||||||
icon: null
|
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) {
|
search(data, q, field, separator) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@
|
|||||||
"js/pages/error.js",
|
"js/pages/error.js",
|
||||||
"js/pages/home.js",
|
"js/pages/home.js",
|
||||||
"js/pages/extensions_builder.js",
|
"js/pages/extensions_builder.js",
|
||||||
|
"js/pages/extensions_builder.js",
|
||||||
|
"js/pages/extensions_builder_preview.js",
|
||||||
"js/pages/extensions.js",
|
"js/pages/extensions.js",
|
||||||
"js/pages/first-install.js",
|
"js/pages/first-install.js",
|
||||||
"js/pages/payments.js",
|
"js/pages/payments.js",
|
||||||
|
|||||||
@@ -296,6 +296,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-md-12">
|
||||||
|
<p v-text="$t('route_access_control')"></p>
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12">
|
||||||
|
<q-toggle
|
||||||
|
v-model="formData.lnbits_route_access_control_enabled"
|
||||||
|
:label="$t('route_access_control_enable')"
|
||||||
|
></q-toggle>
|
||||||
|
<div
|
||||||
|
class="text-caption text-grey-6 q-mt-xs"
|
||||||
|
v-text="$t('route_access_control_hint')"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
use-input
|
||||||
|
input-debounce="0"
|
||||||
|
new-value-mode="add-unique"
|
||||||
|
@new-value="addRouteOption"
|
||||||
|
:options="routeOptionsFiltered"
|
||||||
|
:loading="routeOptionsLoading"
|
||||||
|
@filter="filterRouteOptions"
|
||||||
|
v-model="formData.lnbits_route_access_whitelist"
|
||||||
|
:label="$t('route_access_whitelist_label')"
|
||||||
|
:hint="$t('route_access_whitelist_hint')"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
multiple
|
||||||
|
use-chips
|
||||||
|
use-input
|
||||||
|
input-debounce="0"
|
||||||
|
new-value-mode="add-unique"
|
||||||
|
@new-value="addRouteOption"
|
||||||
|
:options="routeOptionsFiltered"
|
||||||
|
:loading="routeOptionsLoading"
|
||||||
|
@filter="filterRouteOptions"
|
||||||
|
v-model="formData.lnbits_route_access_blacklist"
|
||||||
|
:label="$t('route_access_blacklist_label')"
|
||||||
|
:hint="$t('route_access_blacklist_hint')"
|
||||||
|
></q-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-12 col-md-12">
|
<div class="col-12 col-md-12">
|
||||||
<p v-text="$t('rate_limiter')"></p>
|
<p v-text="$t('rate_limiter')"></p>
|
||||||
<div class="row q-col-gutter-md">
|
<div class="row q-col-gutter-md">
|
||||||
|
|||||||
@@ -86,6 +86,42 @@
|
|||||||
></q-input>
|
></q-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<br />
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p>
|
||||||
|
<span v-text="$t('wallet_featured_button_label')"></span>
|
||||||
|
</p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_label"
|
||||||
|
label="Loop to Onchain"
|
||||||
|
:hint="$t('wallet_featured_button_label_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p><span v-text="$t('wallet_featured_button_url')"></span></p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_url"
|
||||||
|
label="/boltz"
|
||||||
|
:hint="$t('wallet_featured_button_url_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<p><span v-text="$t('wallet_featured_button_icon')"></span></p>
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
type="text"
|
||||||
|
v-model="formData.lnbits_wallet_featured_button_icon"
|
||||||
|
label="bolt"
|
||||||
|
:hint="$t('wallet_featured_button_icon_hint')"
|
||||||
|
></q-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-md q-mt-md">
|
<div class="row q-col-gutter-md q-mt-md">
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<p><span v-text="$t('ui_custom_badge')"></span></p>
|
<p><span v-text="$t('ui_custom_badge')"></span></p>
|
||||||
|
|||||||
@@ -71,9 +71,11 @@
|
|||||||
<span>OFFLINE</span>
|
<span>OFFLINE</span>
|
||||||
</q-badge>
|
</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>
|
<template v-slot:label>
|
||||||
<q-avatar
|
<q-avatar
|
||||||
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
v-if="g.user?.extra?.picture && g.user?.extra?.picture !== ''"
|
||||||
@@ -136,6 +138,20 @@
|
|||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</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-toolbar>
|
||||||
</q-header>
|
</q-header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
<template id="lnbits-language-dropdown">
|
<template id="lnbits-language-dropdown">
|
||||||
<q-btn-dropdown dense flat rounded size="sm" icon="language" class="q-pl-md">
|
<q-btn-dropdown dense flat rounded size="md" class="q-pl-md">
|
||||||
|
<template v-slot:label>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label v-text="currentLanguage.display"></q-item-label>
|
||||||
|
<q-tooltip><span v-text="currentLanguage.label"></span></q-tooltip>
|
||||||
|
</q-item-section>
|
||||||
|
</template>
|
||||||
<q-list v-for="(lang, index) in langs" :key="index">
|
<q-list v-for="(lang, index) in langs" :key="index">
|
||||||
<q-item
|
<q-item
|
||||||
clickable
|
clickable
|
||||||
|
|||||||
@@ -333,6 +333,16 @@
|
|||||||
class="q-mb-md"
|
class="q-mb-md"
|
||||||
>
|
>
|
||||||
</q-input>
|
</q-input>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="g.user.extra.visible_wallet_count"
|
||||||
|
:label="$t('visible_wallet_count')"
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
type="number"
|
||||||
|
class="q-mb-md"
|
||||||
|
></q-input>
|
||||||
|
|
||||||
<q-input
|
<q-input
|
||||||
v-model="g.user.external_id"
|
v-model="g.user.external_id"
|
||||||
:label="$t('external_id')"
|
:label="$t('external_id')"
|
||||||
@@ -380,22 +390,11 @@
|
|||||||
<span v-text="$t('language')"></span>
|
<span v-text="$t('language')"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<lnbits-language-dropdown />
|
<lnbits-language-dropdown
|
||||||
</div>
|
@language-changed="
|
||||||
</div>
|
siteCustomisationChanged({locale: $event})
|
||||||
<div class="row q-mb-md">
|
"
|
||||||
<div class="col-4">
|
/>
|
||||||
<span v-text="$t('visible_wallet_count')"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<q-input
|
|
||||||
v-model="g.user.extra.visible_wallet_count"
|
|
||||||
:label="$t('visible_wallet_count')"
|
|
||||||
filled
|
|
||||||
dense
|
|
||||||
type="number"
|
|
||||||
class="q-mb-md"
|
|
||||||
></q-input>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -407,7 +406,9 @@
|
|||||||
<q-btn
|
<q-btn
|
||||||
v-for="theme in themeOptions"
|
v-for="theme in themeOptions"
|
||||||
:key="theme.name"
|
:key="theme.name"
|
||||||
@click="g.themeChoice = theme.name"
|
@click="
|
||||||
|
siteCustomisationChanged({themeChoice: theme.name})
|
||||||
|
"
|
||||||
:color="theme.color"
|
:color="theme.color"
|
||||||
dense
|
dense
|
||||||
flat
|
flat
|
||||||
@@ -427,6 +428,9 @@
|
|||||||
<q-input
|
<q-input
|
||||||
v-model="g.bgimageChoice"
|
v-model="g.bgimageChoice"
|
||||||
:label="$t('background_image')"
|
:label="$t('background_image')"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({bgimageChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('background_image')"></span
|
><span v-text="$t('background_image')"></span
|
||||||
@@ -445,6 +449,9 @@
|
|||||||
round
|
round
|
||||||
icon="gradient"
|
icon="gradient"
|
||||||
v-model="g.gradientChoice"
|
v-model="g.gradientChoice"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({gradientChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('toggle_gradient')"></span
|
><span v-text="$t('toggle_gradient')"></span
|
||||||
@@ -463,6 +470,9 @@
|
|||||||
flat
|
flat
|
||||||
round
|
round
|
||||||
v-model="g.darkChoice"
|
v-model="g.darkChoice"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({darkChoice: $event})
|
||||||
|
"
|
||||||
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
|
:icon="$q.dark.isActive ? 'brightness_3' : 'wb_sunny'"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
@@ -481,6 +491,9 @@
|
|||||||
v-model="g.borderChoice"
|
v-model="g.borderChoice"
|
||||||
:options="borderOptions"
|
:options="borderOptions"
|
||||||
label="Borders"
|
label="Borders"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({borderChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('border_choices')"></span
|
><span v-text="$t('border_choices')"></span
|
||||||
@@ -508,6 +521,9 @@
|
|||||||
v-model="g.reactionChoice"
|
v-model="g.reactionChoice"
|
||||||
:options="reactionOptions"
|
:options="reactionOptions"
|
||||||
label="Reactions"
|
label="Reactions"
|
||||||
|
@update:model-value="
|
||||||
|
siteCustomisationChanged({reactionChoice: $event})
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
><span v-text="$t('payment_reactions')"></span
|
><span v-text="$t('payment_reactions')"></span
|
||||||
@@ -515,6 +531,17 @@
|
|||||||
</q-select>
|
</q-select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<q-card-section>
|
||||||
|
<q-btn
|
||||||
|
@click="
|
||||||
|
siteCustomisationChanged(defaultSiteCustomisation)
|
||||||
|
"
|
||||||
|
:label="$t('reset_defaults')"
|
||||||
|
filled
|
||||||
|
color="primary"
|
||||||
|
class="float-right q-mb-md"
|
||||||
|
></q-btn>
|
||||||
|
</q-card-section>
|
||||||
</q-tab-panel>
|
</q-tab-panel>
|
||||||
<q-tab-panel name="notifications">
|
<q-tab-panel name="notifications">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
@@ -941,7 +968,7 @@
|
|||||||
v-if="props.row.thumbnail_base64"
|
v-if="props.row.thumbnail_base64"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
style="color: inherit"
|
style="color: inherit"
|
||||||
:href="`/api/v1/assets/${props.row.id}/binary`"
|
:href="`/api/v1/assets/${props.row.id}/data`"
|
||||||
>
|
>
|
||||||
<q-img
|
<q-img
|
||||||
:src="
|
:src="
|
||||||
|
|||||||
@@ -656,7 +656,20 @@
|
|||||||
</q-btn>
|
</q-btn>
|
||||||
<span v-text="shortify(props.row.id)"></span>
|
<span v-text="shortify(props.row.id)"></span>
|
||||||
</q-td>
|
</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>
|
<q-td v-text="props.row.email"></q-td>
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,19 @@
|
|||||||
><span v-text="$t('camera_tooltip')"></span
|
><span v-text="$t('camera_tooltip')"></span
|
||||||
></q-tooltip>
|
></q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
<div
|
||||||
|
v-if="WALLET_FEATURED_BUTTON_URL"
|
||||||
|
class="float-right q-mt-sm q-ml-sm"
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
:label="WALLET_FEATURED_BUTTON_LABEL"
|
||||||
|
:icon="WALLET_FEATURED_BUTTON_ICON || undefined"
|
||||||
|
size="sm"
|
||||||
|
:to="WALLET_FEATURED_BUTTON_URL"
|
||||||
|
>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
<lnbits-update-balance
|
<lnbits-update-balance
|
||||||
v-if="$q.screen.gt.md"
|
v-if="$q.screen.gt.md"
|
||||||
:wallet_id="this.g.wallet.id"
|
:wallet_id="this.g.wallet.id"
|
||||||
|
|||||||
@@ -98,6 +98,8 @@
|
|||||||
"js/pages/error.js",
|
"js/pages/error.js",
|
||||||
"js/pages/home.js",
|
"js/pages/home.js",
|
||||||
"js/pages/extensions_builder.js",
|
"js/pages/extensions_builder.js",
|
||||||
|
"js/pages/extensions_builder.js",
|
||||||
|
"js/pages/extensions_builder_preview.js",
|
||||||
"js/pages/extensions.js",
|
"js/pages/extensions.js",
|
||||||
"js/pages/first-install.js",
|
"js/pages/first-install.js",
|
||||||
"js/pages/payments.js",
|
"js/pages/payments.js",
|
||||||
|
|||||||
Generated
+823
-772
File diff suppressed because it is too large
Load Diff
+37
-59
@@ -8,31 +8,30 @@ urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbit
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bech32==1.2.0",
|
"bech32==1.2.0",
|
||||||
"click==8.2.1",
|
"click==8.3.1",
|
||||||
"ecdsa==0.19.1",
|
|
||||||
"fastapi==0.116.1",
|
"fastapi==0.116.1",
|
||||||
"starlette==0.47.1",
|
"starlette==0.47.1",
|
||||||
"httpx==0.27.0",
|
"httpx==0.27.2",
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"lnurl==0.8.3",
|
"lnurl==0.8.3",
|
||||||
"pydantic==1.10.22",
|
"pydantic==1.10.26",
|
||||||
"pyqrcode==1.2.1",
|
"pyqrcode==1.2.1",
|
||||||
"shortuuid==1.0.13",
|
"shortuuid==1.0.13",
|
||||||
"sse-starlette==2.3.6",
|
"sse-starlette==2.3.6",
|
||||||
"typing-extensions==4.14.0",
|
"typing-extensions==4.15.0",
|
||||||
"uvicorn==0.34.3",
|
"uvicorn==0.40.0",
|
||||||
"sqlalchemy==1.4.54",
|
"sqlalchemy==1.4.54",
|
||||||
"aiosqlite==0.21.0",
|
"aiosqlite==0.22.1",
|
||||||
"asyncpg==0.30.0",
|
"asyncpg==0.31.0",
|
||||||
"uvloop==0.21.0",
|
"uvloop==0.22.1",
|
||||||
"websockets==15.0.1",
|
"websockets==15.0.1",
|
||||||
"loguru==0.7.3",
|
"loguru==0.7.3",
|
||||||
"grpcio==1.69.0",
|
"grpcio==1.76.0",
|
||||||
"protobuf==5.29.5",
|
"protobuf==6.33.2",
|
||||||
"pyln-client==25.5",
|
"pyln-client==25.12",
|
||||||
"pywebpush==2.0.3",
|
"pywebpush==2.2.0",
|
||||||
"slowapi==0.1.9",
|
"slowapi==0.1.9",
|
||||||
"websocket-client==1.8.0",
|
"websocket-client==1.9.0",
|
||||||
"pycryptodomex==3.23.0",
|
"pycryptodomex==3.23.0",
|
||||||
"packaging==25.0",
|
"packaging==25.0",
|
||||||
"bolt11==2.1.1",
|
"bolt11==2.1.1",
|
||||||
@@ -42,14 +41,14 @@ dependencies = [
|
|||||||
# needed for boltz, lnurldevice, watchonly extensions
|
# needed for boltz, lnurldevice, watchonly extensions
|
||||||
"embit==0.8.0",
|
"embit==0.8.0",
|
||||||
# needed for scheduler extension
|
# needed for scheduler extension
|
||||||
"python-crontab==3.2.0",
|
"python-crontab==3.3.0",
|
||||||
"pynostr==0.6.2",
|
"pynostr==0.7.0",
|
||||||
"python-multipart==0.0.20",
|
"python-multipart==0.0.21",
|
||||||
"filetype==1.2.0",
|
"filetype==1.2.0",
|
||||||
"nostr-sdk==0.42.1",
|
"nostr-sdk==0.44.0",
|
||||||
"bcrypt==4.3.0",
|
"bcrypt==5.0.0",
|
||||||
"jsonpath-ng==1.7.0",
|
"jsonpath-ng==1.7.0",
|
||||||
"pillow>=12.0.0",
|
"pillow>=12.1.0",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
"greenlet (>=3.3.0,<4.0.0)",
|
"greenlet (>=3.3.0,<4.0.0)",
|
||||||
]
|
]
|
||||||
@@ -60,30 +59,30 @@ lnbits-cli = "lnbits.commands:main"
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
|
breez = ["breez-sdk==0.8.0", "breez-sdk-liquid==0.11.11"]
|
||||||
liquid = ["wallycore==1.4.0"]
|
liquid = ["wallycore==1.5.1"]
|
||||||
migration = ["psycopg2-binary==2.9.10"]
|
migration = ["psycopg2-binary==2.9.11"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"black>=25.1.0,<26.0.0",
|
"black>=25.12.0,<26.0.0",
|
||||||
"mypy==1.17.1",
|
"mypy==1.17.1",
|
||||||
"types-protobuf>=6.30.2.20250516,<7.0.0",
|
"types-protobuf>=6.32.1.20251210,<7.0.0",
|
||||||
"pre-commit>=4.2.0,<5.0.0",
|
"pre-commit>=4.5.1,<5.0.0",
|
||||||
"openapi-spec-validator>=0.7.1,<1.0.0",
|
"openapi-spec-validator>=0.7.2,<1.0.0",
|
||||||
"ruff>=0.12.0,<1.0.0",
|
"ruff>=0.14.10,<1.0.0",
|
||||||
"types-passlib>=1.7.7.20240327,<2.0.0",
|
"types-passlib>=1.7.7.20250602,<2.0.0",
|
||||||
"openai>=1.39.0,<2.0.0",
|
"openai>=2.14.0",
|
||||||
"json5>=0.12.0,<1.0.0",
|
"json5>=0.13.0,<1.0.0",
|
||||||
"asgi-lifespan>=2.1.0,<3.0.0",
|
"asgi-lifespan>=2.1.0,<3.0.0",
|
||||||
"anyio>=4.7.0,<5.0.0",
|
"anyio>=4.12.1",
|
||||||
"pytest>=8.3.4,<9.0.0",
|
"pytest>=9.0.2",
|
||||||
"pytest-cov>=6.0.0,<7.0.0",
|
"pytest-cov>=7.0.0",
|
||||||
"pytest-md>=0.2.0,<0.3.0",
|
"pytest-md>=0.2.0,<0.3.0",
|
||||||
"pytest-httpserver>=1.1.0,<2.0.0",
|
"pytest-httpserver>=1.1.3,<2.0.0",
|
||||||
"pytest-mock>=3.14.0,<4.0.0",
|
"pytest-mock>=3.15.1,<4.0.0",
|
||||||
"types-mock>=5.1.0.20240425,<6.0.0",
|
"types-mock>=5.2.0.20250924,<6.0.0",
|
||||||
"mock>=5.1.0,<6.0.0",
|
"mock>=5.2.0,<6.0.0",
|
||||||
"grpcio-tools>=1.69.0,<2.0.0"
|
"grpcio-tools>=1.76.0,<2.0.0"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
@@ -92,27 +91,6 @@ packages = [
|
|||||||
{include = "lnbits/py.typed"},
|
{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]
|
[tool.pyright]
|
||||||
include = [
|
include = [
|
||||||
"lnbits",
|
"lnbits",
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ async def test_create_invoice_validates_used_currency(
|
|||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
res_data = response.json()
|
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
|
# 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
|
"/api/v1/payments/lnurl", json=lnurl_data, headers=adminkey_headers_from
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
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 ################################
|
################################ Labels ################################
|
||||||
|
|||||||
+195
-10
@@ -2016,9 +2016,7 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
|||||||
]
|
]
|
||||||
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
|
data = UpdateUser(user_id=user.id, username=f"u{tiny_id}", extra=user.extra)
|
||||||
assert data.extra
|
assert data.extra
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_data = response.json()
|
user_data = response.json()
|
||||||
assert len(user_data["extra"]["labels"]) == 2
|
assert len(user_data["extra"]["labels"]) == 2
|
||||||
@@ -2028,21 +2026,208 @@ async def test_api_update_user_labels(http_client: AsyncClient):
|
|||||||
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
|
assert user_data["extra"]["labels"][1]["color"] == "#00FF00"
|
||||||
|
|
||||||
data.extra.labels = []
|
data.extra.labels = []
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=data.dict())
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=data.dict()
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
user_data = response.json()
|
user_data = response.json()
|
||||||
assert len(user_data["extra"]["labels"]) == 0
|
assert len(user_data["extra"]["labels"]) == 0
|
||||||
|
|
||||||
json_data = data.dict()
|
json_data = data.dict()
|
||||||
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
json_data["extra"] = {"labels": [{"name": "label + 01", "color": "#FF0000"}]}
|
||||||
response = await http_client.put(
|
response = await http_client.patch("/api/v1/auth?usr=" + user.id, json=json_data)
|
||||||
"/api/v1/auth/update?usr=" + user.id, json=json_data
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert (
|
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
@@ -14,9 +14,9 @@ from lnbits.core.crud import (
|
|||||||
delete_account,
|
delete_account,
|
||||||
get_account_by_username,
|
get_account_by_username,
|
||||||
get_payment,
|
get_payment,
|
||||||
|
get_user,
|
||||||
update_payment,
|
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 import Account, CreateInvoice, PaymentState, User
|
||||||
from lnbits.core.models.users import UpdateSuperuserPassword
|
from lnbits.core.models.users import UpdateSuperuserPassword
|
||||||
from lnbits.core.services import create_user_account, update_wallet_balance
|
from lnbits.core.services import create_user_account, update_wallet_balance
|
||||||
@@ -114,10 +114,10 @@ async def user_alan():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def admin_user():
|
async def admin_user():
|
||||||
username = "admin"
|
username = "admin_" + uuid4().hex[:8]
|
||||||
account = await get_account_by_username(username)
|
user = await get_user(ADMIN_USER_ID)
|
||||||
if account:
|
if user:
|
||||||
return await get_user_from_account(account)
|
return user
|
||||||
|
|
||||||
account = Account(
|
account = Account(
|
||||||
id=ADMIN_USER_ID,
|
id=ADMIN_USER_ID,
|
||||||
|
|||||||
+22
-1
@@ -1,4 +1,4 @@
|
|||||||
from datetime import date
|
from datetime import date, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -8,6 +8,9 @@ from lnbits.core.crud import (
|
|||||||
get_wallet,
|
get_wallet,
|
||||||
get_wallet_for_key,
|
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
|
from lnbits.db import POSTGRES
|
||||||
|
|
||||||
|
|
||||||
@@ -18,6 +21,24 @@ async def test_date_conversion(db):
|
|||||||
assert row and isinstance(row.get("now"), date)
|
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
|
# make test to create wallet and delete wallet
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_wallet_and_delete_wallet(app, to_user):
|
async def test_create_wallet_and_delete_wallet(app, to_user):
|
||||||
|
|||||||
+60
-20
@@ -100,8 +100,8 @@ def insert_to_pg(query, data):
|
|||||||
logger.error(exc)
|
logger.error(exc)
|
||||||
logger.error(f"Failed to insert {d}")
|
logger.error(f"Failed to insert {d}")
|
||||||
else:
|
else:
|
||||||
logger.error("query:", query)
|
logger.error("query: " + query)
|
||||||
logger.error("data:", d)
|
logger.error("data: " + str(d))
|
||||||
raise ValueError(f"Failed to insert {d}") from exc
|
raise ValueError(f"Failed to insert {d}") from exc
|
||||||
connection.commit()
|
connection.commit()
|
||||||
|
|
||||||
@@ -125,6 +125,7 @@ def migrate_ext(file: str):
|
|||||||
migrate_db(file, schema)
|
migrate_db(file, schema)
|
||||||
logger.info(f"✅ Migrated ext: {schema}")
|
logger.info(f"✅ Migrated ext: {schema}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error(exc)
|
||||||
logger.error(f"🛑 Failed to migrate extension {schema}: {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 = []
|
exclude_tables = []
|
||||||
assert os.path.isfile(file), f"{file} does not exist!"
|
assert os.path.isfile(file), f"{file} does not exist!"
|
||||||
|
|
||||||
cursor = get_sqlite_cursor(file)
|
sqlite_cursor = get_sqlite_cursor(file)
|
||||||
tables = cursor.execute(
|
tables = sqlite_cursor.execute(
|
||||||
"""
|
"""
|
||||||
SELECT name FROM sqlite_master
|
SELECT name FROM sqlite_master
|
||||||
WHERE type='table' AND name not like 'sqlite?_%' escape '?'
|
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:
|
if exclude_tables and table_name in exclude_tables:
|
||||||
continue
|
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)
|
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:
|
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)
|
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):
|
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):
|
def build_on_conflict_query_statement(schema, table_name, columns):
|
||||||
unique_cols = table_unique_columns(schema, table_name)
|
unique_cols = table_unique_columns(schema, table_name)
|
||||||
if len(unique_cols) == 0:
|
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):
|
def table_unique_columns(schema, table_name):
|
||||||
cursor = get_postgres_cursor()
|
cursor = get_postgres_cursor()
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT a.attname
|
SELECT
|
||||||
FROM pg_index i
|
array_agg(a.attname ORDER BY a.attnum) AS columns,
|
||||||
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
i.indisprimary as is_primary,
|
||||||
AND a.attnum = ANY(i.indkey)
|
i.indexrelid::regclass AS index_name,
|
||||||
WHERE i.indrelid = '{schema}.{table_name}'::regclass
|
COUNT(*) AS column_count,
|
||||||
AND i.indisunique;
|
(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)
|
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()
|
cursor.close()
|
||||||
return columns
|
if len(columns) == 0:
|
||||||
|
return []
|
||||||
|
return columns[0]
|
||||||
|
|
||||||
|
|
||||||
def to_column_type(column_type):
|
def to_column_type(column_type: str):
|
||||||
if column_type == "TIMESTAMP":
|
if column_type.upper() == "TIMESTAMP":
|
||||||
return "to_timestamp(%s)"
|
return "to_timestamp(%s)"
|
||||||
if column_type in ["BOOLEAN", "BOOL"]:
|
if column_type.upper() in ["BOOLEAN", "BOOL"]:
|
||||||
return "%s::boolean"
|
return "%s::boolean"
|
||||||
return "%s"
|
return "%s"
|
||||||
|
|
||||||
@@ -255,7 +295,7 @@ parser.add_argument(
|
|||||||
|
|
||||||
args = parser.parse_args()
|
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):
|
if os.path.isdir(args.sqlite_path):
|
||||||
exclude_tables = ["dbversions"]
|
exclude_tables = ["dbversions"]
|
||||||
|
|||||||
Reference in New Issue
Block a user