[feat] Extension Builder (#3339)
This commit is contained in:
parent
c05122e5fb
commit
4f9a5090c2
27 changed files with 2871 additions and 16 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
460
lnbits/core/models/extensions_builder.py
Normal file
460
lnbits/core/models/extensions_builder.py
Normal 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
|
||||||
|
|
@ -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,7 +37,8 @@ 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
|
||||||
|
|
||||||
await ext_info.download_archive()
|
if not skip_download:
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
|
|
|
||||||
543
lnbits/core/services/extensions_builder.py
Normal file
543
lnbits/core/services/extensions_builder.py
Normal 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
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
830
lnbits/core/templates/core/extensions_builder.html
Normal file
830
lnbits/core/templates/core/extensions_builder.html
Normal 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>
|
||||||
|
(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>
|
||||||
|
(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
|
||||||
|
> (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
|
||||||
|
> (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
|
||||||
|
> (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
|
||||||
|
> (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
|
||||||
|
> (Owner Data) or
|
||||||
|
<code v-text="extensionData.client_data.name"></code
|
||||||
|
> (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
|
||||||
|
> (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 %}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
164
lnbits/core/views/extensions_builder_api.py
Normal file
164
lnbits/core/views/extensions_builder_api.py
Normal 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.")
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.css
vendored
2
lnbits/static/bundle.min.css
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
115
lnbits/static/js/components/data-fields.js
Normal file
115
lnbits/static/js/components/data-fields.js
Normal 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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
350
lnbits/static/js/extensions_builder.js
Normal file
350
lnbits/static/js/extensions_builder.js
Normal 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]
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue