[feat] Extension Builder (#3339)

This commit is contained in:
Vlad Stan 2025-09-25 18:33:33 +03:00 committed by GitHub
parent c05122e5fb
commit 4f9a5090c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2871 additions and 16 deletions

View file

@ -7,6 +7,7 @@ from .views.audit_api import audit_router
from .views.auth_api import auth_router from .views.auth_api import auth_router
from .views.callback_api import callback_router from .views.callback_api import callback_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
from .views.extensions_builder_api import extension_builder_router
from .views.fiat_api import fiat_router from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
@ -31,6 +32,7 @@ def init_core_routers(app: FastAPI):
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(node_router) app.include_router(node_router)
app.include_router(extension_router) app.include_router(extension_router)
app.include_router(extension_builder_router)
app.include_router(super_node_router) app.include_router(super_node_router)
app.include_router(public_node_router) app.include_router(public_node_router)
app.include_router(payment_router) app.include_router(payment_router)

View file

@ -409,7 +409,6 @@ class InstallableExtension(BaseModel):
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash) tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True) shutil.rmtree(tmp_dir, True)
with zipfile.ZipFile(self.zip_path, "r") as zip_ref: with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir) zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0] generated_dir_name = os.listdir(tmp_dir)[0]
@ -628,8 +627,11 @@ class InstallableExtension(BaseModel):
@classmethod @classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]: async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = [] extension_releases: list[ExtensionRelease] = []
all_manifests = [
for url in settings.lnbits_extensions_manifests: *settings.lnbits_extensions_manifests,
settings.lnbits_extensions_builder_manifest_url,
]
for url in all_manifests:
try: try:
manifest = await cls.fetch_manifest(url) manifest = await cls.fetch_manifest(url)
for r in manifest.repos: for r in manifest.repos:

View file

@ -0,0 +1,460 @@
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from pydantic import BaseModel, validator
from lnbits.helpers import (
camel_to_snake,
is_camel_case,
is_snake_case,
urlsafe_short_hash,
)
class DataField(BaseModel):
name: str
type: str
label: str | None = None
hint: str | None = None
optional: bool = False
editable: bool = False
searchable: bool = False
sortable: bool = False
fields: list[DataField] = []
def normalize(self) -> None:
self.name = self.name.strip()
self.type = self.type.strip()
if self.label:
self.label = self.label.strip()
if self.hint:
self.hint = self.hint.strip()
if self.type == "json":
self.editable = False
self.searchable = False
self.sortable = False
else:
self.fields = []
for field in self.fields:
field.normalize()
def field_to_py(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if self.type == "json":
field_type = "dict"
elif self.type in ["wallet", "currency", "text"]:
field_type = "str"
if self.optional:
field_type += " | None"
if self.type == "currency":
field_type += ' = "sat"'
return f"{field_name}: {field_type}"
def field_to_js(self) -> str:
field_name = camel_to_snake(self.name)
default_value = "null"
if self.type == "json":
default_value = "{}"
if self.type == "currency":
default_value = '"sat"'
return f"{field_name}: {default_value}"
def field_to_ui_table_column(self) -> str:
column = {
"name": self.name,
"align": "left",
"label": self.label or self.name,
"field": self.name,
"sortable": self.sortable,
}
return json.dumps(column)
def field_to_db(self) -> str:
field_name = camel_to_snake(self.name)
field_type = self.type
if field_type == "str":
db_type = "TEXT"
elif field_type == "int":
db_type = "INT"
elif field_type == "float":
db_type = "REAL"
elif field_type == "bool":
db_type = "BOOLEAN"
elif field_type == "datetime":
db_type = "TIMESTAMP"
else:
db_type = "TEXT"
db_field = f"{field_name} {db_type}"
if not self.optional:
db_field += " NOT NULL"
if field_type == "json":
db_field += " DEFAULT '{empty_dict}'"
return db_field
def field_mock_value(self, index: int) -> Any:
if self.name == "id":
return urlsafe_short_hash()
if self.type == "int":
return index
elif self.type == "float":
return float(f"{index}.0{index * 2}")
elif self.type == "bool":
return True if index % 2 == 0 else False
elif self.type == "datetime":
return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
elif self.type == "json":
return {"key": "value"}
elif self.type == "currency":
return "USD"
else:
return f"{self.name} {index}"
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Field name is required.")
if not is_snake_case(v):
raise ValueError(f"Field Name must be snake_case. Found: {v}")
return v
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Owner Data type is required")
if v not in [
"str",
"int",
"float",
"bool",
"datetime",
"json",
"wallet",
"currency",
"text",
]:
raise ValueError(
"Field Type must be one of: "
"str, int, float, bool, datetime, json, wallet, currency, text."
f" Found: {v}"
)
return v
@validator("label")
def validate_label(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field label cannot contain double quotes ("). Value: {v}'
)
return v
@validator("hint")
def validate_hint(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
return v
class DataFields(BaseModel):
name: str
editable: bool = True
fields: list[DataField] = []
def __init__(self, **data):
super().__init__(**data)
self.normalize()
def normalize(self) -> None:
self.name = self.name.strip()
for field in self.fields:
field.normalize()
if all(not field.editable for field in self.fields):
self.editable = False
def get_field_by_name(self, name: str | None) -> DataField | None:
if not name:
return None
for field in self.fields:
if field.name == name:
return field
return None
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Data fields name is required")
if not is_camel_case(v):
raise ValueError(f"Data name must be CamelCase. Found: {v}")
return v
class SettingsFields(DataFields):
enabled: bool = False
type: str = "user"
@validator("type")
def validate_type(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Settings type is required")
if v not in ["user", "admin"]:
raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
return v
class ActionFields(BaseModel):
generate_action: bool = False
generate_payment_logic: bool = False
wallet_id: str | None = None
currency: str | None = None
amount: str | None = None
amount_source: Literal["owner_data", "client_data"] | None = None
paid_flag: str | None = None
class OwnerDataFields(BaseModel):
name: str | None = None
description: str | None = None
class ClientDataFields(BaseModel):
public_inputs: list[str] = []
class PublicPageFields(BaseModel):
has_public_page: bool = False
owner_data_fields: OwnerDataFields
client_data_fields: ClientDataFields
action_fields: ActionFields
class PreviewAction(BaseModel):
is_preview_mode: bool = False
is_settings_preview: bool = False
is_owner_data_preview: bool = False
is_client_data_preview: bool = False
is_public_page_preview: bool = False
def __init__(self, **data):
super().__init__(**data)
if not self.is_preview_mode:
self.is_settings_preview = False
self.is_owner_data_preview = False
self.is_client_data_preview = False
self.is_public_page_preview = False
class ExtensionData(BaseModel):
id: str
name: str
stub_version: str | None
short_description: str | None = None
description: str | None = None
owner_data: DataFields
client_data: DataFields
settings_data: SettingsFields
public_page: PublicPageFields
preview_action: PreviewAction = PreviewAction()
def __init__(self, **data):
super().__init__(**data)
self.validate_data()
self.normalize()
def normalize(self) -> None:
self.id = self.id.strip()
self.name = self.name.strip()
if self.stub_version:
self.stub_version = self.stub_version.strip()
if self.short_description:
self.short_description = self.short_description.strip()
if self.description:
self.description = self.description.strip()
if not self.public_page.has_public_page:
self.public_page.action_fields.generate_action = False
self.public_page.action_fields.generate_payment_logic = False
if not self.public_page.action_fields.generate_action:
self.public_page.action_fields.generate_payment_logic = False
def validate_data(self) -> None:
self._validate_field_names()
self._validate_public_page_fields()
self._validate_action_fields()
def _validate_public_page_fields(self) -> None:
if not self.public_page.has_public_page:
return
public_page_name = self.public_page.owner_data_fields.name
if public_page_name:
public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
if not public_page_name_field:
raise ValueError(
"Public Page Name must be one of the owner data fields."
f" Received: {public_page_name}."
)
public_page_description = self.public_page.owner_data_fields.description
if public_page_description:
public_page_description_field = self.owner_data.get_field_by_name(
public_page_description
)
if not public_page_description_field:
raise ValueError(
"Public Page Description must be one of the owner data fields."
f" Received: {public_page_description}."
)
public_page_inputs = self.public_page.client_data_fields.public_inputs
if public_page_inputs:
for input_field in public_page_inputs:
input_field_obj = self.client_data.get_field_by_name(input_field)
if not input_field_obj:
raise ValueError(
"Public Page Input fields"
" must be one of the client data fields."
f" Received: {input_field}."
)
def _validate_action_fields(self) -> None:
if not self.public_page.action_fields.generate_action:
return
if not self.public_page.action_fields.generate_payment_logic:
return
self._validate_owner_data_fields()
self._validate_client_data_fields()
def _validate_owner_data_fields(self) -> None:
wallet_id = self.public_page.action_fields.wallet_id
if wallet_id:
wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
if not wallet_id_field:
raise ValueError(
"Action Wallet ID must be one of the owner data fields."
f" Received: {wallet_id}."
)
if wallet_id_field.type != "wallet":
raise ValueError(
"Action Wallet ID field type must be 'wallet'."
f" Received: {wallet_id_field.type}."
)
currency = self.public_page.action_fields.currency
if currency:
currency_field = self.owner_data.get_field_by_name(currency)
if not currency_field:
raise ValueError(
"Action Currency must be one of the owner data fields."
f" Received: {currency}."
)
if currency_field.type != "currency":
raise ValueError(
"Action Currency field type must be 'currency'."
f" Received: {currency_field.type}."
)
def _validate_field_names(self) -> None:
reserved_names = {"id", "created_at", "updated_at"}
nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
)
nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
if nok:
raise ValueError(
f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
)
def _validate_client_data_fields(self) -> None:
amount = self.public_page.action_fields.amount
amount_source = self.public_page.action_fields.amount_source
if amount_source and amount:
if amount_source == "owner_data":
amount_field = self.owner_data.get_field_by_name(amount)
else:
amount_field = self.client_data.get_field_by_name(amount)
if not amount_field:
raise ValueError(
"Action Amount must be one of the "
"client data or owner data fields."
f" Received: {amount}."
)
if amount_field.type not in ["int", "float"]:
raise ValueError(
"Action Amount field type must be 'int' or 'float'."
f" Received: {amount_field.type}."
)
paid_flag = self.public_page.action_fields.paid_flag
if paid_flag:
paid_flag_field = self.client_data.get_field_by_name(paid_flag)
if not paid_flag_field:
raise ValueError(
"Action Paid Flag must be one of the client data fields."
f" Received: {paid_flag}."
)
if paid_flag_field.type != "bool":
raise ValueError(
"Action Paid Flag field type must be 'bool'."
f" Received: {paid_flag_field.type}."
)
@validator("id")
def validate_id(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension ID is required")
if not is_snake_case(v):
raise ValueError(f"Extension Id must be snake_case. Found: {v}")
return v
@validator("name")
def validate_name(cls, v: str) -> str:
if v.strip() == "":
raise ValueError("Extension name is required")
return v
@validator("stub_version")
def validate_stub_version(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Extension stub version cannot contain double quotes ("). Value: {v}'
)
return v
@validator("short_description")
def validate_short_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field short description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("description")
def validate_description(cls, v: str | None) -> str | None:
if v and '"' in v:
raise ValueError(
f'Field description cannot contain double quotes ("). Value: {v}'
)
return v
@validator("owner_data")
def validate_owner_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one owner data field is required")
return v
@validator("client_data")
def validate_client_data(cls, v: DataFields) -> DataFields:
if len(v.fields) == 0:
raise ValueError("At least one client data field is required")
return v

View file

@ -21,7 +21,9 @@ from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
async def install_extension(ext_info: InstallableExtension) -> Extension: async def install_extension(
ext_info: InstallableExtension, skip_download: bool | None = False
) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta() ext_info.meta = ext_info.meta or ExtensionMeta()
@ -35,6 +37,7 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
if installed_ext and installed_ext.meta: if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments ext_info.meta.payments = installed_ext.meta.payments
if not skip_download:
await ext_info.download_archive() await ext_info.download_archive()
ext_info.extract_archive() ext_info.extract_archive()

View file

@ -0,0 +1,543 @@
import asyncio
import json
import os
import shutil
import zipfile
from hashlib import sha256
from pathlib import Path
from time import time
from uuid import uuid4
import shortuuid
from jinja2 import Environment, FileSystemLoader
from loguru import logger
from lnbits.core.models.extensions import ExtensionRelease, InstallableExtension
from lnbits.core.models.extensions_builder import DataField, ExtensionData
from lnbits.db import dict_to_model
from lnbits.helpers import (
camel_to_snake,
camel_to_words,
download_url,
lowercase_first_letter,
)
from lnbits.settings import settings
py_files = [
"__init__.py",
"models.py",
"migrations.py",
"views_api.py",
"crud.py",
"views.py",
"tasks.py",
"services.py",
]
remove_line_marker = "{remove_line_marker}}"
ui_table_columns = [
DataField(
name="updated_at",
type="datetime",
label="Updated At",
hint="Timestamp of the last update",
optional=False,
editable=False,
searchable=False,
sortable=True,
),
DataField(
name="id",
type="str",
label="ID",
hint="Unique identifier",
optional=False,
editable=False,
searchable=False,
sortable=True,
),
]
excluded_dirs = {"./.", "./__pycache__", "./node_modules", "./transform"}
async def build_extension_from_data(
data: ExtensionData, stub_ext_id: str, working_dir_name: str | None = None
):
release = await _get_extension_stub_release(stub_ext_id, data.stub_version)
release.hash = sha256(uuid4().hex.encode("utf-8")).hexdigest()
release.icon = f"/{data.id}/static/image/{data.id}.png"
release.is_github_release = False
await _fetch_extension_builder_stub(stub_ext_id, release)
build_dir = _copy_ext_stub_to_build_dir(
stub_ext_id=stub_ext_id,
stub_version=release.version,
new_ext_id=data.id,
working_dir_name=working_dir_name,
)
_transform_extension_builder_stub(data, build_dir)
_export_extension_data_json(data, build_dir)
return release, build_dir
def clean_extension_builder_data() -> None:
working_dir = Path(settings.extension_builder_working_dir_path)
if working_dir.is_dir():
shutil.rmtree(working_dir, True)
working_dir.mkdir(parents=True, exist_ok=True)
def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
_replace_jinja_placeholders(data, extension_dir)
_rename_extension_builder_stub(data, extension_dir)
def _export_extension_data_json(data: ExtensionData, build_dir: Path):
json.dump(
data.dict(),
open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
indent=4,
)
async def _get_extension_stub_release(
stub_ext_id: str, stub_version: str | None = None
) -> ExtensionRelease:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
cache_dir.mkdir(parents=True, exist_ok=True)
release_cache_file = Path(cache_dir, "release.json")
if stub_version:
cached_release = _load_extension_stub_release_from_cache(
stub_ext_id, stub_version
)
if cached_release:
logger.debug(f"Loading release from cache {stub_ext_id} ({stub_version}).")
return cached_release
releases: list[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(stub_ext_id)
)
release = next((r for r in releases if r.version == stub_version), None)
if not release and len(releases) > 0:
release = releases[0]
if not release:
raise ValueError(f"Release {stub_ext_id} ({stub_version}) not found.")
logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
with open(release_cache_file, "w", encoding="utf-8") as f:
f.write(json.dumps(release.dict(), indent=4))
return release
def _load_extension_stub_release_from_cache(
stub_ext_id: str, stub_version: str
) -> ExtensionRelease | None:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
release_cache_file = Path(cache_dir, "release.json")
if release_cache_file.is_file():
with open(release_cache_file, encoding="utf-8") as f:
return dict_to_model(json.load(f), ExtensionRelease)
return None
async def _fetch_extension_builder_stub(
stub_ext_id: str, release: ExtensionRelease
) -> Path:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{release.version}")
cache_dir.mkdir(parents=True, exist_ok=True)
stub_ext_zip_path = Path(cache_dir, release.version + ".zip")
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
if not stub_ext_zip_path.is_file():
await asyncio.to_thread(download_url, release.archive_url, stub_ext_zip_path)
shutil.rmtree(ext_stub_cache_dir, True)
if not ext_stub_cache_dir.is_dir():
tmp_dir = Path(cache_dir, "tmp")
shutil.rmtree(tmp_dir, True)
tmp_dir.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(stub_ext_zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir = Path(tmp_dir, os.listdir(tmp_dir)[0])
shutil.copytree(generated_dir, Path(ext_stub_cache_dir))
shutil.rmtree(tmp_dir, True)
return ext_stub_cache_dir
def _copy_ext_stub_to_build_dir(
stub_ext_id: str,
stub_version: str,
new_ext_id: str,
working_dir_name: str | None = None,
) -> Path:
working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
cache_dir = Path(working_dir, f"cache-{stub_version}")
ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
if not ext_stub_cache_dir.is_dir():
raise ValueError(
f"Extension stub cache dir not found: {stub_ext_id} ({stub_version})"
)
working_dir_name = working_dir_name or f"ext-{int(time())}-{shortuuid.uuid()}"
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir)
return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data)
for py_file in py_files:
template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data}
)
embeded_index_js = rederer
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
owner_inputs = _fields_to_html_input(
[f for f in data.owner_data.fields if f.editable],
"ownerDataFormDialog.data",
ext_stub_dir,
)
client_inputs = _fields_to_html_input(
[f for f in data.client_data.fields if f.editable],
"clientDataFormDialog.data",
ext_stub_dir,
)
settings_inputs = _fields_to_html_input(
[f for f in data.settings_data.fields if f.editable],
"settingsFormDialog.data",
ext_stub_dir,
)
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
rederer = _render_file(
template_path,
{
"embeded_index_js": embeded_index_js,
"extension_builder_stub_owner_inputs": owner_inputs,
"extension_builder_stub_settings_inputs": settings_inputs,
"extension_builder_stub_client_inputs": client_inputs,
"preview": data.preview_action,
"cancel_comment": remove_line_marker,
**parsed_data,
},
)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
public_client_inputs = _fields_to_html_input(
[
f
for f in data.client_data.fields
if f.name in data.public_page.client_data_fields.public_inputs
],
"publicClientData",
ext_stub_dir,
)
public_template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
)
template_path = public_template_path.as_posix()
if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True)
else:
rederer = _render_file(
template_path,
{
"extension_builder_stub_public_client_inputs": public_client_inputs,
"preview": data.preview_action,
**data.public_page.action_fields.dict(),
"cancel_comment": remove_line_marker,
},
)
with open(template_path, "w", encoding="utf-8") as f:
f.write(rederer)
_remove_lines_with_string(template_path, remove_line_marker)
def zip_directory(source_dir, zip_path):
"""
Zips the contents of a directory (including subdirectories and files).
Parameters:
- source_dir (str): The path of the directory to zip.
- zip_path (str): The path where the .zip file will be saved.
"""
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(source_dir):
if _is_excluded_dir(root):
continue
for file in files:
full_path = os.path.join(root, file)
# Add file with a relative path inside the zip
relative_path = os.path.relpath(full_path, start=source_dir)
zipf.write(full_path, arcname=relative_path)
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix()
rename_values = {
"extension_builder_stub_name": data.name,
"extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id,
"OwnerData": data.owner_data.name,
"ownerData": lowercase_first_letter(data.owner_data.name),
"Owner Data": camel_to_words(data.owner_data.name),
"owner data": camel_to_words(data.owner_data.name).lower(),
"owner_data": camel_to_snake(data.owner_data.name),
"ClientData": data.client_data.name,
"clientData": lowercase_first_letter(data.client_data.name),
"Client Data": camel_to_words(data.client_data.name),
"client data": camel_to_words(data.client_data.name).lower(),
"client_data": camel_to_snake(data.client_data.name),
}
for old_text, new_text in rename_values.items():
_replace_text_in_files(
directory=extension_dir_path,
old_text=old_text,
new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
)
_rename_files_and_dirs_in_directory(
directory=extension_dir_path,
old_text="extension_builder_stub",
new_text=data.id,
)
_rename_files_and_dirs_in_directory(
directory=extension_dir_path,
old_text="owner_data",
new_text=camel_to_snake(data.owner_data.name),
)
def _replace_text_in_files(
directory: str,
old_text: str,
new_text: str,
file_extensions: list[str] | None = None,
):
"""
Recursively replaces text in all files under the given directory.
"""
for root, _, files in os.walk(directory):
if _is_excluded_dir(root):
continue
for filename in files:
if file_extensions:
if not any(filename.endswith(ext) for ext in file_extensions):
continue
file_path = os.path.join(root, filename)
try:
with open(file_path, encoding="utf-8") as f:
content = f.read()
if old_text in content:
new_content = content.replace(old_text, new_text)
with open(file_path, "w", encoding="utf-8") as f:
f.write(new_content)
logger.trace(f"Updated: {file_path}")
except (UnicodeDecodeError, PermissionError, FileNotFoundError) as e:
logger.debug(f"Skipped {file_path}: {e}")
def _render_file(template_path: str, data: dict) -> str:
# Extract directory and file name
template_dir = os.path.dirname(template_path)
template_file = os.path.basename(template_path)
# Create Jinja environment
# env = Environment(loader=FileSystemLoader(template_dir))
env = _jinja_env(template_dir)
template = env.get_template(template_file)
# Render the template with data
return template.render(**data)
def _jinja_env(template_dir: str) -> Environment:
return Environment(
loader=FileSystemLoader(template_dir),
variable_start_string="<<",
variable_end_string=">>",
block_start_string="<%",
block_end_string="%>",
comment_start_string="<#",
comment_end_string="#>",
autoescape=False,
)
def _parse_extension_data(data: ExtensionData) -> dict:
return {
"owner_data": {
"name": data.owner_data.name,
"editable": data.owner_data.editable,
"js_fields": [
field.field_to_js()
for field in data.owner_data.fields
if field.editable
],
"search_fields": [
camel_to_snake(field.name)
for field in data.owner_data.fields
if field.searchable
],
"ui_table_columns": [
field.field_to_ui_table_column()
for field in data.owner_data.fields + ui_table_columns
if field.sortable
],
"ui_mock_data": [
json.dumps(
{
field.name: field.field_mock_value(index=index)
for field in data.owner_data.fields + ui_table_columns
}
)
for index in range(1, 5)
],
"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],
},
"client_data": {
"name": data.client_data.name,
"editable": data.client_data.editable,
"search_fields": [
camel_to_snake(field.name)
for field in data.client_data.fields
if field.searchable
],
"ui_table_columns": [
field.field_to_ui_table_column()
for field in data.client_data.fields + ui_table_columns
if field.sortable
],
"ui_mock_data": [
json.dumps(
{
field.name: field.field_mock_value(index=index)
for field in data.client_data.fields + ui_table_columns
}
)
for index in range(1, 7)
],
"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],
},
"settings_data": {
"enabled": data.settings_data.enabled,
"is_admin_settings_only": data.settings_data.type == "admin",
"db_fields": [field.field_to_db() for field in data.settings_data.fields],
"all_fields": [field.field_to_py() for field in data.settings_data.fields],
},
"public_page": data.public_page,
"cancel_comment": remove_line_marker,
}
def _fields_to_html_input(
fields: list[DataField], model_name: str, ext_stub_dir: Path
) -> str:
template_path = Path(
ext_stub_dir, "templates", "extension_builder_stub", "_input_fields.html"
).as_posix()
rederer = _render_file(
template_path,
{
"fields": fields,
"model_name": model_name,
},
)
return rederer
def _remove_lines_with_string(file_path: str, target: str) -> None:
"""
Removes lines from a file that contain the given target string.
Args:
file_path (str): Path to the file.
target (str): Substring to search for in lines to remove.
"""
with open(file_path, encoding="utf-8") as f:
lines = f.readlines()
filtered_lines = [line for line in lines if target not in line]
with open(file_path, "w", encoding="utf-8") as f:
f.writelines(filtered_lines)
def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
"""
Recursively renames files and directories by replacing part of their names.
"""
# First rename directories (bottom-up) so we don't lose paths while renaming
for root, dirs, files in os.walk(directory, topdown=False):
if _is_excluded_dir(root):
continue
# Rename files
for filename in files:
if old_text in filename:
old_path = os.path.join(root, filename)
new_filename = filename.replace(old_text, new_text)
new_path = os.path.join(root, new_filename)
try:
os.rename(old_path, new_path)
logger.trace(f"Renamed file: {old_path} -> {new_path}")
except Exception as e:
logger.warning(f"Failed to rename file {old_path}: {e}")
# Rename directories
for dirname in dirs:
if old_text in dirname:
old_dir_path = os.path.join(root, dirname)
new_dir_name = dirname.replace(old_text, new_text)
new_dir_path = os.path.join(root, new_dir_name)
try:
os.rename(old_dir_path, new_dir_path)
logger.trace(f"Renamed directory: {old_dir_path} -> {new_dir_path}")
except Exception as e:
logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path):
for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir):
return True
return False

View file

@ -84,6 +84,27 @@
/> />
</q-item-section> </q-item-section>
</q-item> </q-item>
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>
<span v-text="$t('misc_disable_extensions_builder')"></span>
</q-item-label>
<q-item-label caption>
<span
v-text="$t('misc_disable_extensions_builder_label')"
></span>
</q-item-label>
</q-item-section>
<q-item-section avatar>
<q-toggle
size="md"
v-model="formData.lnbits_extensions_builder_activate_non_admins"
checked-icon="check"
color="green"
unchecked-icon="clear"
/>
</q-item-section>
</q-item>
<q-item tag="label" v-ripple> <q-item tag="label" v-ripple>
<q-item-section> <q-item-section>
<q-item-label> <q-item-label>
@ -105,6 +126,17 @@
</q-item> </q-item>
<br /> <br />
</div> </div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('extension_builder_manifest_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_builder_manifest_url"
:label="$t('extension_builder_manifest_url')"
:hint="$t('extension_builder_manifest_url_hint')"
></q-input>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>

View file

@ -24,6 +24,7 @@
v-text="$t('only_admins_can_install')" v-text="$t('only_admins_can_install')"
></i> ></i>
<q-space></q-space> <q-space></q-space>
<q-input <q-input
:label="$t('search_extensions')" :label="$t('search_extensions')"
:dense="dense" :dense="dense"
@ -53,6 +54,18 @@
v-text="$t('new_version') + ` (${updatableExtensions?.length})`" v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
></span> ></span>
</q-badge> </q-badge>
{% if extension_builder_enabled %}
<q-btn flat no-caps icon="architecture" to="/extensions/builder"
><span v-text="$t('create_extension')"></span
></q-btn>
{% else %}
<q-btn disabled flat no-caps icon="architecture"
><span v-text="$t('create_extension')"></span>
<q-tooltip
v-text="$t('only_admins_can_create_extensions')"
></q-tooltip>
</q-btn>
{% endif %}
<q-btn <q-btn
v-if="g.user.admin" v-if="g.user.admin"
flat flat

View file

@ -0,0 +1,830 @@
{% if not ajax %} {% extends "base.html" %} {% endif %}
<!---->
{% from "macros.jinja" import window_vars with context %}
<!---->
{% block scripts %} {{ window_vars(user) }}{% endblock %} {% block page %}
<div class="row">
<div class="col-12">
<q-stepper
v-model="step"
ref="stepper"
color="primary"
animated
header-nav
class="q-pt-sm"
@update:model-value="onStepChange"
>
<q-step
:name="1"
title="Describe"
icon="info"
:done="step > 1"
style="min-height: 100px"
>
<div class="row q-col-gutter-md">
<div class="col-12">
<span class="text-h6">
Tell us something about your extension:
</span>
<ul>
<li>This is the first step, you can return and change it.</li>
<li>
The <code>`name`</code> and
<code>`sort description`</code> fields are what the users will
see when browsing the list of extensions.
</li>
<li>
The <code>`id`</code> field is used internally and in the URL of
your extension.
</li>
</ul>
</div>
<!-- todo: add icon -->
<div class="col-12">
<div>
<q-btn
color="primary"
label="Upload Existing config"
@click="$refs.extensionDataInput.click()"
class="q-mb-md"
/>
<input
type="file"
ref="extensionDataInput"
accept="application/json"
style="display: none"
@change="onJsonDataInput"
/>
</div>
</div>
</div>
<q-separator class="q-mt-sm"></q-separator>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.name"
label="Extension Name"
hint="The name of your extension"
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.id"
label="Extension Id"
hint="Lowercase letters, numbers, and underscores only (snake_case). This will be used in the URL."
>
</q-input>
</div>
<div class="col-md-4 col-sm-12">
<q-input
filled
v-model="extensionData.short_description"
label="Short Description"
hint="A short description that is shown in the extension list."
>
</q-input>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<q-input
filled
v-model="extensionData.description"
label="Description"
hint="A detailed description of your extension."
type="textarea"
rows="3"
maxlength="1000"
>
</q-input>
</div>
</div>
</q-step>
<q-step
:name="2"
title="Settings"
icon="settings"
:done="step > 2"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep2"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('settings')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.settings_data.enabled"
label="Generate Settings Fields"
size="md"
color="green"
/>
<br />
<ul>
<li>Define what settings your extension will have.</li>
<li>
You can choose if each user has its own settings or if the
settings are global (set by the admin).
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.settings_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-md-2 col-sm-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.settings_data.type"
:options="settingsTypes"
></q-select>
</div>
<div class="col-md-10 col-sm-12 q-pt-sm">
<q-badge
v-if="extensionData.settings_data.type === 'user'"
outline
class="text-caption q-ml-md"
>Each user can set its own settings for this extension.</q-badge
>
<q-badge v-else outline class="text-caption q-ml-md"
>Settings are set by the admin and apply to all users of the
extension</q-badge
>
</div>
</div>
<div v-if="extensionData.settings_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.settings_data.fields"
:hide-advanced="true"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="3"
:done="step > 3"
title="Owner Data"
icon="list"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep3"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('owner_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-input
v-model="extensionData.owner_data.name"
filled
label="Owner Table Name"
hint="CamelCase name for the owner data table (e.g. Campaign, PoS, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
The owner of the extension manages this data. It can add, remove
and update instances of it.
</li>
<li>
Some fileds are present by default, like
<code>created_at</code>, <code>updated_at</code> and
<code>extra</code>.
</li>
</ul>
</div>
</div>
<div class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.owner_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="4"
:done="step > 4"
title="Client Data"
icon="blur_linear"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep4"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('client_data')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<!-- <q-toggle
v-model="extensionData.client_data.enabled"
label="Generate Client Table"
disable
size="md"
color="green"
/>
<br /> -->
<q-input
v-if="extensionData.client_data.enabled"
v-model="extensionData.client_data.name"
filled
label="Client Table Name"
hint="CamelCase name for the client data table (e.g. Donation, Payment, etc.)"
class="q-mb-xl"
>
</q-input>
<ul>
<li>
This data is created by users of the extension. Usually when
they submit a form or make a payment.
</li>
<li>
The owner of the extension can view this data, but should not
modify it.
</li>
</ul>
</div>
</div>
<q-separator
v-if="extensionData.client_data.enabled"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.client_data.enabled" class="row q-mt-lg">
<div class="col-12">
<lnbits-data-fields
:fields="extensionData.client_data.fields"
></lnbits-data-fields>
</div>
</div>
</q-step>
<q-step
:name="5"
:done="step > 5"
title="Public Pages"
icon="link"
style="min-height: 100px"
>
<div class="row q-col-gutter-md q-mt-md">
<div class="col-md-8 col-sm-12">
<iframe
ref="iframeStep5"
class="full-width"
height="400px"
sandbox="allow-scripts allow-same-origin"
></iframe>
</div>
<div class="col-md-4 col-sm-12">
<q-btn
@click="previewExtension('public_page')"
color="primary"
outline
label="Refresh Preview"
class="full-width q-mb-md"
></q-btn>
<q-toggle
v-model="extensionData.public_page.has_public_page"
label="Generate Public Page"
size="md"
color="green"
/>
<br />
<ul>
<li>
Most extensions have a public page that can be shared (this page
will still be accessible even if you have restricted access to
your LNbits install).
</li>
</ul>
</div>
</div>
<div v-if="extensionData.public_page.has_public_page">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page title</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a title for the public
page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.name"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page description</q-item-label>
<q-item-label caption
>Select the field from the
<code v-text="extensionData.owner_data.name"></code>&nbsp;
(Owner Data) that will be used as a description for the
public page.</q-item-label
>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.owner_data_fields.description"
:options="[''].concat(extensionData.owner_data.fields.map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Public page inputs</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the fields from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be shown as inputs in
the public page form.
</li>
<li>You can select multiple fields.</li>
<li>
A corresponding input field will be created for each
selected field.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
multiple
use-chips
v-model="extensionData.public_page.client_data_fields.public_inputs"
:options="extensionData.client_data.fields.map(f => f.name)"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Action Button</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the public page will have a button to
perform an action (e.g. generate a payment request).
</li>
<li>
The action will use the selected input fields from
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) as parameters.
</li>
<li>
A corresponding REST API endpoint will be created.
</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_action"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
</div>
<q-separator
v-if="extensionData.public_page.action_fields.generate_action"
class="q-mt-sm"
></q-separator>
<div v-if="extensionData.public_page.action_fields.generate_action">
<div class="row q-col-gutter-md q-mt-md">
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Generate Payment Logic</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
If enabled, the endpoint will create an invoice from
the submitted data and the UI will show the QR code
with the invoice.
</li>
<li>
A listener will be created to check for the pay event.
</li>
<li>You must map the fieds.</li>
</ul></q-item-label
>
</q-item-section>
<q-item-section avatar>
<q-toggle
v-model="extensionData.public_page.action_fields.generate_payment_logic"
size="md"
color="green"
/>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6"></div>
</div>
<div
v-if="extensionData.public_page.action_fields.generate_action && extensionData.public_page.action_fields.generate_payment_logic"
class="row q-col-gutter-md q-mt-md"
>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Wallet</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the wallet which
will generate the invoice and receive the payments.
</li>
<li>
Only fields with the type <code>Wallet</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.wallet_id"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'wallet').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Currency</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) that represents the currency
which will be used to for the amount.
</li>
<li>
Only fields with the type <code>Currency</code> will
be shown.
</li>
<li>Empty if you want to use sats.</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.currency"
:options="[''].concat(extensionData.owner_data.fields.filter(f => f.type === 'currency').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Amount</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.owner_data.name"></code
>&nbsp; (Owner Data) or
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that represents the amount (in
the selected currency).
</li>
<li>
Only fields with the type <code>Integer</code> and
<code>Float</code> will be shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount_source"
:options="amountSource"
class="q-mr-sm"
></q-select>
</div>
<div class="col-6">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.amount"
:options="paymentActionAmountFields"
></q-select>
</div>
</div>
</q-item-section>
</q-item>
</div>
<div class="col-12 col-md-6">
<q-item tag="label" v-ripple>
<q-item-section>
<q-item-label>Paid Flag</q-item-label>
<q-item-label caption>
<ul class="q-pa-none q-ma-none">
<li>
Select the field from the
<code v-text="extensionData.client_data.name"></code
>&nbsp; (Client Data) that will be set to true when
the invoice is paid.
</li>
<li>
Only fields with the type <code>Boolean</code> will be
shown.
</li>
</ul>
</q-item-label>
</q-item-section>
<q-item-section>
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.public_page.action_fields.paid_flag"
:options="[''].concat(extensionData.client_data.fields.filter(f => f.type === 'bool').map(f => f.name))"
></q-select>
</q-item-section>
</q-item>
</div>
</div>
</div>
</div>
</q-step>
<q-step
:name="6"
:done="step > 6"
title="Publish"
icon="publish"
style="min-height: 100px"
>
<div v-if="g.user.admin" class="row">
<div class="col-md-4 col-sm-12 col-xs-12">
<q-select
filled
dense
emit-value
map-options
v-model="extensionData.stub_version"
hint="The version of the extension stub. Make sure it is compatible with your LNbits install."
:options="extensionStubVersions.map(f => f.version)"
></q-select>
</div>
<div class="col-md-4 col-sm-12 col-xs-12">
<q-btn
@click="cleanCacheData()"
color="grey"
outline
label="Clean Cache"
class="q-ml-md"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
The extension builder uses caching to speed up the build
process.
</li>
<li>
This action clears old data and redownloads the Extension
Builder Stub release.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
<div v-if="g.user.admin" class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtensionAndDeploy()"
color="primary"
label="Build and Deploy (Admin Only)"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Installs the extension directly to this LNbits instance.
</li>
<li>
The extension will be enabled by default, and available to
all users.
</li>
</ul>
</q-tooltip>
</q-icon>
</div>
</div>
</div>
<div class="row q-mt-md">
<div class="col-md-4 col-sm-12 col-xs-12">
<div class="row">
<q-btn
@click="buildExtension()"
outline
color="gray"
label="Download Extension Zip"
icon="download"
class="col"
/>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
Builds the extension and downloads a zip file with the code.
You can then install it manually in your LNbits instance.
</q-tooltip>
</q-icon>
</div>
</div>
</div>
</q-step>
<template v-slot:navigation>
<q-separator></q-separator>
<div class="row">
<div class="col-md-6 col-sm-12 q-pl-md q-pt-md">
<q-btn
v-if="step == 1"
label="Clear All Data"
color="negative"
@click="clearAllData"
></q-btn>
<q-btn
v-else
flat
color="grey-8"
class="q-mr-sm"
@click="previousStep()"
label="Back"
icon="chevron_left"
/>
</div>
<div class="col-md-6 col-sm-12 q-pr-md q-pb-md">
<q-stepper-navigation class="float-right">
<q-btn
v-if="step < 6"
@click="nextStep()"
color="primary"
label="Next"
></q-btn>
<template v-else>
<q-btn
@click="exportJsonData()"
color="primary"
label="Export JSON Data"
></q-btn>
<q-icon
name="info"
size="md"
color="primary"
class="cursor-pointer q-ml-sm self-center"
>
<q-tooltip>
<ul class="q-pl-sm">
<li>
Exports the config JSON so it can be later imported or
shared.
</li>
<li>
This JSON is also added to the zip in a file called
`builder.json`.
</li>
</ul>
</q-tooltip>
</q-icon>
</template>
</q-stepper-navigation>
</div>
</div>
</template>
</q-stepper>
</div>
</div>
<div class="row q-col-gutter-md"></div>
{% endblock %}

View file

@ -3,11 +3,7 @@ import traceback
from http import HTTPStatus from http import HTTPStatus
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import ( from fastapi import APIRouter, Depends, HTTPException
APIRouter,
Depends,
HTTPException,
)
from loguru import logger from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions

View file

@ -0,0 +1,164 @@
import os
import shutil
from hashlib import sha256
from http import HTTPStatus
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from lnbits.core.models import (
SimpleStatus,
User,
)
from lnbits.core.models.extensions import (
Extension,
ExtensionMeta,
InstallableExtension,
UserExtension,
)
from lnbits.core.models.extensions_builder import ExtensionData
from lnbits.core.services.extensions import (
activate_extension,
install_extension,
)
from lnbits.core.services.extensions_builder import (
build_extension_from_data,
clean_extension_builder_data,
zip_directory,
)
from lnbits.decorators import (
check_admin,
check_user_exists,
)
from lnbits.settings import settings
from ..crud import (
create_user_extension,
get_user_extension,
update_user_extension,
)
extension_builder_router = APIRouter(
tags=["Extension Managment"],
prefix="/api/v1/extension/builder",
)
@extension_builder_router.post(
"/zip",
summary="Build and download extension zip.",
description="""
This endpoint generates a zip file for the extension based on the provided data.
""",
)
async def api_build_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> FileResponse:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub" # todo: do not hardcode, fetch from manifest
release, build_dir = await build_extension_from_data(data, stub_ext_id)
ext_info = InstallableExtension(
id=data.id,
name=data.name,
version="0.1.0",
short_description=data.short_description,
meta=ExtensionMeta(installed_release=release),
)
ext_zip_file = ext_info.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
zip_directory(build_dir, ext_zip_file)
shutil.rmtree(build_dir, True)
return FileResponse(
ext_zip_file, filename=f"{data.id}.zip", media_type="application/zip"
)
@extension_builder_router.post(
"/deploy",
summary="Build extension based on provided config.",
description="""
This endpoint generates a zip file for the extension based on the provided data.
If `deploy` is set to true, the extension will be installed and activated.
""",
)
async def api_deploy_extension(
data: ExtensionData,
user: User = Depends(check_admin),
) -> SimpleStatus:
working_dir_name = "deploy_" + sha256(user.id.encode("utf-8")).hexdigest()
stub_ext_id = "extension_builder_stub"
release, build_dir = await build_extension_from_data(
data, stub_ext_id, working_dir_name
)
ext_info = InstallableExtension(
id=data.id,
name=data.name,
version="0.1.0",
short_description=data.short_description,
meta=ExtensionMeta(installed_release=release),
icon=release.icon,
)
ext_zip_file = ext_info.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
zip_directory(build_dir.parent, ext_zip_file)
await install_extension(ext_info, skip_download=True)
await activate_extension(Extension.from_installable_ext(ext_info))
user_ext = await get_user_extension(user.id, data.id)
if not user_ext:
user_ext = UserExtension(user=user.id, extension=data.id, active=True)
await create_user_extension(user_ext)
elif not user_ext.active:
user_ext.active = True
await update_user_extension(user_ext)
return SimpleStatus(success=True, message=f"Extension '{data.id}' deployed.")
@extension_builder_router.post(
"/preview",
summary="Build and preview the extension ui.",
)
async def api_preview_extension(
data: ExtensionData,
user: User = Depends(check_user_exists),
) -> SimpleStatus:
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
stub_ext_id = "extension_builder_stub"
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
await build_extension_from_data(data, stub_ext_id, working_dir_name)
return SimpleStatus(success=True, message=f"Extension '{data.id}' preview ready.")
@extension_builder_router.delete(
"",
summary="Clean extension builder data.",
description="""
This endpoint cleans the extension builder data.
""",
dependencies=[Depends(check_admin)],
)
async def api_delete_extension_builder_data() -> SimpleStatus:
clean_extension_builder_data()
return SimpleStatus(success=True, message="Extension Builder data cleaned.")

View file

@ -1,4 +1,6 @@
from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
from typing import Annotated from typing import Annotated
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
@ -149,11 +151,95 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
{ {
"user": user.json(), "user": user.json(),
"extension_data": extension_data, "extension_data": extension_data,
"extension_builder_enabled": user.admin
or settings.lnbits_extensions_builder_activate_non_admins,
"ajax": _is_ajax_request(request), "ajax": _is_ajax_request(request),
}, },
) )
@generic_router.get(
"/extensions/builder", name="extensions builder", response_class=HTMLResponse
)
async def extensions_builder(request: Request, user: User = Depends(check_user_exists)):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
return template_renderer().TemplateResponse(
request,
"core/extensions_builder.html",
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
@generic_router.get(
"/extensions/builder/preview/{ext_id}",
name="extensions builder",
response_class=HTMLResponse,
)
async def extensions_builder_preview(
request: Request,
ext_id: str,
page_name: str | None = None,
user: User = Depends(check_user_exists),
):
if not settings.lnbits_extensions_builder_activate_non_admins and not user.admin:
raise HTTPException(
HTTPStatus.FORBIDDEN,
"Extension Builder is disabled for non admin users.",
)
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(
"extension_builder_stub",
ext_id,
working_dir_name,
ext_id,
"templates",
ext_id,
html_file_name,
)
html_file_full_path = Path(
settings.extension_builder_working_dir_path, html_file_path
)
if not html_file_full_path.is_file():
return template_renderer().TemplateResponse(
request,
"error.html",
{
"err": f"Extension {ext_id} not found",
"message": "Please 'Refresh Preview' first.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
"ajax": _is_ajax_request(request),
},
)
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'"
)
return response
@generic_router.get( @generic_router.get(
"/wallet", "/wallet",
response_class=HTMLResponse, response_class=HTMLResponse,

View file

@ -53,7 +53,12 @@ def static_url_for(static: str, path: str) -> str:
def template_renderer(additional_folders: list | None = None) -> Jinja2Templates: def template_renderer(additional_folders: list | None = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"] folders = [
"lnbits/templates",
"lnbits/core/templates",
settings.extension_builder_working_dir_path.as_posix(),
]
if additional_folders: if additional_folders:
additional_folders += [ additional_folders += [
Path(settings.lnbits_extensions_path, "extensions", f) Path(settings.lnbits_extensions_path, "extensions", f)
@ -368,3 +373,27 @@ def normalize_endpoint(endpoint: str, add_proto=True) -> str:
f"https://{endpoint}" if not endpoint.startswith("http") else endpoint f"https://{endpoint}" if not endpoint.startswith("http") else endpoint
) )
return endpoint return endpoint
def camel_to_words(name: str) -> str:
# Add space before capital letters (but not at the start)
words = re.sub(r"(?<!^)(?=[A-Z])", " ", name)
return words.strip()
def camel_to_snake(name: str) -> str:
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
return name.lower()
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
def is_snake_case(v: str) -> bool:
return re.match(r"^[a-z]+(_[a-z0-9]+)*$", v) is not None
def lowercase_first_letter(s: str) -> str:
return s[:1].lower() + s[1:] if s else s

View file

@ -51,11 +51,19 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: list[str] = Field(default=[]) lnbits_admin_extensions: list[str] = Field(default=[])
lnbits_user_default_extensions: list[str] = Field(default=[]) lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_manifests: list[str] = Field( lnbits_extensions_manifests: list[str] = Field(
default=[ default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json" "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
] ]
) )
lnbits_extensions_builder_manifest_url: str = Field(
default="https://raw.githubusercontent.com/lnbits/extension_builder_stub/refs/heads/main/manifest.json"
)
@property
def extension_builder_working_dir_path(self) -> Path:
return Path(settings.lnbits_data_folder, "extensions_builder")
class ExtensionsInstallSettings(LNbitsSettings): class ExtensionsInstallSettings(LNbitsSettings):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -243,6 +243,13 @@ body.body--dark .q-field--error .q-field__messages {
width: 500px; width: 500px;
} }
.blur-and-disable {
filter: blur(4px); /* blur entire element & children */
pointer-events: none; /* block mouse interaction */
user-select: none; /* prevent text selection */
opacity: 0.6; /* optional: faded look */
}
.lnbits__table-bordered td, .lnbits__table-bordered td,
.lnbits__table-bordered th { .lnbits__table-bordered th {
border: 1px solid black; border: 1px solid black;

View file

@ -164,6 +164,8 @@ window.localisation.en = {
featured: 'Featured', featured: 'Featured',
all: 'All', all: 'All',
only_admins_can_install: '(Only admin accounts can install extensions)', only_admins_can_install: '(Only admin accounts can install extensions)',
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
new_version: 'New Version', new_version: 'New Version',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
@ -420,6 +422,7 @@ window.localisation.en = {
admin_settings: 'Admin Settings', admin_settings: 'Admin Settings',
extension_cost: 'This release requires a payment of minimum {cost} sats.', extension_cost: 'This release requires a payment of minimum {cost} sats.',
extension_paid_sats: 'You have already paid {paid_sats} sats.', extension_paid_sats: 'You have already paid {paid_sats} sats.',
create_extension: 'Create Extension',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
pay_with: 'Pay with {provider}', pay_with: 'Pay with {provider}',
@ -489,9 +492,16 @@ window.localisation.en = {
user_default_extensions_label: 'User extensions', user_default_extensions_label: 'User extensions',
user_default_extensions_hint: user_default_extensions_hint:
'Extensions that will be enabled by default for the users.', 'Extensions that will be enabled by default for the users.',
extension_builder: 'Extension Builder',
extension_builder_manifest_url: 'Extension Builder Manifest URL',
extension_builder_manifest_url_hint:
'URL to a JSON manifest file with extension builder details',
miscellanous: 'Miscellanous', miscellanous: 'Miscellanous',
misc_disable_extensions: 'Disable Extensions', misc_disable_extensions: 'Disable Extensions',
misc_disable_extensions_label: 'Disable all extensions', misc_disable_extensions_label: 'Disable all extensions',
misc_disable_extensions_builder: 'Enable Extensions Builder',
misc_disable_extensions_builder_label:
'Enable Extensions Builder for non admin users.',
misc_hide_api: 'Hide API', misc_hide_api: 'Hide API',
misc_hide_api_label: 'Hides wallet api, extensions can choose to honor', misc_hide_api_label: 'Hides wallet api, extensions can choose to honor',
wallets_management: 'Wallets Management', wallets_management: 'Wallets Management',

View file

@ -1,14 +1,15 @@
window.LNbits = { window.LNbits = {
g: window.g, g: window.g,
api: { api: {
request(method, url, apiKey, data) { request(method, url, apiKey, data, options = {}) {
return axios({ return axios({
method: method, method: method,
url: url, url: url,
headers: { headers: {
'X-Api-Key': apiKey 'X-Api-Key': apiKey
}, },
data: data data: data,
...options
}) })
}, },
getServerHealth() { getServerHealth() {

View file

@ -0,0 +1,115 @@
window.app.component('lnbits-data-fields', {
name: 'lnbits-data-fields',
template: '#lnbits-data-fields',
props: ['fields', 'hide-advanced'],
data() {
return {
fieldTypes: [
{label: 'Text', value: 'str'},
{label: 'Integer', value: 'int'},
{label: 'Float', value: 'float'},
{label: 'Boolean', value: 'bool'},
{label: 'Date Time', value: 'datetime'},
{label: 'JSON', value: 'json'},
{label: 'Wallet Select', value: 'wallet'},
{label: 'Currency Select', value: 'currency'}
],
fieldsTable: {
columns: [
{
name: 'name',
align: 'left',
label: 'Field Name',
field: 'name',
sortable: true
},
{
name: 'type',
align: 'left',
label: 'Type',
field: 'type',
sortable: false
},
{
name: 'label',
align: 'left',
label: 'UI Label',
field: 'label',
sortable: true
},
{
name: 'hint',
align: 'left',
label: 'UI Hint',
field: 'hint',
sortable: false
},
{
name: 'optional',
align: 'left',
label: 'Optional',
field: 'optional',
sortable: false
}
],
pagination: {
sortBy: 'name',
rowsPerPage: 100,
page: 1,
rowsNumber: 100
},
search: null,
hideEmpty: true
}
}
},
methods: {
addField: function () {
this.fields.push({
name: 'field_name_' + (this.fields.length + 1),
type: 'text',
label: '',
hint: '',
optional: true,
sortable: true,
searchable: true,
editable: true,
fields: [] // For nested fields in JSON type
})
},
removeField: function (field) {
const index = this.fields.indexOf(field)
if (index > -1) {
this.fields.splice(index, 1)
}
}
},
async created() {
if (!this.hideAdvanced) {
this.fieldsTable.columns.push(
{
name: 'editable',
align: 'left',
label: 'UI Editable',
field: 'editable',
sortable: false
},
{
name: 'sortable',
align: 'left',
label: 'Sortable',
field: 'sortable',
sortable: false
},
{
name: 'searchable',
align: 'left',
label: 'Searchable',
field: 'searchable',
sortable: false
}
)
}
}
})

View file

@ -0,0 +1,350 @@
window.ExtensionsBuilderPageLogic = {
data: function () {
return {
step: 1,
previewStepNames: {
2: 'settings',
3: 'owner_data',
4: 'client_data',
5: 'public_page'
},
extensionDataCleanString: '',
extensionData: {
id: '',
name: '',
stub_version: '',
short_description: '',
description: '',
public_page: {
has_public_page: true,
owner_data_fields: {
name: '',
description: ''
},
client_data_fields: {
public_inputs: []
},
action_fields: {
generate_action: true,
generate_payment_logic: false,
wallet_id: '',
currency: '',
amount: '',
paid_flag: ''
}
},
preview_action: {
is_preview_mode: false,
is_settings_preview: false,
is_owner_data_preview: false,
is_client_data_preview: false,
is_public_page_preview: false
},
settings_data: {
name: 'Settings',
enabled: true,
type: 'user',
fields: []
},
owner_data: {
name: 'OwnerData',
fields: []
},
client_data: {
enabled: true,
name: 'ClientData',
fields: []
}
},
sampleField: {
name: 'name',
type: 'str',
label: 'Name',
hint: '',
optional: true,
editable: true,
searchable: true,
sortable: true
},
settingsTypes: [
{label: 'User Settings', value: 'user'},
{label: 'Admin Settings', value: 'admin'}
],
amountSource: [
{label: 'Client Data', value: 'client_data'},
{label: 'Owner Data', value: 'owner_data'}
],
extensionStubVersions: []
}
},
watch: {
'extensionData.public_page.action_fields.amount_source': function (
newVal,
oldVal
) {
if (oldVal && newVal !== oldVal) {
this.extensionData.public_page.action_fields.amount = ''
}
}
},
computed: {
paymentActionAmountFields() {
const amount_source =
this.extensionData.public_page.action_fields.amount_source
console.log('### amount_source:', amount_source)
if (!amount_source) return ['']
if (amount_source === 'owner_data') {
return [''].concat(
this.extensionData.owner_data.fields
.filter(f => f.type === 'int' || f.type === 'float')
.map(f => f.name)
)
}
if (amount_source === 'client_data') {
return [''].concat(
this.extensionData.client_data.fields
.filter(f => f.type === 'int' || f.type === 'float')
.map(f => f.name)
)
}
}
},
methods: {
saveState() {
this.$q.localStorage.set(
'lnbits.extension.builder.data',
JSON.stringify(this.extensionData)
)
this.$q.localStorage.set('lnbits.extension.builder.step', this.step)
},
nextStep() {
this.saveState()
this.$refs.stepper.next()
this.refreshPreview()
},
previousStep() {
this.saveState()
this.$refs.stepper.previous()
this.refreshPreview()
},
onStepChange() {
this.saveState()
this.refreshPreview()
},
clearAllData() {
LNbits.utils
.confirmDialog(
'Are you sure you want to clear all data? This action cannot be undone.'
)
.onOk(() => {
this.extensionData = JSON.parse(this.extensionDataCleanString)
this.$q.localStorage.remove('lnbits.extension.builder.data')
this.$refs.stepper.set(1)
})
},
exportJsonData() {
const status = Quasar.exportFile(
`${this.extensionData.id || 'data-export'}.json`,
JSON.stringify(this.extensionData, null, 2),
'text/json'
)
if (status !== true) {
Quasar.Notify.create({
message: 'Browser denied file download...',
color: 'negative',
icon: null
})
} else {
Quasar.Notify.create({
message: 'File downloaded!',
color: 'positive',
icon: 'file_download'
})
}
},
onJsonDataInput(event) {
const file = event.target.files[0]
const reader = new FileReader()
reader.onload = e => {
this.extensionData = {
...this.extensionData,
...JSON.parse(e.target.result)
}
this.$refs.extensionDataInput.value = null
Quasar.Notify.create({
message: 'File loaded!',
color: 'positive',
icon: 'file_upload'
})
}
reader.readAsText(file)
},
async buildExtension() {
try {
const options = {responseType: 'blob'}
const response = await LNbits.api.request(
'POST',
'/api/v1/extension/builder/zip',
null,
this.extensionData,
options
)
// download the zip file
const url = window.URL.createObjectURL(new Blob([response.data]))
const a = document.createElement('a')
a.href = url
a.download = `${this.extensionData.id || 'lnbits-extension'}.zip`
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async buildExtensionAndDeploy() {
try {
const {data} = await LNbits.api.request(
'POST',
'/api/v1/extension/builder/deploy',
null,
this.extensionData
)
Quasar.Notify.create({
message: data.message || 'Extension deployed!',
color: 'positive'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async cleanCacheData() {
LNbits.utils
.confirmDialog(
'Are you sure you want to clean the cache data? This action cannot be undone.',
'Clean Cache Data'
)
.onOk(async () => {
try {
const {data} = await LNbits.api.request(
'DELETE',
'/api/v1/extension/builder',
null,
{}
)
Quasar.Notify.create({
message: data.message || 'Cache data cleaned!',
color: 'positive'
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
async previewExtension(previewPageName) {
this.saveState()
try {
await LNbits.api.request(
'POST',
'/api/v1/extension/builder/preview',
null,
{
...this.extensionData,
...{
preview_action: {
is_preview_mode: !!previewPageName,
is_settings_preview: previewPageName === 'settings',
is_owner_data_preview: previewPageName === 'owner_data',
is_client_data_preview: previewPageName === 'client_data',
is_public_page_preview: previewPageName === 'public_page'
}
}
}
)
this.refreshIframe(previewPageName)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
async refreshPreview() {
setTimeout(() => {
const stepName = this.previewStepNames[`${this.step}`] || ''
if (!stepName) return
this.previewExtension(stepName)
}, 100)
},
async getStubExtensionReleases() {
try {
const stub_ext_id = 'extension_builder_stub'
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${stub_ext_id}/releases`
)
this.extensionStubVersions = data.sort((a, b) =>
a.version < b.version ? 1 : -1
)
this.extensionData.stub_version = this.extensionStubVersions[0]
? this.extensionStubVersions[0].version
: ''
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
refreshIframe(previewPageName = '') {
const iframe = this.$refs[`iframeStep${this.step}`]
if (!iframe) {
console.warn('Extension Builder Preview iframe not loaded yet.')
return
}
iframe.onload = () => {
const iframeDoc =
iframe.contentDocument || iframe.contentWindow.document
iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top'
}
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
},
initBasicData() {
this.extensionData.owner_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionData.client_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionData.settings_data.fields = [
JSON.parse(JSON.stringify(this.sampleField))
]
this.extensionDataCleanString = JSON.stringify(this.extensionData)
}
},
created: function () {
this.initBasicData()
const extensionData = this.$q.localStorage.getItem(
'lnbits.extension.builder.data'
)
if (extensionData) {
this.extensionData = {...this.extensionData, ...JSON.parse(extensionData)}
}
const step = +this.$q.localStorage.getItem('lnbits.extension.builder.step')
if (step) {
this.step = step
}
if (this.g.user.admin) {
this.getStubExtensionReleases()
}
setTimeout(() => {
this.refreshIframe()
}, 1000)
},
mixins: [windowMixin]
}

View file

@ -184,6 +184,15 @@ const routes = [
scripts: ['/static/js/extensions.js'] scripts: ['/static/js/extensions.js']
} }
}, },
{
path: '/extensions/builder',
name: 'ExtensionsBuilder',
component: DynamicComponent,
props: {
fetchUrl: '/extensions/builder',
scripts: ['/static/js/extensions_builder.js']
}
},
{ {
path: '/account', path: '/account',
name: 'Account', name: 'Account',

View file

@ -31,6 +31,12 @@ body.body--dark .q-field--error {
.lnbits__dialog-card { .lnbits__dialog-card {
width: 500px; width: 500px;
} }
.blur-and-disable {
filter: blur(4px); /* blur entire element & children */
pointer-events: none; /* block mouse interaction */
user-select: none; /* prevent text selection */
opacity: 0.6; /* optional: faded look */
}
.lnbits__table-bordered td, .lnbits__table-bordered td,
.lnbits__table-bordered th { .lnbits__table-bordered th {

View file

@ -42,6 +42,7 @@
"js/components/lnbits-qrcode-lnurl.js", "js/components/lnbits-qrcode-lnurl.js",
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components.js", "js/components.js",
"js/init-app.js" "js/init-app.js"

View file

@ -1671,3 +1671,189 @@
<q-separator class="col q-ml-sm"></q-separator> <q-separator class="col q-ml-sm"></q-separator>
</div> </div>
</template> </template>
<template id="lnbits-data-fields">
<q-table
:rows="fields"
row-key="name"
:columns="fieldsTable.columns"
v-model:pagination="fieldsTable.pagination"
>
<template v-slot:bottom-row>
<q-tr>
<q-td auto-width></q-td>
<q-td colspan="100%">
<q-btn
@click="addField"
icon="add"
size="sm"
color="primary"
class="q-ml-xs"
:label="$t('add_field')"
/>
</q-td>
</q-tr>
</template>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span>
<q-icon
v-if="col.name == 'optional'"
name="info"
size="xs"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul>
<li>
The field is optional. The field can be left blank by the
user.
</li>
<li>
The UI form will not require this field to be filled out.
</li>
<li>The DB table will allow NULL values for this field.</li>
<li>Non optional fields must be filled out.</li>
</ul>
</q-tooltip>
</q-icon>
<q-icon
v-else-if="col.name == 'editable'"
name="info"
size="xs"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul>
<li>The UI form will allow the field to be edited.</li>
</ul>
</q-tooltip>
</q-icon>
<q-icon
v-else-if="col.name == 'sortable'"
name="info"
size="xs"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul>
<li>In the UI Table a column will be created for the field.</li>
<li>The UI Table column will be sortable.</li>
</ul>
</q-tooltip>
</q-icon>
<q-icon
v-else-if="col.name == 'searchable'"
name="info"
size="xs"
color="primary"
class="cursor-pointer q-ml-xs q-mb-xs"
>
<q-tooltip>
<ul>
<li>
The free text search will include this field when searching.
</li>
</ul>
</q-tooltip>
</q-icon>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
v-if="props.row.readonly !== true"
@click="removeField(props.row)"
round
icon="delete"
size="sm"
color="negative"
class="q-ml-xs"
>
</q-btn>
</q-td>
<q-td full-width>
<q-input
dense
filled
v-model="props.row.name"
:readonly="props.row.readonly === true"
type="text"
>
</q-input>
</q-td>
<q-td>
<q-select
filled
dense
emit-value
map-options
v-model="props.row.type"
:options="fieldTypes"
:readonly="props.row.readonly === true"
></q-select>
</q-td>
<q-td>
<q-input dense filled v-model="props.row.label" type="text">
</q-input>
</q-td>
<q-td>
<q-input dense filled v-model="props.row.hint" type="text"> </q-input>
</q-td>
<q-td>
<q-toggle
v-model="props.row.optional"
:readonly="props.row.readonly === true"
size="md"
color="green"
/>
</q-td>
<q-td v-if="!hideAdvanced">
<q-toggle
v-if="props.row.type !== 'json'"
:readonly="props.row.readonly === true"
v-model="props.row.editable"
size="md"
color="green"
/>
</q-td>
<q-td v-if="!hideAdvanced">
<q-toggle
v-if="props.row.type !== 'json'"
:readonly="props.row.readonly === true"
v-model="props.row.sortable"
size="md"
color="green"
/>
</q-td>
<q-td v-if="!hideAdvanced">
<q-toggle
v-if="props.row.type !== 'json'"
:readonly="props.row.readonly === true"
v-model="props.row.searchable"
size="md"
color="green"
/>
</q-td>
</q-tr>
<q-tr v-if="props.row.type === 'json'" :props="props">
<q-td></q-td>
<q-td></q-td>
<q-td colspan="100%">
<lnbits-data-fields
:fields="props.row.fields"
:hide-advanced="true"
></lnbits-data-fields>
</q-td>
</q-tr>
</template>
</q-table>
</template>

View file

@ -94,6 +94,7 @@
"js/components/lnbits-qrcode-lnurl.js", "js/components/lnbits-qrcode-lnurl.js",
"js/components/lnbits-funding-sources.js", "js/components/lnbits-funding-sources.js",
"js/components/extension-settings.js", "js/components/extension-settings.js",
"js/components/data-fields.js",
"js/components/payment-list.js", "js/components/payment-list.js",
"js/components.js", "js/components.js",
"js/init-app.js" "js/init-app.js"

View file

@ -250,6 +250,7 @@ classmethod-decorators = [
# TODO: remove S101 ignore # TODO: remove S101 ignore
"lnbits/*" = ["S101"] "lnbits/*" = ["S101"]
"lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"] "lnbits/core/views/admin_api.py" = ["S602", "S603", "S607"]
"lnbits/core/services/extensions_builder.py" = ["S701"]
"crypto.py" = ["S324"] "crypto.py" = ["S324"]
"test*.py" = ["S101", "S105", "S106", "S307"] "test*.py" = ["S101", "S105", "S106", "S307"]
"tools*.py" = ["S101", "S608"] "tools*.py" = ["S101", "S608"]