diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index 6c9beeaae..211865146 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -132,18 +132,10 @@ Now visit `0.0.0.0:5000` to make a super-user account.
```sh
# Install nix. If you have installed via another manager, remove and use this install (from https://nixos.org/download)
-sh <(curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install) --daemon
+sh <(c&url -L https://nixos.org/nix/install) --daemon
# Enable nix-command and flakes experimental features for nix:
-grep -qxF 'experimental-features = nix-command flakes' /etc/nix/nix.conf || \
-echo 'experimental-features = nix-command flakes' | sudo tee -a /etc/nix/nix.conf
-
-# Add user to Nix
-grep -qxF "trusted-users = root $USER" /etc/nix/nix.conf || \
-echo "trusted-users = root $USER" | sudo tee -a /etc/nix/nix.conf
-
-# Restart daemon so changes apply
-sudo systemctl restart nix-daemon
+echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf
# Add cachix for cached binaries
nix-env -iA cachix -f https://cachix.org/api/v1/install
@@ -168,7 +160,7 @@ but you can also set the env variables or pass command line arguments:
```sh
# .env variables are currently passed when running, but LNbits can be managed with the admin UI.
-LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000 --host 0.0.0.0
+LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
# Once you have created a user, you can set as the super_user
SUPER_USER=be54db7f245346c8833eaa430e1e0405 LNBITS_ADMIN_UI=true ./result/bin/lnbits --port 9000
diff --git a/flake.nix b/flake.nix
index 8f19bcdfa..3dfc5099c 100644
--- a/flake.nix
+++ b/flake.nix
@@ -139,10 +139,9 @@
replaceVars = prev.replaceVars or (path: vars: prev.substituteAll ({ src = path; } // vars));
};
- # System-specific nixos modules to avoid circular dependency
nixosModules.default = { pkgs, lib, config, ... }: {
imports = [ "${./nix/modules/lnbits-service.nix}" ];
- nixpkgs.overlays = [ self.overlays.${system}.default ];
+ nixpkgs.overlays = [ self.overlays.default ];
};
checks = { };
diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py
index 64d84f5a3..6a5cc6995 100644
--- a/lnbits/core/__init__.py
+++ b/lnbits/core/__init__.py
@@ -7,7 +7,6 @@ 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
@@ -32,7 +31,6 @@ 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)
diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py
index 9ec83e4c0..684488dcb 100644
--- a/lnbits/core/models/extensions.py
+++ b/lnbits/core/models/extensions.py
@@ -409,6 +409,7 @@ 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]
@@ -627,11 +628,8 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
- all_manifests = [
- *settings.lnbits_extensions_manifests,
- settings.lnbits_extensions_builder_manifest_url,
- ]
- for url in all_manifests:
+
+ for url in settings.lnbits_extensions_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
diff --git a/lnbits/core/models/extensions_builder.py b/lnbits/core/models/extensions_builder.py
deleted file mode 100644
index fd871b81a..000000000
--- a/lnbits/core/models/extensions_builder.py
+++ /dev/null
@@ -1,460 +0,0 @@
-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
diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py
index b2a561d86..0f85064ab 100644
--- a/lnbits/core/services/extensions.py
+++ b/lnbits/core/services/extensions.py
@@ -21,9 +21,7 @@ from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
-async def install_extension(
- ext_info: InstallableExtension, skip_download: bool | None = False
-) -> Extension:
+async def install_extension(ext_info: InstallableExtension) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
@@ -37,8 +35,7 @@ async def install_extension(
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
- if not skip_download:
- await ext_info.download_archive()
+ await ext_info.download_archive()
ext_info.extract_archive()
diff --git a/lnbits/core/services/extensions_builder.py b/lnbits/core/services/extensions_builder.py
deleted file mode 100644
index 8187c9111..000000000
--- a/lnbits/core/services/extensions_builder.py
+++ /dev/null
@@ -1,543 +0,0 @@
-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
diff --git a/lnbits/core/templates/admin/_tab_extensions.html b/lnbits/core/templates/admin/_tab_extensions.html
index 9acf114d5..14ed75f6b 100644
--- a/lnbits/core/templates/admin/_tab_extensions.html
+++ b/lnbits/core/templates/admin/_tab_extensions.html
@@ -84,27 +84,6 @@
/>
-
-
- -
-`name` and
- `sort description` fields are what the users will
- see when browsing the list of extensions.
- `id` field is used internally and in the URL of
- your extension.
- created_at, updated_at and
- extra.
-
- (Owner Data) that will be used as a title for the public
- page.
- (Owner Data) that will be used as a description for the
- public page. (Client Data) that will be shown as inputs in
- the public page form.
- (Client Data) as parameters.
- (Owner Data) that represents the wallet which
- will generate the invoice and receive the payments.
- Wallet will be
- shown.
- (Owner Data) that represents the currency
- which will be used to for the amount.
- Currency will
- be shown.
- (Owner Data) or
- (Client Data) that represents the amount (in
- the selected currency).
- Integer and
- Float will be shown.
- (Client Data) that will be set to true when
- the invoice is paid.
- Boolean will be
- shown.
-