[feat] Extension Builder (#3339)
This commit is contained in:
@@ -7,6 +7,7 @@ from .views.audit_api import audit_router
|
||||
from .views.auth_api import auth_router
|
||||
from .views.callback_api import callback_router
|
||||
from .views.extension_api import extension_router
|
||||
from .views.extensions_builder_api import extension_builder_router
|
||||
from .views.fiat_api import fiat_router
|
||||
|
||||
# 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(node_router)
|
||||
app.include_router(extension_router)
|
||||
app.include_router(extension_builder_router)
|
||||
app.include_router(super_node_router)
|
||||
app.include_router(public_node_router)
|
||||
app.include_router(payment_router)
|
||||
|
||||
@@ -409,7 +409,6 @@ class InstallableExtension(BaseModel):
|
||||
|
||||
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
|
||||
shutil.rmtree(tmp_dir, True)
|
||||
|
||||
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(tmp_dir)
|
||||
generated_dir_name = os.listdir(tmp_dir)[0]
|
||||
@@ -628,8 +627,11 @@ class InstallableExtension(BaseModel):
|
||||
@classmethod
|
||||
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
|
||||
extension_releases: list[ExtensionRelease] = []
|
||||
|
||||
for url in settings.lnbits_extensions_manifests:
|
||||
all_manifests = [
|
||||
*settings.lnbits_extensions_manifests,
|
||||
settings.lnbits_extensions_builder_manifest_url,
|
||||
]
|
||||
for url in all_manifests:
|
||||
try:
|
||||
manifest = await cls.fetch_manifest(url)
|
||||
for r in manifest.repos:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -35,7 +37,8 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
|
||||
if installed_ext and installed_ext.meta:
|
||||
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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
<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-section>
|
||||
<q-item-label>
|
||||
@@ -105,6 +126,17 @@
|
||||
</q-item>
|
||||
<br />
|
||||
</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>
|
||||
</q-card-section>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
v-text="$t('only_admins_can_install')"
|
||||
></i>
|
||||
<q-space></q-space>
|
||||
|
||||
<q-input
|
||||
:label="$t('search_extensions')"
|
||||
:dense="dense"
|
||||
@@ -53,6 +54,18 @@
|
||||
v-text="$t('new_version') + ` (${updatableExtensions?.length})`"
|
||||
></span>
|
||||
</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
|
||||
v-if="g.user.admin"
|
||||
flat
|
||||
|
||||
@@ -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 bolt11 import decode as bolt11_decode
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
Depends,
|
||||
HTTPException,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.core.crud.extensions import get_user_extensions
|
||||
|
||||
@@ -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 pathlib import Path
|
||||
from typing import Annotated
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
@@ -149,11 +151,95 @@ async def extensions(request: Request, user: User = Depends(check_user_exists)):
|
||||
{
|
||||
"user": user.json(),
|
||||
"extension_data": extension_data,
|
||||
"extension_builder_enabled": user.admin
|
||||
or settings.lnbits_extensions_builder_activate_non_admins,
|
||||
"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(
|
||||
"/wallet",
|
||||
response_class=HTMLResponse,
|
||||
|
||||
Reference in New Issue
Block a user