diff --git a/.env.example b/.env.example index 37e62ce3..26358af4 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,9 @@ LNBITS_HIDE_API=false # Disable extensions for all users, use "all" to disable all extensions LNBITS_DISABLED_EXTENSIONS="amilk" +# LNBITS_EXTENSIONS_MANIFESTS="https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json,https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions-trial.json" +# GitHub has rate-limits for its APIs. The limit can be increased specifying a GITHUB_TOKEN +# LNBITS_EXT_GITHUB_TOKEN=github_pat_xxxxxxxxxxxxxxxxxx # Database: to use SQLite, specify LNBITS_DATA_FOLDER # to use PostgreSQL, specify LNBITS_DATABASE_URL=postgres://... diff --git a/.gitignore b/.gitignore index c2a305e7..3b0b00ba 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ tests/data/*.sqlite3 .venv venv +data *.sqlite3 .pyre* @@ -38,3 +39,7 @@ docker # Nix *result* + +# Ignore extensions (post installable extension PR) +extensions/ +upgrades/ \ No newline at end of file diff --git a/docs/guide/extension-install.md b/docs/guide/extension-install.md new file mode 100644 index 00000000..b835170b --- /dev/null +++ b/docs/guide/extension-install.md @@ -0,0 +1,137 @@ +# Extension Install + +Anyone can create an extension by following the [example extension](https://github.com/lnbits/lnbits/tree/extension_install_02/lnbits/extensions/example). + +Extensions can be installed by an admin user after the **LNbits** instance has been started. + +## Configure Repositories + +Go to `Manage Server` > `Server` > `Extensions Manifests` + +![image](https://user-images.githubusercontent.com/2951406/213494038-e8152d8e-61f2-4cb7-8b5f-361fc3f9a31f.png) + + +An `Extension Manifest` is a link to a `JSON` file which contains information about various extensions that can be installed (repository of extensions). +Multiple repositories can be configured. For more information check the [Manifest File](https://github.com/lnbits/lnbits/blob/extension_install_02/docs/guide/extension-install.md#manifest-file) section. + + +**LNbits** administrators should configure their instances to use repositories that they trust (like the [lnbits-extensions](https://github.com/lnbits/lnbits-extensions/) one). +> **Warning** +> Extensions can have bugs or malicious code, be careful what you install!! + +## Install New Extension +Only administrator users can install or upgrade extensions. + +Go to `Manage Extensions` > `Add Remove Extensions` +![image](https://user-images.githubusercontent.com/2951406/213647560-67da4f8a-3315-436f-b690-3b3de536d2e6.png) + +A list of extensions that can be installed is displayed: +![image](https://user-images.githubusercontent.com/2951406/213647904-d463775e-86b6-4354-a199-d50e08565092.png) + +> **Note** +> If the extension is installed from a GitHub repo, then the GitHub star count will be shown. + +Click the `Manage` button in order to install a particular release of the extension. +![image](https://user-images.githubusercontent.com/2951406/213648543-6c5c8cae-3bf4-447f-8499-344cac61c566.png) + +> **Note** +> An extension can be listed in more than one repository. The admin user must select which repository it wants to install from. + +Select the version to be installed (usually the last one) and click `Install`. One can also check the `Release Notes` first. + +> **Note**: +> +> For Github repository: the order of the releases is the one in the GitHub releases page +> +> For Explicit Release: the order of the releases is the one in the "extensions" object + + +The extension has been installed but it cannot be accessed yet. In order to activate the extension toggle it in the `Activated` state. + +Go to `Manage Extensions` (as admin user or regular user). Search for the extension and enable it. + + +## Uninstall Extension +On the `Install` page click `Manage` for the extension you want to uninstall: +![image](https://user-images.githubusercontent.com/2951406/213653194-32cbb1da-dcc8-43cf-8a82-1ec5d2d3dc16.png) + +The installed release is highlighted in green. + +Click the `Uninstall` button for the release or the one in the bottom. + +Users will no longer be able to access the extension. + +> **Note** +> The database for the extension is not removed. If the extension is re-installed later, the data will be accessible. + +## Manifest File +The manifest file is just a `JSON` file that lists a collection of extensions that can be installed. This file is of the form: + +```json +{ + "extensions": [...] + "repos": [...] +} +``` + +There are two ways to specify installable extensions: + +### Explicit Release +It goes under the `extensions` object and it is of the form: +```json + { + "id": "lnurlp", + "name": "LNURL Pay Links", + "version": 1, + "shortDescription": "Upgrade to version 111111111", + "icon": "receipt", + "details": "All charge names should be 111111111. API panel must show:
", + "archive": "https://github.com/lnbits/lnbits-extensions/raw/main/new/lnurlp/1/lnurlp.zip", + "hash": "a22d02de6bf306a7a504cd344e032cc6d48837a1d4aeb569a55a57507bf9a43a", + "htmlUrl": "https://github.com/lnbits/lnbits-extensions/tree/main/new/lnurlp/1", + "infoNotification": "This is a very old version" + } +``` + +
Fields Detailed Description + +| Field | Type | | Description | +|----------------------|---------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| id | string | mandatory | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. | +| name | string | mandatory | User friendly name for the extension. It will be displayed on the installation page. | +| version | string | mandatory | Version of this release. [Semantic versoning](https://semver.org/) is recommended. | +| shortDescription | string | optional | A few words about the extension. It will be displayed on the installation page. | +| icon | string | optional | quasar valid icon name | +| details | string (html) | optional | Details about this particular release | +| archive | string | mandatory | URL to the `zip` file that contains the extension source-code | +| hash | string | mandatory | The hash (`sha256`) of the `zip` file. The extension will not be installed if the hash is incorrect. | +| htmlUrl | string | optional | Link to the extension home page. | +| infoNotification | string | optional | Users that have this release installed will see a info message for their extension. For example if the extension support will be terminated soon. | +| criticalNotification | string | optional | Reserved for urgent notifications. The admin user will receive a message each time it visits the `Install` page. One example is if the extension has a critical bug. | + +
+ +This mode has the advantage of strictly specifying what releases of an extension can be installed. + +### GitHub Repository +It goes under the `repos` object and it is of the form: + +```json +{ + "id": "withdraw", + "organisation": "lnbits", + "repository": "withdraw-extension" +} +``` + +| Field | Type | Description | +|--------------|--------|-------------------------------------------------------| +| id | string | The ID of the extension. Must be unique for each extension. It is also used as the path in the URL. | +| organisation | string | The GitHub organisation (eg: `lnbits`) | +| repository | string | The GitHub repository name (eg: `withdraw-extension`) | + +The admin user will see all releases from the Github repository: + +![image](https://user-images.githubusercontent.com/2951406/213508934-11de5ae5-2045-471c-854b-94b6acbf4434.png) + + diff --git a/lnbits/app.py b/lnbits/app.py index 1b1292ce..ee3b0736 100644 --- a/lnbits/app.py +++ b/lnbits/app.py @@ -1,10 +1,14 @@ import asyncio +import glob import importlib import logging +import os +import shutil import signal import sys import traceback from http import HTTPStatus +from typing import Callable from fastapi import FastAPI, Request from fastapi.exceptions import HTTPException, RequestValidationError @@ -14,17 +18,24 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from loguru import logger +from lnbits.core.crud import get_installed_extensions +from lnbits.core.helpers import migrate_extension_database from lnbits.core.tasks import register_task_listeners from lnbits.settings import get_wallet_class, set_wallet_class, settings -from .commands import migrate_databases -from .core import core_app +from .commands import db_versions, load_disabled_extension_list, migrate_databases +from .core import core_app, core_app_extra from .core.services import check_admin_settings from .core.views.generic import core_html_routes +from .extension_manager import ( + Extension, + InstallableExtension, + InstalledExtensionMiddleware, + get_valid_extensions, +) from .helpers import ( get_css_vendored, get_js_vendored, - get_valid_extensions, template_renderer, url_for_vendored, ) @@ -65,6 +76,7 @@ def create_app() -> FastAPI: ) app.add_middleware(GZipMiddleware, minimum_size=1000) + app.add_middleware(InstalledExtensionMiddleware) register_startup(app) register_assets(app) @@ -72,6 +84,9 @@ def create_app() -> FastAPI: register_async_tasks(app) register_exception_handlers(app) + # Allow registering new extensions routes without direct access to the `app` object + setattr(core_app_extra, "register_new_ext_routes", register_new_ext_routes(app)) + return app @@ -105,6 +120,56 @@ async def check_funding_source() -> None: ) +async def check_installed_extensions(app: FastAPI): + """ + Check extensions that have been installed, but for some reason no longer present in the 'lnbits/extensions' directory. + One reason might be a docker-container that was re-created. + The 'data' directory (where the '.zip' files live) is expected to persist state. + Zips that are missing will be re-downloaded. + """ + shutil.rmtree(os.path.join("lnbits", "upgrades"), True) + await load_disabled_extension_list() + installed_extensions = await get_installed_extensions() + + for ext in installed_extensions: + try: + installed = check_installed_extension(ext) + if not installed: + await restore_installed_extension(app, ext) + logger.info(f"✔️ Successfully re-installed extension: {ext.id}") + except Exception as e: + logger.warning(e) + logger.warning(f"Failed to re-install extension: {ext.id}") + + +def check_installed_extension(ext: InstallableExtension) -> bool: + if ext.has_installed_version: + return True + + zip_files = glob.glob( + os.path.join(settings.lnbits_data_folder, "extensions", "*.zip") + ) + + if ext.zip_path not in zip_files: + ext.download_archive() + ext.extract_archive() + + return False + + +async def restore_installed_extension(app: FastAPI, ext: InstallableExtension): + extension = Extension.from_installable_ext(ext) + register_ext_routes(app, extension) + + current_version = (await db_versions()).get(ext.id, 0) + await migrate_extension_database(extension, current_version) + + # mount routes for the new version + core_app_extra.register_new_ext_routes(extension) + if extension.upgrade_hash: + ext.nofiy_upgrade() + + def register_routes(app: FastAPI) -> None: """Register FastAPI routes / LNbits extensions.""" app.include_router(core_app) @@ -112,20 +177,7 @@ def register_routes(app: FastAPI) -> None: for ext in get_valid_extensions(): try: - ext_module = importlib.import_module(f"lnbits.extensions.{ext.code}") - ext_route = getattr(ext_module, f"{ext.code}_ext") - - if hasattr(ext_module, f"{ext.code}_start"): - ext_start_func = getattr(ext_module, f"{ext.code}_start") - ext_start_func() - - if hasattr(ext_module, f"{ext.code}_static_files"): - ext_statics = getattr(ext_module, f"{ext.code}_static_files") - for s in ext_statics: - app.mount(s["path"], s["app"], s["name"]) - - logger.trace(f"adding route for extension {ext_module}") - app.include_router(ext_route) + register_ext_routes(app, ext) except Exception as e: logger.error(str(e)) raise ImportError( @@ -133,24 +185,58 @@ def register_routes(app: FastAPI) -> None: ) +def register_new_ext_routes(app: FastAPI) -> Callable: + # Returns a function that registers new routes for an extension. + # The returned function encapsulates (creates a closure around) the `app` object but does expose it. + def register_new_ext_routes_fn(ext: Extension): + register_ext_routes(app, ext) + + return register_new_ext_routes_fn + + +def register_ext_routes(app: FastAPI, ext: Extension) -> None: + """Register FastAPI routes for extension.""" + ext_module = importlib.import_module(ext.module_name) + + ext_route = getattr(ext_module, f"{ext.code}_ext") + + if hasattr(ext_module, f"{ext.code}_start"): + ext_start_func = getattr(ext_module, f"{ext.code}_start") + ext_start_func() + + if hasattr(ext_module, f"{ext.code}_static_files"): + ext_statics = getattr(ext_module, f"{ext.code}_static_files") + for s in ext_statics: + app.mount(s["path"], s["app"], s["name"]) + + logger.trace(f"adding route for extension {ext_module}") + + prefix = f"/upgrades/{ext.upgrade_hash}" if ext.upgrade_hash != "" else "" + app.include_router(router=ext_route, prefix=prefix) + + def register_startup(app: FastAPI): @app.on_event("startup") async def lnbits_startup(): try: - # 1. wait till migration is done + # wait till migration is done await migrate_databases() - # 2. setup admin settings + # setup admin settings await check_admin_settings() log_server_info() - # 3. initialize WALLET + # initialize WALLET set_wallet_class() - # 4. initialize funding source + # initialize funding source await check_funding_source() + + # check extensions after restart + await check_installed_extensions(app) + except Exception as e: logger.error(str(e)) raise ImportError("Failed to run 'startup' event.") diff --git a/lnbits/commands.py b/lnbits/commands.py index 82ea1430..50228112 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -1,7 +1,5 @@ import asyncio -import importlib import os -import re import warnings import click @@ -11,13 +9,11 @@ from lnbits.settings import settings from .core import db as core_db from .core import migrations as core_migrations +from .core.crud import get_dbversions, get_inactive_extensions +from .core.helpers import migrate_extension_database, run_migration from .db import COCKROACH, POSTGRES, SQLITE -from .helpers import ( - get_css_vendored, - get_js_vendored, - get_valid_extensions, - url_for_vendored, -) +from .extension_manager import get_valid_extensions +from .helpers import get_css_vendored, get_js_vendored, url_for_vendored @click.command("migrate") @@ -59,30 +55,6 @@ def bundle_vendored(): async def migrate_databases(): """Creates the necessary databases if they don't exist already; or migrates them.""" - async def set_migration_version(conn, db_name, version): - await conn.execute( - """ - INSERT INTO dbversions (db, version) VALUES (?, ?) - ON CONFLICT (db) DO UPDATE SET version = ? - """, - (db_name, version, version), - ) - - async def run_migration(db, migrations_module, db_name): - for key, migrate in migrations_module.__dict__.items(): - match = match = matcher.match(key) - if match: - version = int(match.group(1)) - if version > current_versions.get(db_name, 0): - logger.debug(f"running migration {db_name}.{version}") - await migrate(db) - - if db.schema == None: - await set_migration_version(db, db_name, version) - else: - async with core_db.connect() as conn: - await set_migration_version(conn, db_name, version) - async with core_db.connect() as conn: if conn.type == SQLITE: exists = await conn.fetchone( @@ -90,33 +62,30 @@ async def migrate_databases(): ) elif conn.type in {POSTGRES, COCKROACH}: exists = await conn.fetchone( - "SELECT * FROM information_schema.tables WHERE table_name = 'dbversions'" + "SELECT * FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'dbversions'" ) if not exists: await core_migrations.m000_create_migrations_table(conn) - rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall() - current_versions = {row["db"]: row["version"] for row in rows} - matcher = re.compile(r"^m(\d\d\d)_") - db_name = core_migrations.__name__.split(".")[-2] - await run_migration(conn, core_migrations, db_name) + current_versions = await get_dbversions(conn) + core_version = current_versions.get("core", 0) + await run_migration(conn, core_migrations, core_version) for ext in get_valid_extensions(): - try: - - module_str = ( - ext.migration_module or f"lnbits.extensions.{ext.code}.migrations" - ) - ext_migrations = importlib.import_module(module_str) - ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db - db_name = ext.db_name or module_str.split(".")[-2] - except ImportError: - raise ImportError( - f"Please make sure that the extension `{ext.code}` has a migrations file." - ) - - async with ext_db.connect() as ext_conn: - await run_migration(ext_conn, ext_migrations, db_name) + current_version = current_versions.get(ext.code, 0) + await migrate_extension_database(ext, current_version) logger.info("✔️ All migrations done.") + + +async def db_versions(): + async with core_db.connect() as conn: + current_versions = await get_dbversions(conn) + return current_versions + + +async def load_disabled_extension_list() -> None: + """Update list of extensions that have been explicitly disabled""" + inactive_extensions = await get_inactive_extensions() + settings.lnbits_deactivated_extensions += inactive_extensions diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py index dec15d78..75b6d587 100644 --- a/lnbits/core/__init__.py +++ b/lnbits/core/__init__.py @@ -1,11 +1,14 @@ from fastapi.routing import APIRouter +from lnbits.core.models import CoreAppExtra from lnbits.db import Database db = Database("database") core_app: APIRouter = APIRouter() +core_app_extra: CoreAppExtra = CoreAppExtra() + from .views.admin_api import * # noqa from .views.api import * # noqa from .views.generic import * # noqa diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py index e5d4424f..efb594a1 100644 --- a/lnbits/core/crud.py +++ b/lnbits/core/crud.py @@ -8,6 +8,7 @@ import shortuuid from lnbits import bolt11 from lnbits.db import COCKROACH, POSTGRES, Connection +from lnbits.extension_manager import InstallableExtension from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings from . import db @@ -68,6 +69,97 @@ async def get_user(user_id: str, conn: Optional[Connection] = None) -> Optional[ ) +# extensions +# ------- + + +async def add_installed_extension( + ext: InstallableExtension, + conn: Optional[Connection] = None, +) -> None: + meta = { + "installed_release": dict(ext.installed_release) + if ext.installed_release + else None, + "dependencies": ext.dependencies, + } + + version = ext.installed_release.version if ext.installed_release else "" + + await (conn or db).execute( + """ + INSERT INTO installed_extensions (id, version, name, short_description, icon, stars, meta) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (id) DO + UPDATE SET (version, name, active, short_description, icon, stars, meta) = (?, ?, ?, ?, ?, ?, ?) + """, + ( + ext.id, + version, + ext.name, + ext.short_description, + ext.icon, + ext.stars, + json.dumps(meta), + version, + ext.name, + False, + ext.short_description, + ext.icon, + ext.stars, + json.dumps(meta), + ), + ) + + +async def update_installed_extension_state( + *, ext_id: str, active: bool, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( + """ + UPDATE installed_extensions SET active = ? WHERE id = ? + """, + (active, ext_id), + ) + + +async def delete_installed_extension( + *, ext_id: str, conn: Optional[Connection] = None +) -> None: + await (conn or db).execute( + """ + DELETE from installed_extensions WHERE id = ? + """, + (ext_id,), + ) + + +async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None): + row = await (conn or db).fetchone( + "SELECT * FROM installed_extensions WHERE id = ?", + (ext_id,), + ) + + return dict(row) if row else None + + +async def get_installed_extensions( + conn: Optional[Connection] = None, +) -> List["InstallableExtension"]: + rows = await (conn or db).fetchall( + "SELECT * FROM installed_extensions", + (), + ) + return [InstallableExtension.from_row(row) for row in rows] + + +async def get_inactive_extensions(*, conn: Optional[Connection] = None) -> List[str]: + inactive_extensions = await (conn or db).fetchall( + """SELECT id FROM installed_extensions WHERE NOT active""", + (), + ) + return [ext[0] for ext in inactive_extensions] + + async def update_user_extension( *, user_id: str, extension: str, active: bool, conn: Optional[Connection] = None ) -> None: @@ -624,6 +716,23 @@ async def create_admin_settings(super_user: str, new_settings: dict): return await get_super_settings() +# db versions +# -------------- +async def get_dbversions(conn: Optional[Connection] = None): + rows = await (conn or db).fetchall("SELECT * FROM dbversions") + return {row["db"]: row["version"] for row in rows} + + +async def update_migration_version(conn, db_name, version): + await (conn or db).execute( + """ + INSERT INTO dbversions (db, version) VALUES (?, ?) + ON CONFLICT (db) DO UPDATE SET version = ? + """, + (db_name, version, version), + ) + + # tinyurl # ------- diff --git a/lnbits/core/helpers.py b/lnbits/core/helpers.py new file mode 100644 index 00000000..7d5c2920 --- /dev/null +++ b/lnbits/core/helpers.py @@ -0,0 +1,44 @@ +import importlib +import re +from typing import Any + +from loguru import logger + +from lnbits.db import Connection +from lnbits.extension_manager import Extension + +from . import db as core_db +from .crud import update_migration_version + + +async def migrate_extension_database(ext: Extension, current_version): + try: + ext_migrations = importlib.import_module(f"{ext.module_name}.migrations") + ext_db = importlib.import_module(ext.module_name).db + except ImportError as e: + logger.error(e) + raise ImportError( + f"Please make sure that the extension `{ext.code}` has a migrations file." + ) + + async with ext_db.connect() as ext_conn: + await run_migration(ext_conn, ext_migrations, current_version) + + +async def run_migration(db: Connection, migrations_module: Any, current_version: int): + matcher = re.compile(r"^m(\d\d\d)_") + db_name = migrations_module.__name__.split(".")[-2] + for key, migrate in migrations_module.__dict__.items(): + match = matcher.match(key) + if match: + version = int(match.group(1)) + if version > current_version: + logger.debug(f"running migration {db_name}.{version}") + print(f"running migration {db_name}.{version}") + await migrate(db) + + if db.schema == None: + await update_migration_version(db, db_name, version) + else: + async with core_db.connect() as conn: + await update_migration_version(conn, db_name, version) diff --git a/lnbits/core/migrations.py b/lnbits/core/migrations.py index ccbd7871..9ce3e51b 100644 --- a/lnbits/core/migrations.py +++ b/lnbits/core/migrations.py @@ -283,3 +283,20 @@ async def m009_create_tinyurl_table(db): ); """ ) + + +async def m010_create_installed_extensions_table(db): + await db.execute( + """ + CREATE TABLE IF NOT EXISTS installed_extensions ( + id TEXT PRIMARY KEY, + version TEXT NOT NULL, + name TEXT NOT NULL, + short_description TEXT, + icon TEXT, + stars INT NOT NULL DEFAULT 0, + active BOOLEAN DEFAULT false, + meta TEXT NOT NULL DEFAULT '{}' + ); + """ + ) diff --git a/lnbits/core/models.py b/lnbits/core/models.py index 4def937e..c0643af7 100644 --- a/lnbits/core/models.py +++ b/lnbits/core/models.py @@ -4,11 +4,10 @@ import hmac import json import time from sqlite3 import Row -from typing import Dict, List, Optional +from typing import Callable, Dict, List, Optional -from ecdsa import SECP256k1, SigningKey -from fastapi import Query -from lnurl import encode as lnurl_encode +from ecdsa import SECP256k1, SigningKey # type: ignore +from lnurl import encode as lnurl_encode # type: ignore from loguru import logger from pydantic import BaseModel @@ -215,6 +214,10 @@ class BalanceCheck(BaseModel): return cls(wallet=row["wallet"], service=row["service"], url=row["url"]) +class CoreAppExtra: + register_new_ext_routes: Callable + + class TinyURL(BaseModel): id: str url: str diff --git a/lnbits/core/services.py b/lnbits/core/services.py index ee750302..17d43644 100644 --- a/lnbits/core/services.py +++ b/lnbits/core/services.py @@ -22,6 +22,7 @@ from lnbits.settings import ( readonly_variables, send_admin_user_to_saas, settings, + transient_variables, ) from lnbits.wallets.base import PaymentResponse, PaymentStatus @@ -450,7 +451,7 @@ async def check_admin_settings(): def update_cached_settings(sets_dict: dict): for key, value in sets_dict.items(): - if not key in readonly_variables: + if not key in readonly_variables + transient_variables: try: setattr(settings, key, value) except: diff --git a/lnbits/core/static/extension.png b/lnbits/core/static/extension.png new file mode 100644 index 00000000..78378fd7 Binary files /dev/null and b/lnbits/core/static/extension.png differ diff --git a/lnbits/core/static/js/extensions.js b/lnbits/core/static/js/extensions.js index ec8f811c..ad9745e7 100644 --- a/lnbits/core/static/js/extensions.js +++ b/lnbits/core/static/js/extensions.js @@ -3,7 +3,9 @@ new Vue({ data: function () { return { searchTerm: '', - filteredExtensions: null + filteredExtensions: null, + maxStars: 5, + user: null } }, mounted() { @@ -32,5 +34,10 @@ new Vue({ } } }, + created() { + if (window.user) { + this.user = LNbits.map.user(window.user) + } + }, mixins: [windowMixin] }) diff --git a/lnbits/core/templates/admin/_tab_server.html b/lnbits/core/templates/admin/_tab_server.html index f234f182..09abb8a1 100644 --- a/lnbits/core/templates/admin/_tab_server.html +++ b/lnbits/core/templates/admin/_tab_server.html @@ -69,6 +69,61 @@
+
+
+

Admin Extensions

+ +
+
+
+

Disabled Extensions

+ +
+
+
+ +
+

Extension Sources

+ + + +
+ {%raw%} + + {{ manifestUrl }} + + {%endraw%} +
+
+
diff --git a/lnbits/core/templates/admin/_tab_users.html b/lnbits/core/templates/admin/_tab_users.html index c6a4b83e..46483c18 100644 --- a/lnbits/core/templates/admin/_tab_users.html +++ b/lnbits/core/templates/admin/_tab_users.html @@ -58,31 +58,6 @@
-
-
-

Admin Extensions

- -
-
-
-

Disabled Extensions

- -
-
-
+ diff --git a/lnbits/core/templates/admin/index.html b/lnbits/core/templates/admin/index.html index f4d5c601..239830e3 100644 --- a/lnbits/core/templates/admin/index.html +++ b/lnbits/core/templates/admin/index.html @@ -169,6 +169,7 @@ formData: {}, formAddAdmin: '', formAddUser: '', + formAddExtensionsManifest: '', isSuperUser: false, wallet: {}, cancel: {}, @@ -391,6 +392,18 @@ u => u !== user ) }, + addExtensionsManifest() { + const addManifest = this.formAddExtensionsManifest.trim() + const manifests = this.formData.lnbits_extensions_manifests + if (addManifest && addManifest.length && !manifests.includes(addManifest)) { + this.formData.lnbits_extensions_manifests = [...manifests, addManifest] + this.formAddExtensionsManifest = '' + } + }, + removeExtensionsManifest(manifest) { + const manifests = this.formData.lnbits_extensions_manifests + this.formData.lnbits_extensions_manifests = manifests.filter(m => m !== manifest) + }, restartServer() { LNbits.api .request('GET', '/admin/api/v1/restart/?usr=' + this.g.user.id) diff --git a/lnbits/core/templates/core/extensions.html b/lnbits/core/templates/core/extensions.html index 88e50269..bf41e276 100644 --- a/lnbits/core/templates/core/extensions.html +++ b/lnbits/core/templates/core/extensions.html @@ -3,7 +3,20 @@ {% endblock %} {% block page %}
-
+
+

+ Extensions + Add Extensions +

+
+ +
- +
+
+ + +
{% raw %} -
+
+ {{ extension.name }} +
+
{{ extension.name }}
+ +
+

Add Extensions

+
+ +
+ + + +
+
+ +
+
+ +
+
+ + + + + +
+
+
+
+
+
+
+ + +
+
+ +
+ + +
+
+
+ + New Version + + {% raw %} +
+ {{ extension.name }} +
+
+ {{ extension.name }} +
+
+ {{ extension.shortDescription }} +
+
+ {{ extension.name }} +
+
+ {{ extension.shortDescription }} +
+ {% endraw %} +
+
+
+
+ Depends on: +   + + + +
+
+
+ +
+ + Ratings coming soon +
+
+ + +
+
+
+ + Manage + +
+
+ +
+
+
+ +
+
+
+
+
+
+ + +
Warning
+

+ You are about to remove the extension for all users.
+ Are you sure you want to continue? +

+ +
+ Yes, Uninstall + Cancel +
+
+
+ + + + +
+
+
+ + + + + + + + + + + + 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 -
-
- - - - - {% 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