Merge pull request #1231 from lnbits/extension_install_02

Extension install 02
This commit is contained in:
Arc 2023-01-26 17:01:52 +00:00 committed by GitHub
commit 6846dd373f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1938 additions and 2098 deletions

View File

@ -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://...

5
.gitignore vendored
View File

@ -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/

View File

@ -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 <code>111111111</code>. API panel must show: <br>",
"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"
}
```
<details><summary>Fields Detailed Description</summary>
| 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. |
</details>
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)

View File

@ -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.")

View File

@ -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

View File

@ -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

View File

@ -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
# -------

44
lnbits/core/helpers.py Normal file
View File

@ -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)

View File

@ -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 '{}'
);
"""
)

View File

@ -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

View File

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -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]
})

View File

@ -69,6 +69,61 @@
<br />
</div>
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.name)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Disabled Extensions</p>
<q-select
filled
v-model="formData.lnbits_disabled_extensions"
:options="g.extensions.map(e => e.name)"
multiple
hint="Disable extensions *amilk disabled by default as resource heavy"
label="Disable extensions"
></q-select>
<br />
</div>
</div>
<div>
<p>Extension Sources</p>
<q-input
filled
v-model="formAddExtensionsManifest"
@keydown.enter="addExtensionsManifest"
type="text"
label="Source URL (only use the official LNbits extension source, and sources you can trust)"
hint="Repositories from where the extensions can be downloaded"
>
<q-btn @click="addExtensionsManifest" dense flat icon="add"></q-btn>
</q-input>
<div>
{%raw%}
<q-chip
v-for="manifestUrl in formData.lnbits_extensions_manifests"
:key="manifestUrl"
removable
@remove="removeExtensionsManifest(manifestUrl)"
color="primary"
text-color="white"
>
{{ manifestUrl }}
</q-chip>
{%endraw%}
</div>
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>

View File

@ -58,31 +58,6 @@
</div>
<br />
</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<p>Admin Extensions</p>
<q-select
filled
v-model="formData.lnbits_admin_extensions"
multiple
hint="Extensions only user with admin privileges can use"
label="Admin extensions"
:options="g.extensions.map(e => e.name)"
></q-select>
<br />
</div>
<div class="col-12 col-md-6">
<p>Disabled Extensions</p>
<q-select
filled
v-model="formData.lnbits_disabled_extensions"
:options="g.extensions.map(e => e.name)"
multiple
hint="Disable extensions *amilk disabled by default as resource heavy"
label="Disable extensions"
></q-select>
<br />
</div>
</div>
</q-card-section>
</q-tab-panel>

View File

@ -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)

View File

@ -3,7 +3,20 @@
<script src="/core/static/js/extensions.js"></script>
{% endblock %} {% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-sm-3 col-xs-8 q-ml-auto">
<div class="col-sm-9 gt-sm col-xs-12 mt-lg">
<p class="text-h4">
Extensions
<q-btn
flat
color="primary"
type="a"
:href="['/install?usr=', user.id].join('')"
>Add Extensions</q-btn
>
</p>
</div>
<div class="col-sm-3 col-xs-12 q-ml-auto">
<q-input v-model="searchTerm" label="Search extensions">
<q-icon
v-if="searchTerm !== ''"
@ -22,18 +35,39 @@
:key="extension.code"
>
<q-card>
<q-card-section>
<q-card-section style="min-height: 140px">
<div class="row">
<div class="col-3">
<q-img
v-if="extension.tile"
:src="extension.tile"
spinner-color="white"
style="max-width: 100%"
></q-img>
<div v-else>
<q-icon
class="gt-sm"
name="extension"
color="primary"
size="70px"
></q-icon>
<q-icon
class="lt-md"
name="extension"
color="primary"
size="35px"
></q-icon>
</div>
</div>
<div class="col-9 q-pl-sm">
{% raw %}
<div class="text-h5 gt-sm q-mt-sm q-mb-xs">
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
{{ extension.name }}
</div>
<div
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
style="min-height: 60px"
>
{{ extension.name }}
</div>
<div
@ -59,12 +93,14 @@
<div>
<q-rating
class="gt-sm"
v-model="maxStars"
disable
size="2em"
size="1.5em"
:max="5"
color="primary"
></q-rating
><q-rating
></q-rating>
<q-rating
v-model="maxStars"
class="lt-md"
size="1.5em"
:max="5"

View File

@ -0,0 +1,484 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {{ window_vars(user, extensions) }}{% block page %}
<div class="row q-col-gutter-md q-mb-md">
<div class="col-sm-9 col-xs-12">
<p class="text-h4 gt-sm">Add Extensions</p>
</div>
<div class="col-sm-3 col-xs-12 q-ml-auto">
<q-input v-model="searchTerm" label="Search extensions">
<q-icon
v-if="searchTerm !== ''"
name="close"
@click="searchTerm = ''"
class="cursor-pointer"
/>
</q-input>
</div>
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12">
<q-card>
<div class="q-pa-xs">
<div class="q-gutter-y-md">
<q-tabs
v-model="tab"
@input="handleTabChanged"
active-color="primary"
align="left"
>
<q-tab
name="featured"
label="Featured"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="all"
label="All"
@update="val => tab = val.name"
></q-tab>
<q-tab
name="installed"
label="Installed"
@update="val => tab = val.name"
></q-tab>
</q-tabs>
</div>
</div>
</q-card>
</div>
</div>
<div class="row q-col-gutter-md">
<div
class="col-6 col-md-4 col-lg-3"
v-for="extension in filteredExtensions"
:key="extension.id + extension.hash"
>
<q-card>
<q-card-section style="min-height: 140px">
<div class="row">
<div class="col-3">
<q-img
v-if="extension.icon"
:src="extension.icon"
spinner-color="white"
style="max-width: 100%"
></q-img>
<div v-else>
<q-icon
class="gt-sm"
name="extension"
color="primary"
size="70px"
></q-icon>
<q-icon
class="lt-md"
name="extension"
color="primary"
size="35px"
></q-icon>
</div>
</div>
<div class="col-9 q-pl-sm">
<q-badge
v-if="hasNewVersion(extension)"
color="green"
class="float-right"
>
<small>New Version</small>
</q-badge>
{% raw %}
<div class="text-h5 gt-sm q-mt-sm q-mb-xs gt-sm">
{{ extension.name }}
</div>
<div
class="text-h5 gt-sm q-mt-sm q-mb-xs lt-md"
style="min-height: 60px"
>
{{ extension.name }}
</div>
<div
class="text-subtitle2 gt-sm"
style="font-size: 11px; height: 34px"
>
{{ extension.shortDescription }}
</div>
<div class="text-subtitle1 lt-md q-mt-sm q-mb-xs">
{{ extension.name }}
</div>
<div
class="text-subtitle2 lt-md"
style="font-size: 9px; height: 34px"
>
{{ extension.shortDescription }}
</div>
{% endraw %}
</div>
</div>
<div class="row q-pt-sm">
<div class="col">
<small v-if="extension.dependencies?.length">Depends on:</small>
<small v-else>&nbsp;</small>
<q-badge
v-for="dep in extension.dependencies"
:key="dep"
color="orange"
>
<small v-text="dep"></small>
</q-badge>
</div>
</div>
</q-card-section>
<q-card-section>
<div>
<q-rating
class="gt-sm"
v-model="maxStars"
disable
size="1.5em"
:max="5"
color="primary"
></q-rating>
<q-rating
v-model="maxStars"
class="lt-md"
size="1.5em"
:max="5"
color="primary"
></q-rating
><q-tooltip>Ratings coming soon</q-tooltip>
</div>
</q-card-section>
<q-separator v-if="g.user.admin"></q-separator>
<q-card-actions>
<div class="col-10">
<div v-if="g.user.admin">
<div v-if="!extension.inProgress">
<q-btn @click="showUpgrade(extension)" flat color="primary">
Manage</q-btn
>
<q-toggle
v-if="extension.isAvailable && extension.isInstalled"
:label="extension.isActive ? 'Activated': 'Deactivated' "
color="secodary"
v-model="extension.isActive"
@input="toggleExtension(extension)"
></q-toggle>
</div>
<div v-else>
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</div>
</div>
<div class="col-2">
<div class="float-right"></div>
</div>
</q-card-actions>
</q-card>
</div>
<q-dialog v-model="showUninstallDialog">
<q-card class="q-pa-lg">
<h6 class="q-my-md text-primary">Warning</h6>
<p>
You are about to remove the extension for all users. <br />
Are you sure you want to continue?
</p>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="uninstallExtension()"
>Yes, Uninstall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showUpgradeDialog">
<q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section>
<div class="text-h6" v-text="selectedExtension?.name"></div>
</q-card-section>
<div class="col-12 col-md-5 q-gutter-y-md" v-if="selectedExtensionRepos">
<q-card
flat
bordered
class="my-card"
v-for="repoName of Object.keys(selectedExtensionRepos)"
:key="repoName"
>
<q-expansion-item
:key="repoName"
group="repos"
:caption="repoName"
:content-inset-level="0.5"
:default-opened="selectedExtensionRepos[repoName].isInstalled"
>
<template v-slot:header>
<q-item-section avatar>
<q-avatar
:icon="selectedExtensionRepos[repoName].isInstalled ? 'download_done': 'download'"
:text-color="selectedExtensionRepos[repoName].isInstalled ? 'green' : ''"
/>
</q-item-section>
<q-item-section>
<div class="row">
<div class="col-10">
Repository
<br />
<small v-text="repoName"></small>
</div>
<div class="col-2">
<!-- <div v-if="selectedExtension.stars" class="float-right">
<small v-text="selectedExtension.stars"> </small>
<q-rating
max="1"
v-model="maxStars"
size="1.5em"
color="yellow"
icon="star"
icon-selected="star"
readonly
no-dimming
>
</q-rating>
</div> -->
</div>
</div>
</q-item-section>
</template>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
<q-expansion-item
v-for="release of selectedExtensionRepos[repoName].releases"
:key="release.version"
group="releases"
:icon="release.isInstalled ? 'download_done' : 'download'"
:label="release.description"
:caption="release.version"
:content-inset-level="0.5"
:header-class="release.isInstalled ? 'text-green' : ''"
>
<q-card>
<q-card-section>
<q-btn
v-if="!release.isInstalled"
@click="installExtension(release)"
color="primary unelevated mt-lg pt-lg"
>Install</q-btn
>
<q-btn v-else @click="showUninstall()" flat color="red">
Uninstall</q-btn
>
<a
v-if="release.html_url"
class="text-secondary float-right"
:href="release.html_url"
target="_blank"
style="color: inherit"
>Release Notes</a
>
</q-card-section>
<div
v-if="release.details_html"
v-html="release.details_html"
></div>
<q-separator></q-separator> </q-card
></q-expansion-item>
</q-list>
</q-card-section>
</q-expansion-item>
</q-card>
</div>
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
<div class="row q-mt-lg">
<q-btn
v-if="selectedExtension?.isInstalled"
@click="showUninstall()"
flat
color="red"
>
Uninstall</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
new Vue({
el: '#vue',
data: function () {
return {
searchTerm: '',
tab: 'featured',
filteredExtensions: null,
showUninstallDialog: false,
showUpgradeDialog: false,
selectedExtension: null,
selectedExtensionRepos: null,
maxStars: 5
}
},
watch: {
searchTerm(term) {
this.filterExtensions(term, this.tab)
}
},
methods: {
handleTabChanged: function (tab) {
this.filterExtensions(this.searchTerm, tab)
},
filterExtensions: function (term, tab) {
// Filter the extensions list
function extensionNameContains(searchTerm) {
return function (extension) {
return (
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
extension.shortDescription
?.toLowerCase()
.includes(searchTerm.toLowerCase())
)
}
}
this.filteredExtensions = this.extensions
.filter(e => (tab === 'installed' ? e.isInstalled : true))
.filter(e => (tab === 'featured' ? e.isFeatured : true))
.filter(extensionNameContains(term))
},
installExtension: async function (release) {
const extension = this.selectedExtension
try {
extension.inProgress = true
this.showUpgradeDialog = false
await LNbits.api.request(
'POST',
`/api/v1/extension?usr=${this.g.user.id}`,
this.g.user.wallets[0].adminkey,
{
ext_id: extension.id,
archive: release.archive,
source_repo: release.source_repo
}
)
window.location.href = [
"{{ url_for('install.extensions') }}",
'?usr=',
this.g.user.id
].join('')
} catch (error) {
LNbits.utils.notifyApiError(error)
extension.inProgress = false
}
},
uninstallExtension: async function () {
const extension = this.selectedExtension
this.showUpgradeDialog = false
this.showUninstallDialog = false
try {
extension.inProgress = true
await LNbits.api.request(
'DELETE',
`/api/v1/extension/${extension.id}?usr=${this.g.user.id}`,
this.g.user.wallets[0].adminkey
)
window.location.href = [
"{{ url_for('install.extensions') }}",
'?usr=',
this.g.user.id
].join('')
} catch (error) {
LNbits.utils.notifyApiError(error)
extension.inProgress = false
}
},
toggleExtension: function (extension) {
const action = extension.isActive ? 'activate' : 'deactivate'
window.location.href = [
"{{ url_for('install.extensions') }}",
'?usr=',
this.g.user.id,
`&${action}=`,
extension.id
].join('')
},
showUninstall: function () {
this.showUpgradeDialog = false
this.showUninstallDialog = true
},
showUpgrade: async function (extension) {
this.selectedExtension = extension
this.showUpgradeDialog = true
this.selectedExtensionRepos = null
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/${extension.id}/releases?usr=${this.g.user.id}`,
this.g.user.wallets[0].adminkey
)
this.selectedExtensionRepos = data.reduce((repos, release) => {
repos[release.source_repo] = repos[release.source_repo] || {
releases: [],
isInstalled: false
}
release.isInstalled = this.isInstalledVersion(
this.selectedExtension,
release
)
if (release.isInstalled) {
repos[release.source_repo].isInstalled = true
}
repos[release.source_repo].releases.push(release)
return repos
}, {})
} catch (error) {
LNbits.utils.notifyApiError(error)
extension.inProgress = false
}
},
hasNewVersion: function (extension) {
if (extension.installedRelease && extension.latestRelease) {
return (
extension.installedRelease.version !==
extension.latestRelease.version
)
}
},
isInstalledVersion: function (extension, release) {
if (extension.installedRelease) {
return (
extension.installedRelease.source_repo === release.source_repo &&
extension.installedRelease.version === release.version
)
}
}
},
created: function () {
if (!this.g.user.admin) {
this.$q.notify({
timeout: 3000,
message: 'Only admin accounts can install extensions',
icon: null
})
}
this.extensions = JSON.parse('{{extensions | tojson | safe}}').map(e => ({
...e,
inProgress: false
}))
this.filteredExtensions = this.extensions.concat([])
},
mixins: [windowMixin]
})
</script>
{% endblock %}

View File

@ -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##################################

View File

@ -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
)

594
lnbits/extension_manager.py Normal file
View File

@ -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()

View File

@ -1,3 +0,0 @@
# StreamerCopilot
Tool to help streamers accept sats for tips

View File

@ -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))

View File

@ -1,8 +0,0 @@
{
"name": "Streamer Copilot",
"short_description": "Video tips/animations/webhooks",
"tile": "/copilot/static/bitcoin-streaming.png",
"contributors": [
"arcbtc"
]
}

View File

@ -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,))

View File

@ -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)

View File

@ -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"
)

View File

@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

View File

@ -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),
)

View File

@ -1,178 +0,0 @@
<q-card>
<q-card-section>
<p>
StreamerCopilot: get tips via static QR (lnurl-pay) and show an
animation<br />
<small>
Created by,
<a class="text-secondary" href="https://github.com/benarc"
>Ben Arc</a
></small
>
</p>
</q-card-section>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/copilot"></q-btn>
<q-expansion-item group="api" dense expand-separator label="Create copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">POST</span> /copilot/api/v1/copilot</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}copilot/api/v1/copilot -d
'{"title": &lt;string&gt;, "animation": &lt;string&gt;,
"show_message":&lt;string&gt;, "amount": &lt;integer&gt;,
"lnurl_title": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Update copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">PUT</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url
}}copilot/api/v1/copilot/&lt;copilot_id&gt; -d '{"title":
&lt;string&gt;, "animation": &lt;string&gt;,
"show_message":&lt;string&gt;, "amount": &lt;integer&gt;,
"lnurl_title": &lt;string&gt;}' -H "Content-type: application/json"
-H "X-Api-Key: {{user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilot">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}copilot/api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Get copilots">
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span> /copilot/api/v1/copilots</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">
Body (application/json)
</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;copilot_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}copilot/api/v1/copilots -H
"X-Api-Key: {{ user.wallets[0].inkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a pay link"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/copilot/api/v1/copilot/&lt;copilot_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}copilot/api/v1/copilot/&lt;copilot_id&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Trigger an animation"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-blue">GET</span>
/api/v1/copilot/ws/&lt;copilot_id&gt;/&lt;comment&gt;/&lt;data&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 200</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url
}}copilot/api/v1/copilot/ws/&lt;string, copilot_id&gt;/&lt;string,
comment&gt;/&lt;string, gif name&gt; -H "X-Api-Key: {{
user.wallets[0].adminkey }}"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-card>

View File

@ -1,305 +0,0 @@
{% extends "public.html" %} {% block page %}<q-page>
<video
autoplay="true"
id="videoScreen"
style="width: 100%"
class="fixed-bottom-right"
></video>
<video
autoplay="true"
id="videoCamera"
style="width: 100%"
class="fixed-bottom-right"
></video>
<img src="" style="width: 100%" id="animations" class="fixed-bottom-left" />
<div
v-if="copilot.lnurl_toggle == 1"
id="draggableqr"
class="rounded-borders"
style="
width: 250px;
background-color: white;
height: 300px;
margin-top: 10%;
"
>
<div class="col">
<qrcode
:value="'lightning:' + copilot.lnurl"
:options="{width:250}"
class="rounded-borders"
></qrcode>
<center class="absolute-bottom" style="color: black; font-size: 20px">
{% raw %}{{ copilot.lnurl_title }}{% endraw %}
</center>
</div>
</div>
<h2
id="draggableprice"
v-if="copilot.show_price != 0"
class="text-bold"
style="
margin: 60px 60px;
font-size: 110px;
text-shadow: 4px 8px 4px black;
color: white;
"
>
{% raw %}{{ price }}{% endraw %}
</h2>
<p
v-if="copilot.show_ack != 0"
class="fixed-top"
style="
font-size: 22px;
text-shadow: 2px 4px 1px black;
color: white;
padding-left: 40%;
"
>
Powered by LNbits/StreamerCopilot
</p>
</q-page>
{% endblock %} {% block scripts %}
<style>
body.body--dark .q-drawer,
body.body--dark .q-footer,
body.body--dark .q-header,
.q-drawer,
.q-footer,
.q-header {
display: none;
}
.q-page {
padding: 0px;
}
#draggableqr {
width: 250px;
height: 300px;
cursor: grab;
}
#draggableprice {
width: 550px;
height: 60px;
cursor: grab;
}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.js"></script>
<script src="https://code.jquery.com/ui/1.13.2/jquery-ui.js"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
price: '',
counter: 1,
colours: ['teal', 'purple', 'indigo', 'pink', 'green'],
copilot: {},
animQueue: [],
queue: false,
lnurl: ''
}
},
methods: {
showNotif: function (userMessage) {
var colour =
this.colours[Math.floor(Math.random() * this.colours.length)]
this.$q.notify({
color: colour,
icon: 'chat_bubble_outline',
html: true,
message: '<h4 style="color: white;">' + userMessage + '</h4>',
position: 'top-left',
timeout: 5000
})
},
openURL: function (url) {
return Quasar.utils.openURL(url)
},
initCamera() {
var video = document.querySelector('#videoCamera')
if (navigator.mediaDevices.getUserMedia) {
navigator.mediaDevices
.getUserMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
}
},
initScreenShare() {
var video = document.querySelector('#videoScreen')
navigator.mediaDevices
.getDisplayMedia({video: true})
.then(function (stream) {
video.srcObject = stream
})
.catch(function (err0r) {
console.log('Something went wrong!')
})
},
pushAnim(content) {
document.getElementById('animations').style.width = content[0]
document.getElementById('animations').src = content[1]
if (content[2] != 'none') {
self.showNotif(content[2])
}
setTimeout(function () {
document.getElementById('animations').src = ''
}, 5000)
},
launch() {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' +
self.copilot.id +
'/launching/rocket'
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
mounted() {
this.initCamera()
},
created: function () {
$(function () {
$('#draggableqr').draggable()
$('#draggableprice').draggable()
}),
(self = this)
self.copilot = JSON.parse(localStorage.getItem('copilot'))
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + self.copilot.id,
localStorage.getItem('inkey')
)
.then(function (response) {
self.copilot = response.data
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
this.connectionBitStamp = new WebSocket('wss://ws.bitstamp.net')
const obj = JSON.stringify({
event: 'bts:subscribe',
data: {channel: 'live_trades_' + self.copilot.show_price}
})
this.connectionBitStamp.onmessage = function (e) {
if (self.copilot.show_price) {
if (self.copilot.show_price == 'btcusd') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btceur') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(JSON.parse(e.data).data.price)
)
} else if (self.copilot.show_price == 'btcgbp') {
self.price = String(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'GBP'
}).format(JSON.parse(e.data).data.price)
)
}
}
}
this.connectionBitStamp.onopen = () => this.connectionBitStamp.send(obj)
const fetch = data =>
new Promise(resolve => setTimeout(resolve, 5000, this.pushAnim(data)))
const addTask = (() => {
let pending = Promise.resolve()
const run = async data => {
try {
await pending
} finally {
return fetch(data)
}
}
return data => (pending = run(data))
})()
if (location.protocol !== 'http:') {
localUrl =
'wss://' +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
self.copilot.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/api/v1/ws/' +
self.copilot.id
}
this.connection = new WebSocket(localUrl)
this.connection.onmessage = function (e) {
console.log(e)
res = e.data.split('-')
if (res[0] == 'rocket') {
addTask(['40%', '/copilot/static/rocket.gif', res[1]])
}
if (res[0] == 'face') {
addTask(['35%', '/copilot/static/face.gif', res[1]])
}
if (res[0] == 'bitcoin') {
addTask(['30%', '/copilot/static/bitcoin.gif', res[1]])
}
if (res[0] == 'confetti') {
addTask(['100%', '/copilot/static/confetti.gif', res[1]])
}
if (res[0] == 'martijn') {
addTask(['40%', '/copilot/static/martijn.gif', res[1]])
}
if (res[0] == 'rick') {
addTask(['40%', '/copilot/static/rick.gif', res[1]])
}
if (res[0] == 'true') {
document.getElementById('videoCamera').style.width = '20%'
self.initScreenShare()
}
if (res[0] == 'false') {
document.getElementById('videoCamera').style.width = '100%'
document.getElementById('videoScreen').src = null
}
}
this.connection.onopen = () => this.launch
}
})
</script>
{% endblock %}

View File

@ -1,660 +0,0 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCopilot.show = true"
>New copilot instance
</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Copilots</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
<q-btn flat color="grey" @click="exportcopilotCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
flat
dense
:data="CopilotLinks"
row-key="id"
:columns="CopilotsTable.columns"
:pagination.sync="CopilotsTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th style="width: 5%"></q-th>
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
<!-- <q-th auto-width></q-th> -->
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="apps"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotPanel(props.row.id)"
>
<q-tooltip> Panel </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
unelevated
dense
size="xs"
icon="face"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
@click="openCopilotCompose(props.row.id)"
>
<q-tooltip> Compose window </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="deleteCopilotLink(props.row.id)"
icon="cancel"
color="pink"
>
<q-tooltip> Delete copilot </q-tooltip>
</q-btn>
</q-td>
<q-td>
<q-btn
flat
dense
size="xs"
@click="openUpdateCopilotLink(props.row.id)"
icon="edit"
color="light-blue"
>
<q-tooltip> Edit copilot </q-tooltip>
</q-btn>
</q-td>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.value }}</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} StreamCopilot Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "copilot/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog
v-model="formDialogCopilot.show"
position="top"
@hide="closeFormDialog"
>
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCopilot" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.title"
type="text"
label="Title"
></q-input>
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.lnurl_toggle"
label="Include lnurl payment QR? (requires https)"
left-label
></q-checkbox>
</div>
<div v-if="formDialogCopilot.data.lnurl_toggle">
<q-checkbox
v-model="formDialogCopilot.data.show_message"
left-label
label="Show lnurl-pay messages? (supported by few wallets)"
></q-checkbox>
<q-select
filled
dense
emit-value
v-model="formDialogCopilot.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
></q-select>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 1"
>
<q-card>
<q-card-section>
<div class="row">
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation1"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1threshold"
type="number"
step="1"
label="From *sats (min. 10)"
:rules="[ val => val >= 10 || 'Please use minimum 10' ]"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation1webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 2 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation1threshold > 0"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation2"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation2threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation1threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation2webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Payment threshold 3 (Must be higher than last)"
>
<q-card>
<q-card-section>
<div
class="row"
v-if="formDialogCopilot.data.animation2threshold > formDialogCopilot.data.animation1threshold"
>
<div class="col">
<q-select
filled
dense
v-model.trim="formDialogCopilot.data.animation3"
:options="options"
label="Animation"
/>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model="formDialogCopilot.data.animation3threshold"
type="number"
step="1"
label="From *sats"
:min="formDialogCopilot.data.animation2threshold"
>
</q-input>
</div>
<div class="col q-pl-xs">
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.animation3webhook"
type="text"
label="Webhook"
>
</q-input>
</div>
</div>
</q-card-section>
</q-card>
</q-expansion-item>
<q-input
filled
dense
v-model.trim="formDialogCopilot.data.lnurl_title"
type="text"
max="1440"
label="Lnurl title (message with QR code)"
>
</q-input>
</div>
<div class="q-gutter-sm">
<q-select
filled
dense
style="width: 50%"
v-model.trim="formDialogCopilot.data.show_price"
:options="currencyOptions"
label="Show price"
/>
</div>
<div class="q-gutter-sm">
<div class="row">
<q-checkbox
v-model="formDialogCopilot.data.show_ack"
left-label
label="Show 'powered by LNbits'"
></q-checkbox>
</div>
</div>
<div class="row q-mt-lg">
<q-btn
v-if="formDialogCopilot.data.id"
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Update Copilot</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="
formDialogCopilot.data.title == ''"
type="submit"
>Create Copilot</q-btn
>
<q-btn @click="cancelCopilot" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
var mapCopilot = obj => {
obj._data = _.clone(obj)
obj.theTime = obj.time * 60 - (Date.now() / 1000 - obj.timestamp)
obj.time = obj.time + 'mins'
if (obj.time_elapsed) {
obj.date = 'Time elapsed'
} else {
obj.date = Quasar.utils.date.formatDate(
new Date((obj.theTime - 3600) * 1000),
'HH:mm:ss'
)
}
obj.displayComposeUrl = ['/copilot/cp/', obj.id].join('')
obj.displayPanelUrl = ['/copilot/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
filter: '',
CopilotLinks: [],
CopilotLinksObj: [],
CopilotsTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'id',
field: 'id'
},
{
name: 'lnurl_toggle',
align: 'left',
label: 'Show lnurl pay link',
field: 'lnurl_toggle'
},
{
name: 'title',
align: 'left',
label: 'title',
field: 'title'
},
{
name: 'amount_made',
align: 'left',
label: 'amount made',
field: 'amount_made'
}
],
pagination: {
rowsPerPage: 10
}
},
passedCopilot: {},
formDialog: {
show: false,
data: {}
},
formDialogCopilot: {
show: false,
data: {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
qrCodeDialog: {
show: false,
data: null
},
options: ['bitcoin', 'confetti', 'rocket', 'face', 'martijn', 'rick'],
currencyOptions: ['None', 'btcusd', 'btceur', 'btcgbp']
}
},
methods: {
cancelCopilot: function (data) {
var self = this
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
},
closeFormDialog: function () {
this.clearFormDialogCopilot()
this.formDialog.data = {
is_unique: false
}
},
sendFormDataCopilot: function () {
var self = this
if (self.formDialogCopilot.data.id) {
this.updateCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
} else {
console.log(self.g.user.wallets[0].adminkey)
console.log(self.formDialogCopilot.data)
this.createCopilot(
self.g.user.wallets[0].adminkey,
self.formDialogCopilot.data
)
}
},
createCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request('POST', '/copilot/api/v1/copilot', wallet, updatedData)
.then(function (response) {
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilots: function () {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot',
this.g.user.wallets[0].inkey
)
.then(function (response) {
if (response.data) {
self.CopilotLinks = response.data.map(mapCopilot)
}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
getCopilot: function (copilot_id) {
var self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/' + copilot_id,
this.g.user.wallets[0].inkey
)
.then(function (response) {
localStorage.setItem('copilot', JSON.stringify(response.data))
localStorage.setItem('inkey', self.g.user.wallets[0].inkey)
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
openCopilotCompose: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../copilot/cp/', '_blank', params)
},
openCopilotPanel: function (copilot_id) {
this.getCopilot(copilot_id)
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=300,height=450,left=10,top=400'
open('../copilot/pn/', '_blank', params)
},
deleteCopilotLink: function (copilotId) {
var self = this
var link = _.findWhere(this.CopilotLinks, {id: copilotId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/copilot/api/v1/copilot/' + copilotId,
self.g.user.wallets[0].adminkey
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === copilotId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
openUpdateCopilotLink: function (copilotId) {
var self = this
var copilot = _.findWhere(this.CopilotLinks, {id: copilotId})
self.formDialogCopilot.data = _.clone(copilot._data)
self.formDialogCopilot.show = true
},
updateCopilot: function (wallet, data) {
var self = this
var updatedData = {}
for (const property in data) {
if (data[property]) {
updatedData[property] = data[property]
}
if (property == 'animation1threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation2threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
if (property == 'animation3threshold' && data[property]) {
updatedData[property] = parseInt(data[property])
}
}
LNbits.api
.request(
'PUT',
'/copilot/api/v1/copilot/' + updatedData.id,
wallet,
updatedData
)
.then(function (response) {
self.CopilotLinks = _.reject(self.CopilotLinks, function (obj) {
return obj.id === updatedData.id
})
self.CopilotLinks.push(mapCopilot(response.data))
self.formDialogCopilot.show = false
self.clearFormDialogCopilot()
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
clearFormDialogCopilot() {
this.formDialogCopilot.data = {
lnurl_toggle: false,
show_message: false,
show_ack: false,
show_price: 'None',
title: ''
}
},
exportcopilotCSV: function () {
var self = this
LNbits.utils.exportCSV(self.CopilotsTable.columns, this.CopilotLinks)
}
},
created: function () {
var self = this
var getCopilots = this.getCopilots
getCopilots()
}
})
</script>
{% endblock %}

View File

@ -1,156 +0,0 @@
{% extends "public.html" %} {% block page %}
<div class="q-pa-sm" style="width: 240px; margin: 10px auto">
<q-card class="my-card">
<div class="column">
<div class="col">
<center>
<q-btn
flat
round
dense
@click="openCompose"
icon="face"
style="font-size: 60px"
></q-btn>
</center>
</div>
<center>
<div class="col" style="margin: 15px; font-size: 22px">
Title: {% raw %} {{ copilot.title }} {% endraw %}
</div>
</center>
<q-separator></q-separator>
<div class="col">
<div class="row">
<div class="col">
<q-btn
class="q-mt-sm q-ml-sm"
color="primary"
@click="fullscreenToggle"
label="Screen share"
size="sm"
>
</q-btn>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rocket')"
label="rocket"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('confetti')"
label="confetti"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('face')"
label="face"
size="sm"
/>
</div>
</div>
<div class="row q-pa-sm">
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('rick')"
label="rick"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('martijn')"
label="martijn"
size="sm"
/>
</div>
<div class="col">
<q-btn
style="width: 95%"
color="primary"
@click="animationBTN('bitcoin')"
label="bitcoin"
size="sm"
/>
</div>
</div>
</div>
</div>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data() {
return {
fullscreen_cam: true,
textareaModel: '',
iframe: '',
copilot: {}
}
},
methods: {
iframeChange: function (url) {
this.connection.send(String(url))
},
fullscreenToggle: function () {
self = this
self.animationBTN(String(this.fullscreen_cam))
if (this.fullscreen_cam) {
this.fullscreen_cam = false
} else {
this.fullscreen_cam = true
}
},
openCompose: function () {
let params =
'scrollbars=no, resizable=no,status=no,location=no,toolbar=no,menubar=no,width=1200,height=644,left=410,top=100'
open('../cp/', 'test', params)
},
animationBTN: function (name) {
self = this
LNbits.api
.request(
'GET',
'/copilot/api/v1/copilot/ws/' + self.copilot.id + '/none/' + name
)
.then(function (response1) {
self.$q.notify({
color: 'green',
message: 'Sent!'
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
})
}
},
created: function () {
self = this
self.copilot = JSON.parse(localStorage.getItem('copilot'))
}
})
</script>
{% endblock %}

View File

@ -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}
)

View File

@ -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 ""

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -137,7 +137,15 @@ Vue.component('lnbits-extension-list', {
<q-icon name="clear_all" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption">Manage extensions</q-item-label>
<q-item-label lines="1" class="text-caption">Extensions</q-item-label>
</q-item-section>
</q-item>
<q-item clickable tag="a" :href="['/install?usr=', user.id].join('')">
<q-item-section side>
<q-icon name="playlist_add" color="grey-5" size="md"></q-icon>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-caption">Add Extensions</q-item-label>
</q-item-section>
</q-item>
</q-list>

Binary file not shown.