Support custom path for installed extensions (#1940)

* feat: the more you fuck around the more you learn
* feat: backwards compatible static file loading
* refactor: update paths for extension files
* refactor: var renaming
* doc: update `LNBITS_EXTENSIONS_PATH` documentation
* fix: default folder install
* feat: install ext without external path
* doc: `PYTHONPATH` no longer required
* fix: add warnings
* fix: missing path
* refactor: re-order statements
* fix: hardcoded path separator

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan 2023-09-25 13:44:29 +03:00 committed by GitHub
parent 50561a8696
commit c536df0dae
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 84 additions and 32 deletions

View file

@ -40,6 +40,11 @@ LNBITS_HIDE_API=false
# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN # GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN
# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx # LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx
# Path where extensions will be installed (defaults to `./lnbits/`).
# Inside this directory the `extensions` and `upgrades` sub-directories will be created.
# LNBITS_EXTENSIONS_PATH="/path/to/some/dir"
# Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart. # Extensions to be installed by default. If an extension from this list is uninstalled then it will be re-installed on the next restart.
# The extension must be removed from this list in order to not be re-installed. # The extension must be removed from this list in order to not be re-installed.
LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos" LNBITS_EXTENSIONS_DEFAULT_INSTALL="tpos"

View file

@ -9,6 +9,7 @@ import sys
import traceback import traceback
from hashlib import sha256 from hashlib import sha256
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path
from typing import Callable, List from typing import Callable, List
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
@ -96,6 +97,8 @@ def create_app() -> FastAPI:
app.add_middleware(InstalledExtensionMiddleware) app.add_middleware(InstalledExtensionMiddleware)
app.add_middleware(ExtensionsRedirectMiddleware) app.add_middleware(ExtensionsRedirectMiddleware)
register_custom_extensions_path()
# adds security middleware # adds security middleware
add_ip_block_middleware(app) add_ip_block_middleware(app)
add_ratelimit_middleware(app) add_ratelimit_middleware(app)
@ -229,9 +232,7 @@ def check_installed_extension_files(ext: InstallableExtension) -> bool:
if ext.has_installed_version: if ext.has_installed_version:
return True return True
zip_files = glob.glob( zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
os.path.join(settings.lnbits_data_folder, "extensions", "*.zip")
)
if f"./{str(ext.zip_path)}" not in zip_files: if f"./{str(ext.zip_path)}" not in zip_files:
ext.download_archive() ext.download_archive()
@ -267,6 +268,25 @@ def register_routes(app: FastAPI) -> None:
logger.error(f"Could not load extension `{ext.code}`: {str(e)}") logger.error(f"Could not load extension `{ext.code}`: {str(e)}")
def register_custom_extensions_path():
if settings.has_default_extension_path:
return
default_ext_path = os.path.join("lnbits", "extensions")
if os.path.isdir(default_ext_path) and len(os.listdir(default_ext_path)) != 0:
logger.warning(
"You are using a custom extensions path, "
+ "but the default extensions directory is not empty. "
+ f"Please clean-up the '{default_ext_path}' directory."
)
logger.warning(
f"You can move the existing '{default_ext_path}' directory to: "
+ f" '{settings.lnbits_extensions_path}/extensions'"
)
sys.path.append(str(Path(settings.lnbits_extensions_path, "extensions")))
sys.path.append(str(Path(settings.lnbits_extensions_path, "upgrades")))
def register_new_ext_routes(app: FastAPI) -> Callable: def register_new_ext_routes(app: FastAPI) -> Callable:
# Returns a function that registers new routes for an extension. # Returns a function that registers new routes for an extension.
# The returned function encapsulates (creates a closure around) # The returned function encapsulates (creates a closure around)
@ -303,7 +323,10 @@ def register_ext_routes(app: FastAPI, ext: Extension) -> None:
if hasattr(ext_module, f"{ext.code}_static_files"): if hasattr(ext_module, f"{ext.code}_static_files"):
ext_statics = getattr(ext_module, f"{ext.code}_static_files") ext_statics = getattr(ext_module, f"{ext.code}_static_files")
for s in ext_statics: for s in ext_statics:
app.mount(s["path"], s["app"], s["name"]) static_dir = Path(
settings.lnbits_extensions_path, "extensions", *s["path"].split("/")
)
app.mount(s["path"], StaticFiles(directory=static_dir), s["name"])
if hasattr(ext_module, f"{ext.code}_redirect_paths"): if hasattr(ext_module, f"{ext.code}_redirect_paths"):
ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths") ext_redirects = getattr(ext_module, f"{ext.code}_redirect_paths")

View file

@ -182,12 +182,19 @@ class Extension(NamedTuple):
upgrade_hash: Optional[str] = "" upgrade_hash: Optional[str] = ""
@property @property
def module_name(self): def module_name(self) -> str:
return ( if self.is_upgrade_extension:
f"lnbits.extensions.{self.code}" if settings.has_default_extension_path:
if self.upgrade_hash == "" return f"lnbits.upgrades.{self.code}-{self.upgrade_hash}"
else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}" return f"{self.code}-{self.upgrade_hash}"
)
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.code}"
return self.code
@property
def is_upgrade_extension(self) -> bool:
return self.upgrade_hash != ""
@classmethod @classmethod
def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension": def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
@ -205,7 +212,7 @@ class Extension(NamedTuple):
class ExtensionManager: class ExtensionManager:
def __init__(self) -> None: def __init__(self) -> None:
p = Path(settings.lnbits_path, "extensions") p = Path(settings.lnbits_extensions_path, "extensions")
Path(p).mkdir(parents=True, exist_ok=True) Path(p).mkdir(parents=True, exist_ok=True)
self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()] self._extension_folders: List[Path] = [f for f in p.iterdir() if f.is_dir()]
@ -330,21 +337,25 @@ class InstallableExtension(BaseModel):
@property @property
def zip_path(self) -> Path: def zip_path(self) -> Path:
extensions_data_dir = Path(settings.lnbits_data_folder, "extensions") extensions_data_dir = Path(settings.lnbits_data_folder, "zips")
Path(extensions_data_dir).mkdir(parents=True, exist_ok=True) Path(extensions_data_dir).mkdir(parents=True, exist_ok=True)
return Path(extensions_data_dir, f"{self.id}.zip") return Path(extensions_data_dir, f"{self.id}.zip")
@property @property
def ext_dir(self) -> Path: def ext_dir(self) -> Path:
return Path(settings.lnbits_path, "extensions", self.id) return Path(settings.lnbits_extensions_path, "extensions", self.id)
@property @property
def ext_upgrade_dir(self) -> Path: def ext_upgrade_dir(self) -> Path:
return Path("lnbits", "upgrades", f"{self.id}-{self.hash}") return Path(
settings.lnbits_extensions_path, "upgrades", f"{self.id}-{self.hash}"
)
@property @property
def module_name(self) -> str: def module_name(self) -> str:
if settings.has_default_extension_path:
return f"lnbits.extensions.{self.id}" return f"lnbits.extensions.{self.id}"
return self.id
@property @property
def module_installed(self) -> bool: def module_installed(self) -> bool:
@ -389,21 +400,26 @@ class InstallableExtension(BaseModel):
def extract_archive(self): def extract_archive(self):
logger.info(f"Extracting extension {self.name} ({self.installed_version}).") logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
Path("lnbits", "upgrades").mkdir(parents=True, exist_ok=True) Path(settings.lnbits_extensions_path, "upgrades").mkdir(
shutil.rmtree(self.ext_upgrade_dir, True) parents=True, exist_ok=True
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(self.ext_upgrade_dir)
generated_dir_name = os.listdir(self.ext_upgrade_dir)[0]
os.rename(
Path(self.ext_upgrade_dir, generated_dir_name),
Path(self.ext_upgrade_dir, self.id),
) )
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]
shutil.rmtree(self.ext_upgrade_dir, True)
shutil.copytree(
Path(tmp_dir, generated_dir_name),
Path(self.ext_upgrade_dir),
)
shutil.rmtree(tmp_dir, True)
# Pre-packed extensions can be upgraded # Pre-packed extensions can be upgraded
# Mark the extension as installed so we know it is not the pre-packed version # Mark the extension as installed so we know it is not the pre-packed version
with open( with open(Path(self.ext_upgrade_dir, "config.json"), "r+") as json_file:
Path(self.ext_upgrade_dir, self.id, "config.json"), "r+"
) as json_file:
config_json = json.load(json_file) config_json = json.load(json_file)
self.name = config_json.get("name") self.name = config_json.get("name")
@ -419,10 +435,7 @@ class InstallableExtension(BaseModel):
) )
shutil.rmtree(self.ext_dir, True) shutil.rmtree(self.ext_dir, True)
shutil.copytree( shutil.copytree(Path(self.ext_upgrade_dir), Path(self.ext_dir))
Path(self.ext_upgrade_dir, self.id),
Path(settings.lnbits_path, "extensions", self.id),
)
logger.success(f"Extension {self.name} ({self.installed_version}) installed.") logger.success(f"Extension {self.name} ({self.installed_version}) installed.")
def nofiy_upgrade(self) -> None: def nofiy_upgrade(self) -> None:

View file

@ -30,6 +30,10 @@ def url_for(endpoint: str, external: Optional[bool] = False, **params: Any) -> s
def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates: def template_renderer(additional_folders: Optional[List] = None) -> Jinja2Templates:
folders = ["lnbits/templates", "lnbits/core/templates"] folders = ["lnbits/templates", "lnbits/core/templates"]
if additional_folders: if additional_folders:
additional_folders += [
Path(settings.lnbits_extensions_path, "extensions", f)
for f in additional_folders
]
folders.extend(additional_folders) folders.extend(additional_folders)
t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders)) t = Jinja2Templates(loader=jinja2.FileSystemLoader(folders))

View file

@ -38,8 +38,10 @@ def main(
# create data dir if it does not exist # create data dir if it does not exist
Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True) Path(settings.lnbits_data_folder).mkdir(parents=True, exist_ok=True)
# create extension dir if it does not exist # create `extensions`` dir if it does not exist
Path(settings.lnbits_path, "extensions").mkdir(parents=True, exist_ok=True) Path(settings.lnbits_extensions_path, "extensions").mkdir(
parents=True, exist_ok=True
)
set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips) set_cli_settings(host=host, port=port, forwarded_allow_ips=forwarded_allow_ips)

View file

@ -275,10 +275,15 @@ class EnvSettings(LNbitsSettings):
forwarded_allow_ips: str = Field(default="*") forwarded_allow_ips: str = Field(default="*")
lnbits_title: str = Field(default="LNbits API") lnbits_title: str = Field(default="LNbits API")
lnbits_path: str = Field(default=".") lnbits_path: str = Field(default=".")
lnbits_extensions_path: str = Field(default="lnbits")
lnbits_commit: str = Field(default="unknown") lnbits_commit: str = Field(default="unknown")
super_user: str = Field(default="") super_user: str = Field(default="")
version: str = Field(default="0.0.0") version: str = Field(default="0.0.0")
@property
def has_default_extension_path(self) -> bool:
return self.lnbits_extensions_path == "lnbits"
class SaaSSettings(LNbitsSettings): class SaaSSettings(LNbitsSettings):
lnbits_saas_callback: Optional[str] = Field(default=None) lnbits_saas_callback: Optional[str] = Field(default=None)