+
+ {{ extension.name }}
+
+
{{ extension.name }}
+
+
+
+
+
+
+
+ tab = val.name"
+ >
+ tab = val.name"
+ >
+ tab = val.name"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ New Version
+
+ {% raw %}
+
+ {{ extension.name }}
+
+
+ {{ extension.name }}
+
+
+ {{ extension.shortDescription }}
+
+
+ {{ extension.name }}
+
+
+ {{ extension.shortDescription }}
+
+ {% endraw %}
+
+
+
+
+ Depends on:
+
+
+
+
+
+
+
+
+
+
+ Ratings coming soon
+
+
+
+
+
+
+
+
+
+
+
+
+ Warning
+
+ You are about to remove the extension for all users.
+ Are you sure you want to continue?
+
+
+
+ Yes, Uninstall
+ Cancel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Repository
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Install
+
+ Uninstall
+ Release Notes
+
+
+
+
+
+
+
+
+
+
+
+
+ Uninstall
+ Close
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }}
+
+{% endblock %}
diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py
index 4ee1000f..9341a603 100644
--- a/lnbits/core/views/api.py
+++ b/lnbits/core/views/api.py
@@ -5,7 +5,7 @@ import time
import uuid
from http import HTTPStatus
from io import BytesIO
-from typing import Dict, Optional, Tuple, Union
+from typing import Dict, List, Optional, Tuple, Union
from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout
@@ -29,7 +29,8 @@ from sse_starlette.sse import EventSourceResponse
from starlette.responses import RedirectResponse, StreamingResponse
from lnbits import bolt11, lnurl
-from lnbits.core.models import Payment, Wallet
+from lnbits.core.helpers import migrate_extension_database
+from lnbits.core.models import Payment, User, Wallet
from lnbits.decorators import (
WalletTypeInfo,
check_admin,
@@ -37,6 +38,13 @@ from lnbits.decorators import (
require_admin_key,
require_invoice_key,
)
+from lnbits.extension_manager import (
+ CreateExtension,
+ Extension,
+ ExtensionRelease,
+ InstallableExtension,
+ get_valid_extensions,
+)
from lnbits.helpers import url_for
from lnbits.settings import get_wallet_class, settings
from lnbits.utils.exchange_rates import (
@@ -45,10 +53,13 @@ from lnbits.utils.exchange_rates import (
satoshis_amount_as_fiat,
)
-from .. import core_app, db
+from .. import core_app, core_app_extra, db
from ..crud import (
+ add_installed_extension,
create_tinyurl,
+ delete_installed_extension,
delete_tinyurl,
+ get_dbversions,
get_payments,
get_standalone_payment,
get_tinyurl,
@@ -714,6 +725,105 @@ async def websocket_update_get(item_id: str, data: str):
return {"sent": False, "data": data}
+@core_app.post("/api/v1/extension")
+async def api_install_extension(
+ data: CreateExtension, user: User = Depends(check_admin)
+):
+
+ release = await InstallableExtension.get_extension_release(
+ data.ext_id, data.source_repo, data.archive
+ )
+ if not release:
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
+ )
+ ext_info = InstallableExtension(
+ id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
+ )
+
+ ext_info.download_archive()
+
+ try:
+ ext_info.extract_archive()
+
+ extension = Extension.from_installable_ext(ext_info)
+
+ db_version = (await get_dbversions()).get(data.ext_id, 0)
+ await migrate_extension_database(extension, db_version)
+
+ await add_installed_extension(ext_info)
+ if data.ext_id not in settings.lnbits_deactivated_extensions:
+ settings.lnbits_deactivated_extensions += [data.ext_id]
+
+ # mount routes for the new version
+ core_app_extra.register_new_ext_routes(extension)
+
+ if extension.upgrade_hash:
+ ext_info.nofiy_upgrade()
+
+ except Exception as ex:
+ logger.warning(ex)
+ ext_info.clean_extension_files()
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
+ detail="Failed to install extension.",
+ )
+
+
+@core_app.delete("/api/v1/extension/{ext_id}")
+async def api_uninstall_extension(ext_id: str, user: User = Depends(check_admin)):
+
+ installable_extensions: List[
+ InstallableExtension
+ ] = await InstallableExtension.get_installable_extensions()
+
+ extensions = [e for e in installable_extensions if e.id == ext_id]
+ if len(extensions) == 0:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Unknown extension id: {ext_id}",
+ )
+
+ # check that other extensions do not depend on this one
+ for valid_ext_id in list(map(lambda e: e.code, get_valid_extensions())):
+ installed_ext = next(
+ (ext for ext in installable_extensions if ext.id == valid_ext_id), None
+ )
+ if installed_ext and ext_id in installed_ext.dependencies:
+ raise HTTPException(
+ status_code=HTTPStatus.BAD_REQUEST,
+ detail=f"Cannot uninstall. Extension '{installed_ext.name}' depends on this one.",
+ )
+
+ try:
+ if ext_id not in settings.lnbits_deactivated_extensions:
+ settings.lnbits_deactivated_extensions += [ext_id]
+
+ for ext_info in extensions:
+ ext_info.clean_extension_files()
+ await delete_installed_extension(ext_id=ext_info.id)
+
+ except Exception as ex:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
+ )
+
+
+@core_app.get("/api/v1/extension/{ext_id}/releases")
+async def get_extension_releases(ext_id: str, user: User = Depends(check_admin)):
+ try:
+ extension_releases: List[
+ ExtensionRelease
+ ] = await InstallableExtension.get_extension_releases(ext_id)
+
+ return extension_releases
+
+ except Exception as ex:
+ raise HTTPException(
+ status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(ex)
+ )
+
+
############################TINYURL##################################
diff --git a/lnbits/core/views/generic.py b/lnbits/core/views/generic.py
index 0177203b..b4ecaf4a 100644
--- a/lnbits/core/views/generic.py
+++ b/lnbits/core/views/generic.py
@@ -1,6 +1,6 @@
import asyncio
from http import HTTPStatus
-from typing import Optional
+from typing import List, Optional
from fastapi import Depends, Query, Request, status
from fastapi.exceptions import HTTPException
@@ -16,14 +16,17 @@ from lnbits.decorators import check_admin, check_user_exists
from lnbits.helpers import template_renderer, url_for
from lnbits.settings import get_wallet_class, settings
-from ...helpers import get_valid_extensions
+from ...extension_manager import InstallableExtension, get_valid_extensions
from ..crud import (
create_account,
create_wallet,
delete_wallet,
get_balance_check,
+ get_inactive_extensions,
+ get_installed_extensions,
get_user,
save_balance_notify,
+ update_installed_extension_state,
update_user_extension,
)
from ..services import pay_invoice, redeem_lnurl_withdraw
@@ -61,35 +64,10 @@ async def extensions(
enable: str = Query(None),
disable: str = Query(None),
):
- extension_to_enable = enable
- extension_to_disable = disable
-
- if extension_to_enable and extension_to_disable:
- raise HTTPException(
- HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
- )
-
- # check if extension exists
- if extension_to_enable or extension_to_disable:
- ext = extension_to_enable or extension_to_disable
- if ext not in [e.code for e in get_valid_extensions()]:
- raise HTTPException(
- HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
- )
-
- if extension_to_enable:
- logger.info(f"Enabling extension: {extension_to_enable} for user {user.id}")
- await update_user_extension(
- user_id=user.id, extension=extension_to_enable, active=True
- )
- elif extension_to_disable:
- logger.info(f"Disabling extension: {extension_to_disable} for user {user.id}")
- await update_user_extension(
- user_id=user.id, extension=extension_to_disable, active=False
- )
+ await toggle_extension(enable, disable, user.id)
# Update user as his extensions have been updated
- if extension_to_enable or extension_to_disable:
+ if enable or disable:
user = await get_user(user.id) # type: ignore
return template_renderer().TemplateResponse(
@@ -97,6 +75,93 @@ async def extensions(
)
+@core_html_routes.get(
+ "/install", name="install.extensions", response_class=HTMLResponse
+)
+async def extensions_install(
+ request: Request,
+ user: User = Depends(check_user_exists),
+ activate: str = Query(None),
+ deactivate: str = Query(None),
+):
+ try:
+ installed_exts: List["InstallableExtension"] = await get_installed_extensions()
+ installed_exts_ids = [e.id for e in installed_exts]
+
+ installable_exts: List[
+ InstallableExtension
+ ] = await InstallableExtension.get_installable_extensions()
+ installable_exts += [
+ e for e in installed_exts if e.id not in installed_exts_ids
+ ]
+
+ for e in installable_exts:
+ installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
+ if installed_ext:
+ e.installed_release = installed_ext.installed_release
+ # use the installed extension values
+ e.name = installed_ext.name
+ e.short_description = installed_ext.short_description
+ e.icon = installed_ext.icon
+
+ except Exception as ex:
+ logger.warning(ex)
+ installable_exts = []
+
+ try:
+ ext_id = activate or deactivate
+ if ext_id and user.admin:
+ if deactivate and deactivate not in settings.lnbits_deactivated_extensions:
+ settings.lnbits_deactivated_extensions += [deactivate]
+ elif activate:
+ settings.lnbits_deactivated_extensions = list(
+ filter(
+ lambda e: e != activate, settings.lnbits_deactivated_extensions
+ )
+ )
+ await update_installed_extension_state(
+ ext_id=ext_id, active=activate != None
+ )
+
+ all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
+ inactive_extensions = await get_inactive_extensions()
+ extensions = list(
+ map(
+ lambda ext: {
+ "id": ext.id,
+ "name": ext.name,
+ "icon": ext.icon,
+ "shortDescription": ext.short_description,
+ "stars": ext.stars,
+ "isFeatured": ext.featured,
+ "dependencies": ext.dependencies,
+ "isInstalled": ext.id in installed_exts_ids,
+ "isAvailable": ext.id in all_extensions,
+ "isActive": not ext.id in inactive_extensions,
+ "latestRelease": dict(ext.latest_release)
+ if ext.latest_release
+ else None,
+ "installedRelease": dict(ext.installed_release)
+ if ext.installed_release
+ else None,
+ },
+ installable_exts,
+ )
+ )
+
+ return template_renderer().TemplateResponse(
+ "core/install.html",
+ {
+ "request": request,
+ "user": user.dict(),
+ "extensions": extensions,
+ },
+ )
+ except Exception as e:
+ logger.warning(e)
+ raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
+
+
@core_html_routes.get(
"/wallet",
response_class=HTMLResponse,
@@ -336,3 +401,29 @@ async def index(request: Request, user: User = Depends(check_admin)):
"balance": balance,
},
)
+
+
+async def toggle_extension(extension_to_enable, extension_to_disable, user_id):
+ if extension_to_enable and extension_to_disable:
+ raise HTTPException(
+ HTTPStatus.BAD_REQUEST, "You can either `enable` or `disable` an extension."
+ )
+
+ # check if extension exists
+ if extension_to_enable or extension_to_disable:
+ ext = extension_to_enable or extension_to_disable
+ if ext not in [e.code for e in get_valid_extensions()]:
+ raise HTTPException(
+ HTTPStatus.BAD_REQUEST, f"Extension '{ext}' doesn't exist."
+ )
+
+ if extension_to_enable:
+ logger.info(f"Enabling extension: {extension_to_enable} for user {user_id}")
+ await update_user_extension(
+ user_id=user_id, extension=extension_to_enable, active=True
+ )
+ elif extension_to_disable:
+ logger.info(f"Disabling extension: {extension_to_disable} for user {user_id}")
+ await update_user_extension(
+ user_id=user_id, extension=extension_to_disable, active=False
+ )
diff --git a/lnbits/extension_manager.py b/lnbits/extension_manager.py
new file mode 100644
index 00000000..7955c791
--- /dev/null
+++ b/lnbits/extension_manager.py
@@ -0,0 +1,594 @@
+import hashlib
+import json
+import os
+import shutil
+import sys
+import urllib.request
+import zipfile
+from http import HTTPStatus
+from pathlib import Path
+from typing import Any, List, NamedTuple, Optional, Tuple
+
+import httpx
+from fastapi.exceptions import HTTPException
+from fastapi.responses import JSONResponse
+from loguru import logger
+from pydantic import BaseModel
+from starlette.types import ASGIApp, Receive, Scope, Send
+
+from lnbits.settings import settings
+
+
+class Extension(NamedTuple):
+ code: str
+ is_valid: bool
+ is_admin_only: bool
+ name: Optional[str] = None
+ short_description: Optional[str] = None
+ tile: Optional[str] = None
+ contributors: Optional[List[str]] = None
+ hidden: bool = False
+ migration_module: Optional[str] = None
+ db_name: Optional[str] = None
+ upgrade_hash: Optional[str] = ""
+
+ @property
+ def module_name(self):
+ return (
+ f"lnbits.extensions.{self.code}"
+ if self.upgrade_hash == ""
+ else f"lnbits.upgrades.{self.code}-{self.upgrade_hash}.{self.code}"
+ )
+
+ @classmethod
+ def from_installable_ext(cls, ext_info: "InstallableExtension") -> "Extension":
+ return Extension(
+ code=ext_info.id,
+ is_valid=True,
+ is_admin_only=False, # todo: is admin only
+ name=ext_info.name,
+ upgrade_hash=ext_info.hash if ext_info.module_installed else "",
+ )
+
+
+class ExtensionManager:
+ def __init__(self, include_disabled_exts=False):
+ self._disabled: List[str] = settings.lnbits_disabled_extensions
+ self._admin_only: List[str] = settings.lnbits_admin_extensions
+ self._extension_folders: List[str] = [
+ x[1] for x in os.walk(os.path.join(settings.lnbits_path, "extensions"))
+ ][0]
+
+ @property
+ def extensions(self) -> List[Extension]:
+ output: List[Extension] = []
+
+ if "all" in self._disabled:
+ return output
+
+ for extension in [
+ ext for ext in self._extension_folders if ext not in self._disabled
+ ]:
+ try:
+ with open(
+ os.path.join(
+ settings.lnbits_path, "extensions", extension, "config.json"
+ )
+ ) as json_file:
+ config = json.load(json_file)
+ is_valid = True
+ is_admin_only = True if extension in self._admin_only else False
+ except Exception:
+ config = {}
+ is_valid = False
+ is_admin_only = False
+
+ output.append(
+ Extension(
+ extension,
+ is_valid,
+ is_admin_only,
+ config.get("name"),
+ config.get("short_description"),
+ config.get("tile"),
+ config.get("contributors"),
+ config.get("hidden") or False,
+ config.get("migration_module"),
+ config.get("db_name"),
+ )
+ )
+
+ return output
+
+
+class ExtensionRelease(BaseModel):
+ name: str
+ version: str
+ archive: str
+ source_repo: str
+ is_github_release = False
+ hash: Optional[str]
+ html_url: Optional[str]
+ description: Optional[str]
+ details_html: Optional[str] = None
+ icon: Optional[str]
+
+ @classmethod
+ def from_github_release(
+ cls, source_repo: str, r: "GitHubRepoRelease"
+ ) -> "ExtensionRelease":
+ return ExtensionRelease(
+ name=r.name,
+ description=r.name,
+ version=r.tag_name,
+ archive=r.zipball_url,
+ source_repo=source_repo,
+ is_github_release=True,
+ # description=r.body, # bad for JSON
+ html_url=r.html_url,
+ )
+
+ @classmethod
+ async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
+ try:
+ github_releases = await fetch_github_releases(org, repo)
+ return [
+ ExtensionRelease.from_github_release(f"{org}/{repo}", r)
+ for r in github_releases
+ ]
+ except Exception as e:
+ logger.warning(e)
+ return []
+
+
+class ExplicitRelease(BaseModel):
+ id: str
+ name: str
+ version: str
+ archive: str
+ hash: str
+ dependencies: List[str] = []
+ icon: Optional[str]
+ short_description: Optional[str]
+ html_url: Optional[str]
+ details: Optional[str]
+ info_notification: Optional[str]
+ critical_notification: Optional[str]
+
+
+class GitHubRelease(BaseModel):
+ id: str
+ organisation: str
+ repository: str
+
+
+class Manifest(BaseModel):
+ featured: List[str] = []
+ extensions: List["ExplicitRelease"] = []
+ repos: List["GitHubRelease"] = []
+
+
+class GitHubRepoRelease(BaseModel):
+ name: str
+ tag_name: str
+ zipball_url: str
+ html_url: str
+
+
+class GitHubRepo(BaseModel):
+ stargazers_count: str
+ html_url: str
+ default_branch: str
+
+
+class ExtensionConfig(BaseModel):
+ name: str
+ short_description: str
+ tile: str = ""
+
+
+class InstallableExtension(BaseModel):
+ id: str
+ name: str
+ short_description: Optional[str] = None
+ icon: Optional[str] = None
+ dependencies: List[str] = []
+ is_admin_only: bool = False
+ stars: int = 0
+ featured = False
+ latest_release: Optional[ExtensionRelease]
+ installed_release: Optional[ExtensionRelease]
+
+ @property
+ def hash(self) -> str:
+ if self.installed_release:
+ if self.installed_release.hash:
+ return self.installed_release.hash
+ m = hashlib.sha256()
+ m.update(f"{self.installed_release.archive}".encode())
+ return m.hexdigest()
+ return "not-installed"
+
+ @property
+ def zip_path(self) -> str:
+ extensions_data_dir = os.path.join(settings.lnbits_data_folder, "extensions")
+ os.makedirs(extensions_data_dir, exist_ok=True)
+ return os.path.join(extensions_data_dir, f"{self.id}.zip")
+
+ @property
+ def ext_dir(self) -> str:
+ return os.path.join("lnbits", "extensions", self.id)
+
+ @property
+ def ext_upgrade_dir(self) -> str:
+ return os.path.join("lnbits", "upgrades", f"{self.id}-{self.hash}")
+
+ @property
+ def module_name(self) -> str:
+ return f"lnbits.extensions.{self.id}"
+
+ @property
+ def module_installed(self) -> bool:
+ return self.module_name in sys.modules
+
+ @property
+ def has_installed_version(self) -> bool:
+ if not Path(self.ext_dir).is_dir():
+ return False
+ config_file = os.path.join(self.ext_dir, "config.json")
+ if not Path(config_file).is_file():
+ return False
+ with open(config_file, "r") as json_file:
+ config_json = json.load(json_file)
+ return config_json.get("is_installed") == True
+
+ def download_archive(self):
+ ext_zip_file = self.zip_path
+ if os.path.isfile(ext_zip_file):
+ os.remove(ext_zip_file)
+ try:
+ download_url(self.installed_release.archive, ext_zip_file)
+ except Exception as ex:
+ logger.warning(ex)
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="Cannot fetch extension archive file",
+ )
+
+ archive_hash = file_hash(ext_zip_file)
+ if self.installed_release.hash and self.installed_release.hash != archive_hash:
+ # remove downloaded archive
+ if os.path.isfile(ext_zip_file):
+ os.remove(ext_zip_file)
+ raise HTTPException(
+ status_code=HTTPStatus.NOT_FOUND,
+ detail="File hash missmatch. Will not install.",
+ )
+
+ def extract_archive(self):
+ os.makedirs(os.path.join("lnbits", "upgrades"), exist_ok=True)
+ shutil.rmtree(self.ext_upgrade_dir, 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(
+ os.path.join(self.ext_upgrade_dir, generated_dir_name),
+ os.path.join(self.ext_upgrade_dir, self.id),
+ )
+
+ # Pre-packed extensions can be upgraded
+ # Mark the extension as installed so we know it is not the pre-packed version
+ with open(
+ os.path.join(self.ext_upgrade_dir, self.id, "config.json"), "r+"
+ ) as json_file:
+ config_json = json.load(json_file)
+ config_json["is_installed"] = True
+ json_file.seek(0)
+ json.dump(config_json, json_file)
+ json_file.truncate()
+
+ self.name = config_json.get("name")
+ self.short_description = config_json.get("short_description")
+
+ if (
+ self.installed_release
+ and self.installed_release.is_github_release
+ and config_json.get("tile")
+ ):
+ self.icon = icon_to_github_url(
+ self.installed_release.source_repo, config_json.get("tile")
+ )
+
+ shutil.rmtree(self.ext_dir, True)
+ shutil.copytree(
+ os.path.join(self.ext_upgrade_dir, self.id),
+ os.path.join("lnbits", "extensions", self.id),
+ )
+
+ def nofiy_upgrade(self) -> None:
+ """Update the list of upgraded extensions. The middleware will perform redirects based on this"""
+
+ clean_upgraded_exts = list(
+ filter(
+ lambda old_ext: not old_ext.endswith(f"/{self.id}"),
+ settings.lnbits_upgraded_extensions,
+ )
+ )
+ settings.lnbits_upgraded_extensions = clean_upgraded_exts + [
+ f"{self.hash}/{self.id}"
+ ]
+
+ def clean_extension_files(self):
+ # remove downloaded archive
+ if os.path.isfile(self.zip_path):
+ os.remove(self.zip_path)
+
+ # remove module from extensions
+ shutil.rmtree(self.ext_dir, True)
+
+ shutil.rmtree(self.ext_upgrade_dir, True)
+
+ @classmethod
+ def from_row(cls, data: dict) -> "InstallableExtension":
+ meta = json.loads(data["meta"])
+ ext = InstallableExtension(**data)
+ if "installed_release" in meta:
+ ext.installed_release = ExtensionRelease(**meta["installed_release"])
+ return ext
+
+ @classmethod
+ async def from_github_release(
+ cls, github_release: GitHubRelease
+ ) -> Optional["InstallableExtension"]:
+ try:
+ repo, latest_release, config = await fetch_github_repo_info(
+ github_release.organisation, github_release.repository
+ )
+
+ return InstallableExtension(
+ id=github_release.id,
+ name=config.name,
+ short_description=config.short_description,
+ version="0",
+ stars=repo.stargazers_count,
+ icon=icon_to_github_url(
+ f"{github_release.organisation}/{github_release.repository}",
+ config.tile,
+ ),
+ latest_release=ExtensionRelease.from_github_release(
+ repo.html_url, latest_release
+ ),
+ )
+ except Exception as e:
+ logger.warning(e)
+ return None
+
+ @classmethod
+ def from_explicit_release(cls, e: ExplicitRelease) -> "InstallableExtension":
+ return InstallableExtension(
+ id=e.id,
+ name=e.name,
+ archive=e.archive,
+ hash=e.hash,
+ short_description=e.short_description,
+ icon=e.icon,
+ dependencies=e.dependencies,
+ )
+
+ @classmethod
+ async def get_installable_extensions(
+ cls,
+ ) -> List["InstallableExtension"]:
+ extension_list: List[InstallableExtension] = []
+ extension_id_list: List[str] = []
+
+ for url in settings.lnbits_extensions_manifests:
+ try:
+ manifest = await fetch_manifest(url)
+
+ for r in manifest.repos:
+ if r.id in extension_id_list:
+ continue
+ ext = await InstallableExtension.from_github_release(r)
+ if ext:
+ ext.featured = ext.id in manifest.featured
+ extension_list += [ext]
+ extension_id_list += [ext.id]
+
+ for e in manifest.extensions:
+ if e.id in extension_id_list:
+ continue
+ ext = InstallableExtension.from_explicit_release(e)
+ ext.featured = ext.id in manifest.featured
+ extension_list += [ext]
+ extension_id_list += [e.id]
+ except Exception as e:
+ logger.warning(f"Manifest {url} failed with '{str(e)}'")
+
+ return extension_list
+
+ @classmethod
+ async def get_extension_releases(cls, ext_id: str) -> List["ExtensionRelease"]:
+ extension_releases: List[ExtensionRelease] = []
+
+ for url in settings.lnbits_extensions_manifests:
+ try:
+ manifest = await fetch_manifest(url)
+ for r in manifest.repos:
+ if r.id == ext_id:
+ repo_releases = await ExtensionRelease.all_releases(
+ r.organisation, r.repository
+ )
+ extension_releases += repo_releases
+
+ for e in manifest.extensions:
+ if e.id == ext_id:
+ extension_releases += [
+ ExtensionRelease(
+ name=e.name,
+ version=e.version,
+ archive=e.archive,
+ hash=e.hash,
+ source_repo=url,
+ description=e.short_description,
+ details_html=e.details,
+ html_url=e.html_url,
+ icon=e.icon,
+ )
+ ]
+
+ except Exception as e:
+ logger.warning(f"Manifest {url} failed with '{str(e)}'")
+
+ return extension_releases
+
+ @classmethod
+ async def get_extension_release(
+ cls, ext_id: str, source_repo: str, archive: str
+ ) -> Optional["ExtensionRelease"]:
+ all_releases: List[
+ ExtensionRelease
+ ] = await InstallableExtension.get_extension_releases(ext_id)
+ selected_release = [
+ r
+ for r in all_releases
+ if r.archive == archive and r.source_repo == source_repo
+ ]
+
+ return selected_release[0] if len(selected_release) != 0 else None
+
+
+class InstalledExtensionMiddleware:
+ # This middleware class intercepts calls made to the extensions API and:
+ # - it blocks the calls if the extension has been disabled or uninstalled.
+ # - it redirects the calls to the latest version of the extension if the extension has been upgraded.
+ # - otherwise it has no effect
+ def __init__(self, app: ASGIApp) -> None:
+ self.app = app
+
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
+ if not "path" in scope:
+ await self.app(scope, receive, send)
+ return
+
+ path_elements = scope["path"].split("/")
+ if len(path_elements) > 2:
+ _, path_name, path_type, *rest = path_elements
+ else:
+ _, path_name = path_elements
+ path_type = None
+
+ # block path for all users if the extension is disabled
+ if path_name in settings.lnbits_deactivated_extensions:
+ response = JSONResponse(
+ status_code=HTTPStatus.NOT_FOUND,
+ content={"detail": f"Extension '{path_name}' disabled"},
+ )
+ await response(scope, receive, send)
+ return
+
+ # re-route API trafic if the extension has been upgraded
+ if path_type == "api":
+ upgraded_extensions = list(
+ filter(
+ lambda ext: ext.endswith(f"/{path_name}"),
+ settings.lnbits_upgraded_extensions,
+ )
+ )
+ if len(upgraded_extensions) != 0:
+ upgrade_path = upgraded_extensions[0]
+ tail = "/".join(rest)
+ scope["path"] = f"/upgrades/{upgrade_path}/{path_type}/{tail}"
+
+ await self.app(scope, receive, send)
+
+
+class CreateExtension(BaseModel):
+ ext_id: str
+ archive: str
+ source_repo: str
+
+
+def get_valid_extensions() -> List[Extension]:
+ return [
+ extension for extension in ExtensionManager().extensions if extension.is_valid
+ ]
+
+
+def download_url(url, save_path):
+ with urllib.request.urlopen(url) as dl_file:
+ with open(save_path, "wb") as out_file:
+ out_file.write(dl_file.read())
+
+
+def file_hash(filename):
+ h = hashlib.sha256()
+ b = bytearray(128 * 1024)
+ mv = memoryview(b)
+ with open(filename, "rb", buffering=0) as f:
+ while n := f.readinto(mv):
+ h.update(mv[:n])
+ return h.hexdigest()
+
+
+def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
+ if not path:
+ return ""
+ _, _, *rest = path.split("/")
+ tail = "/".join(rest)
+ return f"https://github.com/{source_repo}/raw/main/{tail}"
+
+
+async def fetch_github_repo_info(
+ org: str, repository: str
+) -> Tuple[GitHubRepo, GitHubRepoRelease, ExtensionConfig]:
+ repo_url = f"https://api.github.com/repos/{org}/{repository}"
+ error_msg = "Cannot fetch extension repo"
+ repo = await gihub_api_get(repo_url, error_msg)
+ github_repo = GitHubRepo.parse_obj(repo)
+
+ lates_release_url = (
+ f"https://api.github.com/repos/{org}/{repository}/releases/latest"
+ )
+ error_msg = "Cannot fetch extension releases"
+ latest_release: Any = await gihub_api_get(lates_release_url, error_msg)
+
+ config_url = f"https://raw.githubusercontent.com/{org}/{repository}/{github_repo.default_branch}/config.json"
+ error_msg = "Cannot fetch config for extension"
+ config = await gihub_api_get(config_url, error_msg)
+
+ return (
+ github_repo,
+ GitHubRepoRelease.parse_obj(latest_release),
+ ExtensionConfig.parse_obj(config),
+ )
+
+
+async def fetch_manifest(url) -> Manifest:
+ error_msg = "Cannot fetch extensions manifest"
+ manifest = await gihub_api_get(url, error_msg)
+ return Manifest.parse_obj(manifest)
+
+
+async def fetch_github_releases(org: str, repo: str) -> List[GitHubRepoRelease]:
+ releases_url = f"https://api.github.com/repos/{org}/{repo}/releases"
+ error_msg = "Cannot fetch extension releases"
+ releases = await gihub_api_get(releases_url, error_msg)
+ return [GitHubRepoRelease.parse_obj(r) for r in releases]
+
+
+async def gihub_api_get(url: str, error_msg: Optional[str]) -> Any:
+ async with httpx.AsyncClient() as client:
+ headers = (
+ {"Authorization": "Bearer " + settings.lnbits_ext_github_token}
+ if settings.lnbits_ext_github_token
+ else None
+ )
+ resp = await client.get(
+ url,
+ headers=headers,
+ )
+ if resp.status_code != 200:
+ logger.warning(f"{error_msg} ({url}): {resp.text}")
+ resp.raise_for_status()
+ return resp.json()
diff --git a/lnbits/extensions/copilot/README.md b/lnbits/extensions/copilot/README.md
deleted file mode 100644
index 323aeddc..00000000
--- a/lnbits/extensions/copilot/README.md
+++ /dev/null
@@ -1,3 +0,0 @@
-# StreamerCopilot
-
-Tool to help streamers accept sats for tips
diff --git a/lnbits/extensions/copilot/__init__.py b/lnbits/extensions/copilot/__init__.py
deleted file mode 100644
index 806801ce..00000000
--- a/lnbits/extensions/copilot/__init__.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import asyncio
-
-from fastapi import APIRouter
-from fastapi.staticfiles import StaticFiles
-
-from lnbits.db import Database
-from lnbits.helpers import template_renderer
-from lnbits.tasks import catch_everything_and_restart
-
-db = Database("ext_copilot")
-
-copilot_static_files = [
- {
- "path": "/copilot/static",
- "app": StaticFiles(packages=[("lnbits", "extensions/copilot/static")]),
- "name": "copilot_static",
- }
-]
-copilot_ext: APIRouter = APIRouter(prefix="/copilot", tags=["copilot"])
-
-
-def copilot_renderer():
- return template_renderer(["lnbits/extensions/copilot/templates"])
-
-
-from .lnurl import * # noqa
-from .tasks import wait_for_paid_invoices
-from .views import * # noqa
-from .views_api import * # noqa
-
-
-def copilot_start():
- loop = asyncio.get_event_loop()
- loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
diff --git a/lnbits/extensions/copilot/config.json b/lnbits/extensions/copilot/config.json
deleted file mode 100644
index fc754999..00000000
--- a/lnbits/extensions/copilot/config.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "name": "Streamer Copilot",
- "short_description": "Video tips/animations/webhooks",
- "tile": "/copilot/static/bitcoin-streaming.png",
- "contributors": [
- "arcbtc"
- ]
-}
diff --git a/lnbits/extensions/copilot/crud.py b/lnbits/extensions/copilot/crud.py
deleted file mode 100644
index 5ecb5cd4..00000000
--- a/lnbits/extensions/copilot/crud.py
+++ /dev/null
@@ -1,97 +0,0 @@
-from typing import List, Optional, Union
-
-from lnbits.helpers import urlsafe_short_hash
-
-from . import db
-from .models import Copilots, CreateCopilotData
-
-###############COPILOTS##########################
-
-
-async def create_copilot(
- data: CreateCopilotData, inkey: Optional[str] = ""
-) -> Optional[Copilots]:
- copilot_id = urlsafe_short_hash()
- await db.execute(
- """
- INSERT INTO copilot.newer_copilots (
- id,
- "user",
- lnurl_toggle,
- wallet,
- title,
- animation1,
- animation2,
- animation3,
- animation1threshold,
- animation2threshold,
- animation3threshold,
- animation1webhook,
- animation2webhook,
- animation3webhook,
- lnurl_title,
- show_message,
- show_ack,
- show_price,
- fullscreen_cam,
- iframe_url,
- amount_made
- )
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- """,
- (
- copilot_id,
- data.user,
- int(data.lnurl_toggle),
- data.wallet,
- data.title,
- data.animation1,
- data.animation2,
- data.animation3,
- data.animation1threshold,
- data.animation2threshold,
- data.animation3threshold,
- data.animation1webhook,
- data.animation2webhook,
- data.animation3webhook,
- data.lnurl_title,
- int(data.show_message),
- int(data.show_ack),
- data.show_price,
- 0,
- None,
- 0,
- ),
- )
- return await get_copilot(copilot_id)
-
-
-async def update_copilot(
- data: CreateCopilotData, copilot_id: str
-) -> Optional[Copilots]:
- q = ", ".join([f"{field[0]} = ?" for field in data])
- items = [f"{field[1]}" for field in data]
- items.append(copilot_id)
- await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items,))
- row = await db.fetchone(
- "SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
- )
- return Copilots(**row) if row else None
-
-
-async def get_copilot(copilot_id: str) -> Optional[Copilots]:
- row = await db.fetchone(
- "SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
- )
- return Copilots(**row) if row else None
-
-
-async def get_copilots(user: str) -> List[Copilots]:
- rows = await db.fetchall(
- 'SELECT * FROM copilot.newer_copilots WHERE "user" = ?', (user,)
- )
- return [Copilots(**row) for row in rows]
-
-
-async def delete_copilot(copilot_id: str) -> None:
- await db.execute("DELETE FROM copilot.newer_copilots WHERE id = ?", (copilot_id,))
diff --git a/lnbits/extensions/copilot/lnurl.py b/lnbits/extensions/copilot/lnurl.py
deleted file mode 100644
index b0bc83bc..00000000
--- a/lnbits/extensions/copilot/lnurl.py
+++ /dev/null
@@ -1,82 +0,0 @@
-import hashlib
-import json
-from http import HTTPStatus
-
-from fastapi import Request
-from fastapi.param_functions import Query
-from lnurl.types import LnurlPayMetadata
-from starlette.exceptions import HTTPException
-from starlette.responses import HTMLResponse
-
-from lnbits.core.services import create_invoice
-
-from . import copilot_ext
-from .crud import get_copilot
-
-
-@copilot_ext.get(
- "/lnurl/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_response"
-)
-async def lnurl_response(req: Request, cp_id: str = Query(None)):
- cp = await get_copilot(cp_id)
- if not cp:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
- )
-
- payResponse = {
- "tag": "payRequest",
- "callback": req.url_for("copilot.lnurl_callback", cp_id=cp_id),
- "metadata": LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]])),
- "maxSendable": 50000000,
- "minSendable": 10000,
- }
-
- if cp.show_message:
- payResponse["commentAllowed"] = 300
- return json.dumps(payResponse)
-
-
-@copilot_ext.get(
- "/lnurl/cb/{cp_id}", response_class=HTMLResponse, name="copilot.lnurl_callback"
-)
-async def lnurl_callback(
- cp_id: str = Query(None), amount: str = Query(None), comment: str = Query(None)
-):
- cp = await get_copilot(cp_id)
- if not cp:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
- )
- amount_received = int(amount)
-
- if amount_received < 10000:
- raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN,
- detail="Amount {round(amount_received / 1000)} is smaller than minimum 10 sats.",
- )
- elif amount_received / 1000 > 10000000:
- raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN,
- detail="Amount {round(amount_received / 1000)} is greater than maximum 50000.",
- )
- comment = ""
- if comment:
- if len(comment or "") > 300:
- raise HTTPException(
- status_code=HTTPStatus.FORBIDDEN,
- detail="Got a comment with {len(comment)} characters, but can only accept 300",
- )
- if len(comment) < 1:
- comment = "none"
- _, payment_request = await create_invoice(
- wallet_id=cp.wallet,
- amount=int(amount_received / 1000),
- memo=cp.lnurl_title,
- unhashed_description=(
- LnurlPayMetadata(json.dumps([["text/plain", str(cp.lnurl_title)]]))
- ).encode(),
- extra={"tag": "copilot", "copilotid": cp.id, "comment": comment},
- )
- payResponse = {"pr": payment_request, "routes": []}
- return json.dumps(payResponse)
diff --git a/lnbits/extensions/copilot/migrations.py b/lnbits/extensions/copilot/migrations.py
deleted file mode 100644
index b1c16dcc..00000000
--- a/lnbits/extensions/copilot/migrations.py
+++ /dev/null
@@ -1,79 +0,0 @@
-async def m001_initial(db):
- """
- Initial copilot table.
- """
-
- await db.execute(
- f"""
- CREATE TABLE copilot.copilots (
- id TEXT NOT NULL PRIMARY KEY,
- "user" TEXT,
- title TEXT,
- lnurl_toggle INTEGER,
- wallet TEXT,
- animation1 TEXT,
- animation2 TEXT,
- animation3 TEXT,
- animation1threshold INTEGER,
- animation2threshold INTEGER,
- animation3threshold INTEGER,
- animation1webhook TEXT,
- animation2webhook TEXT,
- animation3webhook TEXT,
- lnurl_title TEXT,
- show_message INTEGER,
- show_ack INTEGER,
- show_price INTEGER,
- amount_made INTEGER,
- fullscreen_cam INTEGER,
- iframe_url TEXT,
- timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
-
-async def m002_fix_data_types(db):
- """
- Fix data types.
- """
-
- if db.type != "SQLITE":
- await db.execute(
- "ALTER TABLE copilot.copilots ALTER COLUMN show_price TYPE TEXT;"
- )
-
-
-async def m003_fix_data_types(db):
- await db.execute(
- f"""
- CREATE TABLE copilot.newer_copilots (
- id TEXT NOT NULL PRIMARY KEY,
- "user" TEXT,
- title TEXT,
- lnurl_toggle INTEGER,
- wallet TEXT,
- animation1 TEXT,
- animation2 TEXT,
- animation3 TEXT,
- animation1threshold INTEGER,
- animation2threshold INTEGER,
- animation3threshold INTEGER,
- animation1webhook TEXT,
- animation2webhook TEXT,
- animation3webhook TEXT,
- lnurl_title TEXT,
- show_message INTEGER,
- show_ack INTEGER,
- show_price TEXT,
- amount_made INTEGER,
- fullscreen_cam INTEGER,
- iframe_url TEXT,
- timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
- );
- """
- )
-
- await db.execute(
- "INSERT INTO copilot.newer_copilots SELECT * FROM copilot.copilots"
- )
diff --git a/lnbits/extensions/copilot/models.py b/lnbits/extensions/copilot/models.py
deleted file mode 100644
index 7ca2fc96..00000000
--- a/lnbits/extensions/copilot/models.py
+++ /dev/null
@@ -1,66 +0,0 @@
-import json
-from sqlite3 import Row
-from typing import Dict, Optional
-from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
-
-from fastapi.param_functions import Query
-from lnurl.types import LnurlPayMetadata
-from pydantic import BaseModel
-from starlette.requests import Request
-
-from lnbits.lnurl import encode as lnurl_encode
-
-
-class CreateCopilotData(BaseModel):
- user: str = Query(None)
- title: str = Query(None)
- lnurl_toggle: int = Query(0)
- wallet: str = Query(None)
- animation1: str = Query(None)
- animation2: str = Query(None)
- animation3: str = Query(None)
- animation1threshold: int = Query(None)
- animation2threshold: int = Query(None)
- animation3threshold: int = Query(None)
- animation1webhook: str = Query(None)
- animation2webhook: str = Query(None)
- animation3webhook: str = Query(None)
- lnurl_title: str = Query(None)
- show_message: int = Query(0)
- show_ack: int = Query(0)
- show_price: str = Query(None)
- amount_made: int = Query(0)
- timestamp: int = Query(0)
- fullscreen_cam: int = Query(0)
- iframe_url: str = Query(None)
- success_url: str = Query(None)
-
-
-class Copilots(BaseModel):
- id: str
- user: str = Query(None)
- title: str = Query(None)
- lnurl_toggle: int = Query(0)
- wallet: str = Query(None)
- animation1: str = Query(None)
- animation2: str = Query(None)
- animation3: str = Query(None)
- animation1threshold: int = Query(None)
- animation2threshold: int = Query(None)
- animation3threshold: int = Query(None)
- animation1webhook: str = Query(None)
- animation2webhook: str = Query(None)
- animation3webhook: str = Query(None)
- lnurl_title: str = Query(None)
- show_message: int = Query(0)
- show_ack: int = Query(0)
- show_price: str = Query(None)
- amount_made: int = Query(0)
- timestamp: int = Query(0)
- fullscreen_cam: int = Query(0)
- iframe_url: str = Query(None)
- success_url: str = Query(None)
-
- def lnurl(self, req: Request) -> str:
- url = req.url_for("copilot.lnurl_response", cp_id=self.id)
- return lnurl_encode(url)
diff --git a/lnbits/extensions/copilot/static/bitcoin-streaming.png b/lnbits/extensions/copilot/static/bitcoin-streaming.png
deleted file mode 100644
index 1022baf2..00000000
Binary files a/lnbits/extensions/copilot/static/bitcoin-streaming.png and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/bitcoin.gif b/lnbits/extensions/copilot/static/bitcoin.gif
deleted file mode 100644
index ef8c2ecd..00000000
Binary files a/lnbits/extensions/copilot/static/bitcoin.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/confetti.gif b/lnbits/extensions/copilot/static/confetti.gif
deleted file mode 100644
index a3fec971..00000000
Binary files a/lnbits/extensions/copilot/static/confetti.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/face.gif b/lnbits/extensions/copilot/static/face.gif
deleted file mode 100644
index 3e70d779..00000000
Binary files a/lnbits/extensions/copilot/static/face.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/lnurl.png b/lnbits/extensions/copilot/static/lnurl.png
deleted file mode 100644
index ad2c9715..00000000
Binary files a/lnbits/extensions/copilot/static/lnurl.png and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/martijn.gif b/lnbits/extensions/copilot/static/martijn.gif
deleted file mode 100644
index e410677d..00000000
Binary files a/lnbits/extensions/copilot/static/martijn.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/rick.gif b/lnbits/extensions/copilot/static/rick.gif
deleted file mode 100644
index c36c7e19..00000000
Binary files a/lnbits/extensions/copilot/static/rick.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/static/rocket.gif b/lnbits/extensions/copilot/static/rocket.gif
deleted file mode 100644
index 6f19597d..00000000
Binary files a/lnbits/extensions/copilot/static/rocket.gif and /dev/null differ
diff --git a/lnbits/extensions/copilot/tasks.py b/lnbits/extensions/copilot/tasks.py
deleted file mode 100644
index 4975b5a3..00000000
--- a/lnbits/extensions/copilot/tasks.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import asyncio
-import json
-from http import HTTPStatus
-
-import httpx
-from starlette.exceptions import HTTPException
-
-from lnbits.core import db as core_db
-from lnbits.core.models import Payment
-from lnbits.core.services import websocketUpdater
-from lnbits.helpers import get_current_extension_name
-from lnbits.tasks import register_invoice_listener
-
-from .crud import get_copilot
-
-
-async def wait_for_paid_invoices():
- invoice_queue = asyncio.Queue()
- register_invoice_listener(invoice_queue, get_current_extension_name())
-
- while True:
- payment = await invoice_queue.get()
- await on_invoice_paid(payment)
-
-
-async def on_invoice_paid(payment: Payment) -> None:
- if payment.extra.get("tag") != "copilot":
- # not an copilot invoice
- return
-
- webhook = None
- data = None
- copilot = await get_copilot(payment.extra.get("copilotid", -1))
-
- if not copilot:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
- )
- if copilot.animation1threshold:
- if int(payment.amount / 1000) >= copilot.animation1threshold:
- data = copilot.animation1
- webhook = copilot.animation1webhook
- if copilot.animation2threshold:
- if int(payment.amount / 1000) >= copilot.animation2threshold:
- data = copilot.animation2
- webhook = copilot.animation1webhook
- if copilot.animation3threshold:
- if int(payment.amount / 1000) >= copilot.animation3threshold:
- data = copilot.animation3
- webhook = copilot.animation1webhook
- if webhook:
- async with httpx.AsyncClient() as client:
- try:
- r = await client.post(
- webhook,
- json={
- "payment_hash": payment.payment_hash,
- "payment_request": payment.bolt11,
- "amount": payment.amount,
- "comment": payment.extra.get("comment"),
- },
- timeout=40,
- )
- await mark_webhook_sent(payment, r.status_code)
- except (httpx.ConnectError, httpx.RequestError):
- await mark_webhook_sent(payment, -1)
- if payment.extra.get("comment"):
- await websocketUpdater(
- copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
- )
-
- await websocketUpdater(copilot.id, str(data) + "-none")
-
-
-async def mark_webhook_sent(payment: Payment, status: int) -> None:
- if payment.extra:
- payment.extra["wh_status"] = status
- await core_db.execute(
- """
- UPDATE apipayments SET extra = ?
- WHERE hash = ?
- """,
- (json.dumps(payment.extra), payment.payment_hash),
- )
diff --git a/lnbits/extensions/copilot/templates/copilot/_api_docs.html b/lnbits/extensions/copilot/templates/copilot/_api_docs.html
deleted file mode 100644
index 72edc176..00000000
--- a/lnbits/extensions/copilot/templates/copilot/_api_docs.html
+++ /dev/null
@@ -1,178 +0,0 @@
-
-
-
- StreamerCopilot: get tips via static QR (lnurl-pay) and show an
- animation
-
- Created by,
- Ben Arc
-
-
-
-
-
-
-
- POST /copilot/api/v1/copilot
- Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json)
-
-
- Returns 200 OK (application/json)
-
- [<copilot_object>, ...]
- Curl example
- curl -X POST {{ request.base_url }}copilot/api/v1/copilot -d
- '{"title": <string>, "animation": <string>,
- "show_message":<string>, "amount": <integer>,
- "lnurl_title": <string>}' -H "Content-type: application/json"
- -H "X-Api-Key: {{user.wallets[0].adminkey }}"
-
-
-
-
-
-
-
- PUT
- /copilot/api/v1/copilot/<copilot_id>
- Headers
- {"X-Api-Key": <admin_key>}
-
- Body (application/json)
-
-
- Returns 200 OK (application/json)
-
- [<copilot_object>, ...]
- Curl example
- curl -X POST {{ request.base_url
- }}copilot/api/v1/copilot/<copilot_id> -d '{"title":
- <string>, "animation": <string>,
- "show_message":<string>, "amount": <integer>,
- "lnurl_title": <string>}' -H "Content-type: application/json"
- -H "X-Api-Key: {{user.wallets[0].adminkey }}"
-
-
-
-
-
-
-
-
- GET
- /copilot/api/v1/copilot/<copilot_id>
- Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json)
-
-
- Returns 200 OK (application/json)
-
- [<copilot_object>, ...]
- Curl example
- curl -X GET {{ request.base_url
- }}copilot/api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
- user.wallets[0].inkey }}"
-
-
-
-
-
-
-
- GET /copilot/api/v1/copilots
- Headers
- {"X-Api-Key": <invoice_key>}
-
- Body (application/json)
-
-
- Returns 200 OK (application/json)
-
- [<copilot_object>, ...]
- Curl example
- curl -X GET {{ request.base_url }}copilot/api/v1/copilots -H
- "X-Api-Key: {{ user.wallets[0].inkey }}"
-
-
-
-
-
-
-
- DELETE
- /copilot/api/v1/copilot/<copilot_id>
- Headers
- {"X-Api-Key": <admin_key>}
- Returns 204 NO CONTENT
-
- Curl example
- curl -X DELETE {{ request.base_url
- }}copilot/api/v1/copilot/<copilot_id> -H "X-Api-Key: {{
- user.wallets[0].adminkey }}"
-
-
-
-
-
-
-
- GET
- /api/v1/copilot/ws/<copilot_id>/<comment>/<data>
- Headers
- {"X-Api-Key": <admin_key>}
- Returns 200
-
- Curl example
- curl -X GET {{ request.base_url
- }}copilot/api/v1/copilot/ws/<string, copilot_id>/<string,
- comment>/<string, gif name> -H "X-Api-Key: {{
- user.wallets[0].adminkey }}"
-
-
-
-
-
-
diff --git a/lnbits/extensions/copilot/templates/copilot/compose.html b/lnbits/extensions/copilot/templates/copilot/compose.html
deleted file mode 100644
index 2ec4c4f7..00000000
--- a/lnbits/extensions/copilot/templates/copilot/compose.html
+++ /dev/null
@@ -1,305 +0,0 @@
-{% extends "public.html" %} {% block page %}
-
-
-
-
-
-
-
-
- {% raw %}{{ copilot.lnurl_title }}{% endraw %}
-
-
-
-
-
- {% raw %}{{ price }}{% endraw %}
-
-
-
- Powered by LNbits/StreamerCopilot
-
-
-{% endblock %} {% block scripts %}
-
-
-
-
-{% endblock %}
diff --git a/lnbits/extensions/copilot/templates/copilot/index.html b/lnbits/extensions/copilot/templates/copilot/index.html
deleted file mode 100644
index 95c08bae..00000000
--- a/lnbits/extensions/copilot/templates/copilot/index.html
+++ /dev/null
@@ -1,660 +0,0 @@
-{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
-%} {% block page %}
-
-
-
-
-
- {% raw %}
- New copilot instance
-
-
-
-
-
-
-
-
-
Copilots
-
-
-
-
-
-
-
-
- Export to CSV
-
-
-
-
-
-
-
-
-
-
-
-
- {{ col.label }}
-
-
-
-
-
-
-
-
-
- Panel
-
-
-
-
- Compose window
-
-
-
-
- Delete copilot
-
-
-
-
- Edit copilot
-
-
-
-
- {{ col.value }}
-
-
-
- {% endraw %}
-
-
-
-
-
-
-
-
-
- {{SITE_TITLE}} StreamCopilot Extension
-
-
-
-
- {% include "copilot/_api_docs.html" %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Update Copilot
- Create Copilot
- Cancel
-
-
-
-
-
-{% endblock %} {% block scripts %} {{ window_vars(user) }}
-
-{% endblock %}
diff --git a/lnbits/extensions/copilot/templates/copilot/panel.html b/lnbits/extensions/copilot/templates/copilot/panel.html
deleted file mode 100644
index f17bf34c..00000000
--- a/lnbits/extensions/copilot/templates/copilot/panel.html
+++ /dev/null
@@ -1,156 +0,0 @@
-{% extends "public.html" %} {% block page %}
-
-
-
-
-
-
-
-
-
-
- Title: {% raw %} {{ copilot.title }} {% endraw %}
-
-
-
-
-
-
-
-
-{% endblock %} {% block scripts %}
-
-{% endblock %}
diff --git a/lnbits/extensions/copilot/views.py b/lnbits/extensions/copilot/views.py
deleted file mode 100644
index ff69dfba..00000000
--- a/lnbits/extensions/copilot/views.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from typing import List
-
-from fastapi import Depends, Request
-from fastapi.templating import Jinja2Templates
-from starlette.responses import HTMLResponse
-
-from lnbits.core.models import User
-from lnbits.decorators import check_user_exists
-
-from . import copilot_ext, copilot_renderer
-
-templates = Jinja2Templates(directory="templates")
-
-
-@copilot_ext.get("/", response_class=HTMLResponse)
-async def index(request: Request, user: User = Depends(check_user_exists)):
- return copilot_renderer().TemplateResponse(
- "copilot/index.html", {"request": request, "user": user.dict()}
- )
-
-
-@copilot_ext.get("/cp/", response_class=HTMLResponse)
-async def compose(request: Request):
- return copilot_renderer().TemplateResponse(
- "copilot/compose.html", {"request": request}
- )
-
-
-@copilot_ext.get("/pn/", response_class=HTMLResponse)
-async def panel(request: Request):
- return copilot_renderer().TemplateResponse(
- "copilot/panel.html", {"request": request}
- )
diff --git a/lnbits/extensions/copilot/views_api.py b/lnbits/extensions/copilot/views_api.py
deleted file mode 100644
index f0621202..00000000
--- a/lnbits/extensions/copilot/views_api.py
+++ /dev/null
@@ -1,94 +0,0 @@
-from http import HTTPStatus
-
-from fastapi import Depends, Query, Request
-from starlette.exceptions import HTTPException
-
-from lnbits.core.services import websocketUpdater
-from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
-
-from . import copilot_ext
-from .crud import (
- create_copilot,
- delete_copilot,
- get_copilot,
- get_copilots,
- update_copilot,
-)
-from .models import CreateCopilotData
-
-#######################COPILOT##########################
-
-
-@copilot_ext.get("/api/v1/copilot")
-async def api_copilots_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
- wallet_user = wallet.wallet.user
- copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
- try:
- return copilots
- except:
- raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No copilots")
-
-
-@copilot_ext.get("/api/v1/copilot/{copilot_id}")
-async def api_copilot_retrieve(
- req: Request,
- copilot_id: str = Query(None),
- wallet: WalletTypeInfo = Depends(get_key_type),
-):
- copilot = await get_copilot(copilot_id)
- if not copilot:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot not found"
- )
- if not copilot.lnurl_toggle:
- return copilot.dict()
- return {**copilot.dict(), **{"lnurl": copilot.lnurl(req)}}
-
-
-@copilot_ext.post("/api/v1/copilot")
-@copilot_ext.put("/api/v1/copilot/{juke_id}")
-async def api_copilot_create_or_update(
- data: CreateCopilotData,
- copilot_id: str = Query(None),
- wallet: WalletTypeInfo = Depends(require_admin_key),
-):
- data.user = wallet.wallet.user
- data.wallet = wallet.wallet.id
- if copilot_id:
- copilot = await update_copilot(data, copilot_id=copilot_id)
- else:
- copilot = await create_copilot(data, inkey=wallet.wallet.inkey)
- return copilot
-
-
-@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
-async def api_copilot_delete(
- copilot_id: str = Query(None),
- wallet: WalletTypeInfo = Depends(require_admin_key),
-):
- copilot = await get_copilot(copilot_id)
-
- if not copilot:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
- )
-
- await delete_copilot(copilot_id)
-
- return "", HTTPStatus.NO_CONTENT
-
-
-@copilot_ext.get("/api/v1/copilot/ws/{copilot_id}/{comment}/{data}")
-async def api_copilot_ws_relay(
- copilot_id: str = Query(None), comment: str = Query(None), data: str = Query(None)
-):
- copilot = await get_copilot(copilot_id)
- if not copilot:
- raise HTTPException(
- status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
- )
- try:
- await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
- except:
- raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
- return ""
diff --git a/lnbits/helpers.py b/lnbits/helpers.py
index 4804bdea..31f736d9 100644
--- a/lnbits/helpers.py
+++ b/lnbits/helpers.py
@@ -1,83 +1,15 @@
import glob
-import json
import os
-from typing import Any, List, NamedTuple, Optional
+from typing import Any, List, Optional
import jinja2
-import shortuuid
+import shortuuid # type: ignore
from lnbits.jinja2_templating import Jinja2Templates
from lnbits.requestvars import g
from lnbits.settings import settings
-
-class Extension(NamedTuple):
- code: str
- is_valid: bool
- is_admin_only: bool
- name: Optional[str] = None
- short_description: Optional[str] = None
- tile: Optional[str] = None
- contributors: Optional[List[str]] = None
- hidden: bool = False
- migration_module: Optional[str] = None
- db_name: Optional[str] = None
-
-
-class ExtensionManager:
- def __init__(self):
- self._disabled: List[str] = settings.lnbits_disabled_extensions
- self._admin_only: List[str] = settings.lnbits_admin_extensions
- self._extension_folders: List[str] = [
- x[1] for x in os.walk(os.path.join(settings.lnbits_path, "extensions"))
- ][0]
-
- @property
- def extensions(self) -> List[Extension]:
- output: List[Extension] = []
-
- if "all" in self._disabled:
- return output
-
- for extension in [
- ext for ext in self._extension_folders if ext not in self._disabled
- ]:
- try:
- with open(
- os.path.join(
- settings.lnbits_path, "extensions", extension, "config.json"
- )
- ) as json_file:
- config = json.load(json_file)
- is_valid = True
- is_admin_only = True if extension in self._admin_only else False
- except Exception:
- config = {}
- is_valid = False
- is_admin_only = False
-
- output.append(
- Extension(
- extension,
- is_valid,
- is_admin_only,
- config.get("name"),
- config.get("short_description"),
- config.get("tile"),
- config.get("contributors"),
- config.get("hidden") or False,
- config.get("migration_module"),
- config.get("db_name"),
- )
- )
-
- return output
-
-
-def get_valid_extensions() -> List[Extension]:
- return [
- extension for extension in ExtensionManager().extensions if extension.is_valid
- ]
+from .extension_manager import get_valid_extensions
def urlsafe_short_hash() -> str:
@@ -176,7 +108,11 @@ def template_renderer(additional_folders: List = []) -> Jinja2Templates:
t.env.globals["SITE_DESCRIPTION"] = settings.lnbits_site_description
t.env.globals["LNBITS_THEME_OPTIONS"] = settings.lnbits_theme_options
t.env.globals["LNBITS_VERSION"] = settings.lnbits_commit
- t.env.globals["EXTENSIONS"] = get_valid_extensions()
+ t.env.globals["EXTENSIONS"] = [
+ e
+ for e in get_valid_extensions()
+ if e.code not in settings.lnbits_deactivated_extensions
+ ]
if settings.lnbits_custom_logo:
t.env.globals["USE_CUSTOM_LOGO"] = settings.lnbits_custom_logo
diff --git a/lnbits/settings.py b/lnbits/settings.py
index 2db63f4d..bc317e05 100644
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -39,8 +39,26 @@ class LNbitsSettings(BaseSettings):
class UsersSettings(LNbitsSettings):
lnbits_admin_users: List[str] = Field(default=[])
lnbits_allowed_users: List[str] = Field(default=[])
+
+
+class ExtensionsSettings(LNbitsSettings):
lnbits_admin_extensions: List[str] = Field(default=[])
lnbits_disabled_extensions: List[str] = Field(default=[])
+ lnbits_extensions_manifests: List[str] = Field(
+ default=[
+ "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
+ ]
+ )
+
+ # required due to GitHUb rate-limit
+ lnbits_ext_github_token: str = Field(default="")
+
+
+class InstalledExtensionsSettings(LNbitsSettings):
+ # installed extensions that have been deactivated
+ lnbits_deactivated_extensions: List[str] = Field(default=[])
+ # upgraded extensions that require API redirects
+ lnbits_upgraded_extensions: List[str] = Field(default=[])
class ThemesSettings(LNbitsSettings):
@@ -172,6 +190,7 @@ class FundingSourcesSettings(
class EditableSettings(
UsersSettings,
+ ExtensionsSettings,
ThemesSettings,
OpsSettings,
FundingSourcesSettings,
@@ -234,6 +253,18 @@ class SuperUserSettings(LNbitsSettings):
)
+class TransientSettings(InstalledExtensionsSettings):
+ # Transient Settings:
+ # - are initialized, updated and used at runtime
+ # - are not read from a file or from the `setings` table
+ # - are not persisted in the `settings` table when the settings are updated
+ # - are cleared on server restart
+
+ @classmethod
+ def readonly_fields(cls):
+ return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
+
+
class ReadOnlySettings(
EnvSettings,
SaaSSettings,
@@ -254,7 +285,7 @@ class ReadOnlySettings(
return [f for f in inspect.signature(cls).parameters if not f.startswith("_")]
-class Settings(EditableSettings, ReadOnlySettings):
+class Settings(EditableSettings, ReadOnlySettings, TransientSettings):
@classmethod
def from_row(cls, row: Row) -> "Settings":
data = dict(row)
@@ -314,6 +345,7 @@ def send_admin_user_to_saas():
############### INIT #################
readonly_variables = ReadOnlySettings.readonly_fields()
+transient_variables = TransientSettings.readonly_fields()
settings = Settings()
diff --git a/lnbits/static/js/base.js b/lnbits/static/js/base.js
index 32b075b7..d424d563 100644
--- a/lnbits/static/js/base.js
+++ b/lnbits/static/js/base.js
@@ -141,7 +141,8 @@ window.LNbits = {
admin: data.admin,
email: data.email,
extensions: data.extensions,
- wallets: data.wallets
+ wallets: data.wallets,
+ admin: data.admin
}
var mapWallet = this.wallet
obj.wallets = obj.wallets
diff --git a/lnbits/static/js/components.js b/lnbits/static/js/components.js
index 88be819d..0911ea4a 100644
--- a/lnbits/static/js/components.js
+++ b/lnbits/static/js/components.js
@@ -137,7 +137,15 @@ Vue.component('lnbits-extension-list', {
- Manage extensions
+ Extensions
+
+
+
+
+
+
+
+ Add Extensions
diff --git a/tests/data/mock_data.zip b/tests/data/mock_data.zip
index 8356cd54..fb6e5122 100644
Binary files a/tests/data/mock_data.zip and b/tests/data/mock_data.zip differ