mirror of
https://github.com/lnbits/lnbits-legend.git
synced 2025-02-25 23:21:21 +01:00
* feat: add empty dialog * feat: add `details_link` field for extension * feat: show info icon if `details_link` present * feat: add extension details endpoint * feat: first details page * feat: carousel working * feat: full screen * fix: layout * fix: repo site * fix: release icon * fix: repo link * feat: terms and conditions partial * chore: fix typing * fix: info icon layout * chore: add try-catch * feat: layout improvements * feat: add video link * fix: show terms and conditions * chore: code format * feat: add `details_link` * fix: github release details * feat: add close button * chore: code clean-up * chore: revert some changes * feat: i18n * chore: `make bundle` * chore: make bundle * feat: terms and conditions is a link now
518 lines
18 KiB
Python
518 lines
18 KiB
Python
import sys
|
|
from http import HTTPStatus
|
|
from typing import (
|
|
List,
|
|
Optional,
|
|
)
|
|
|
|
from bolt11 import decode as bolt11_decode
|
|
from fastapi import (
|
|
APIRouter,
|
|
Depends,
|
|
HTTPException,
|
|
)
|
|
from loguru import logger
|
|
|
|
from lnbits.core.db import core_app_extra
|
|
from lnbits.core.helpers import (
|
|
migrate_extension_database,
|
|
stop_extension_background_work,
|
|
)
|
|
from lnbits.core.models import (
|
|
SimpleStatus,
|
|
User,
|
|
)
|
|
from lnbits.core.services import check_transaction_status, create_invoice
|
|
from lnbits.decorators import (
|
|
check_access_token,
|
|
check_admin,
|
|
check_user_exists,
|
|
)
|
|
from lnbits.extension_manager import (
|
|
CreateExtension,
|
|
Extension,
|
|
ExtensionRelease,
|
|
InstallableExtension,
|
|
PayToEnableInfo,
|
|
ReleasePaymentInfo,
|
|
UserExtensionInfo,
|
|
fetch_github_release_config,
|
|
fetch_release_details,
|
|
fetch_release_payment_info,
|
|
get_valid_extensions,
|
|
)
|
|
from lnbits.settings import settings
|
|
|
|
from ..crud import (
|
|
add_installed_extension,
|
|
delete_dbversion,
|
|
delete_installed_extension,
|
|
drop_extension_db,
|
|
get_dbversions,
|
|
get_installed_extension,
|
|
get_installed_extensions,
|
|
get_user_extension,
|
|
update_extension_pay_to_enable,
|
|
update_installed_extension_state,
|
|
update_user_extension,
|
|
update_user_extension_extra,
|
|
)
|
|
|
|
extension_router = APIRouter(
|
|
tags=["Extension Managment"],
|
|
prefix="/api/v1/extension",
|
|
)
|
|
|
|
|
|
@extension_router.post("")
|
|
async def api_install_extension(
|
|
data: CreateExtension,
|
|
user: User = Depends(check_admin),
|
|
access_token: Optional[str] = Depends(check_access_token),
|
|
):
|
|
release = await InstallableExtension.get_extension_release(
|
|
data.ext_id, data.source_repo, data.archive, data.version
|
|
)
|
|
if not release:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND, detail="Release not found"
|
|
)
|
|
|
|
if not release.is_version_compatible:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
|
)
|
|
|
|
release.payment_hash = data.payment_hash
|
|
ext_info = InstallableExtension(
|
|
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
|
)
|
|
|
|
try:
|
|
installed_ext = await get_installed_extension(data.ext_id)
|
|
ext_info.payments = installed_ext.payments if installed_ext else []
|
|
|
|
await ext_info.download_archive()
|
|
|
|
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)
|
|
|
|
ext_info.active = True
|
|
await add_installed_extension(ext_info)
|
|
|
|
if extension.is_upgrade_extension:
|
|
# call stop while the old routes are still active
|
|
await stop_extension_background_work(data.ext_id, user.id, access_token)
|
|
|
|
# mount routes for the new version
|
|
core_app_extra.register_new_ext_routes(extension)
|
|
|
|
ext_info.notify_upgrade(extension.upgrade_hash)
|
|
settings.lnbits_deactivated_extensions.discard(data.ext_id)
|
|
|
|
return extension
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
ext_info.clean_extension_files()
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(
|
|
f"Failed to install extension {ext_info.id} "
|
|
f"({ext_info.installed_version})."
|
|
),
|
|
) from exc
|
|
|
|
|
|
@extension_router.get("/{ext_id}/details", dependencies=[Depends(check_user_exists)])
|
|
async def api_extension_details(
|
|
ext_id: str,
|
|
details_link: str,
|
|
):
|
|
|
|
try:
|
|
all_releases = await InstallableExtension.get_extension_releases(ext_id)
|
|
|
|
release = next(
|
|
(r for r in all_releases if r.details_link == details_link), None
|
|
)
|
|
assert release, "Details not found for release"
|
|
|
|
release_details = await fetch_release_details(details_link)
|
|
assert release_details, "Cannot fetch details for release"
|
|
release_details["icon"] = release.icon
|
|
release_details["repo"] = release.repo
|
|
return release_details
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
f"Failed to get details for extension {ext_id}.",
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/sell")
|
|
async def api_update_pay_to_enable(
|
|
ext_id: str,
|
|
data: PayToEnableInfo,
|
|
user: User = Depends(check_admin),
|
|
) -> SimpleStatus:
|
|
try:
|
|
assert (
|
|
data.wallet in user.wallet_ids
|
|
), "Wallet does not belong to this admin user."
|
|
await update_extension_pay_to_enable(ext_id, data)
|
|
return SimpleStatus(
|
|
success=True, message=f"Payment info updated for '{ext_id}' extension."
|
|
)
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(f"Failed to update pay to install data for extension '{ext_id}' "),
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/enable")
|
|
async def api_enable_extension(
|
|
ext_id: str, user: User = Depends(check_user_exists)
|
|
) -> SimpleStatus:
|
|
if ext_id not in [e.code for e in get_valid_extensions()]:
|
|
raise HTTPException(
|
|
HTTPStatus.NOT_FOUND, f"Extension '{ext_id}' doesn't exist."
|
|
)
|
|
try:
|
|
logger.info(f"Enabling extension: {ext_id}.")
|
|
ext = await get_installed_extension(ext_id)
|
|
assert ext, f"Extension '{ext_id}' is not installed."
|
|
assert ext.active, f"Extension '{ext_id}' is not activated."
|
|
|
|
if user.admin or not ext.requires_payment:
|
|
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' enabled.")
|
|
|
|
user_ext = await get_user_extension(user.id, ext_id)
|
|
if not (user_ext and user_ext.extra and user_ext.extra.payment_hash_to_enable):
|
|
raise HTTPException(
|
|
HTTPStatus.PAYMENT_REQUIRED, f"Extension '{ext_id}' requires payment."
|
|
)
|
|
|
|
if user_ext.is_paid:
|
|
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
|
return SimpleStatus(
|
|
success=True, message=f"Paid extension '{ext_id}' enabled."
|
|
)
|
|
|
|
assert (
|
|
ext.pay_to_enable and ext.pay_to_enable.wallet
|
|
), f"Extension '{ext_id}' is missing payment wallet."
|
|
|
|
payment_status = await check_transaction_status(
|
|
wallet_id=ext.pay_to_enable.wallet,
|
|
payment_hash=user_ext.extra.payment_hash_to_enable,
|
|
)
|
|
|
|
if not payment_status.paid:
|
|
raise HTTPException(
|
|
HTTPStatus.PAYMENT_REQUIRED,
|
|
f"Invoice generated but not paid for enabeling extension '{ext_id}'.",
|
|
)
|
|
|
|
user_ext.extra.paid_to_enable = True
|
|
await update_user_extension_extra(user.id, ext_id, user_ext.extra)
|
|
|
|
await update_user_extension(user_id=user.id, extension=ext_id, active=True)
|
|
return SimpleStatus(success=True, message=f"Paid extension '{ext_id}' enabled.")
|
|
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except HTTPException as exc:
|
|
raise exc from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(f"Failed to enable '{ext_id}' "),
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/disable")
|
|
async def api_disable_extension(
|
|
ext_id: str, user: User = Depends(check_user_exists)
|
|
) -> SimpleStatus:
|
|
if ext_id not in [e.code for e in get_valid_extensions()]:
|
|
raise HTTPException(
|
|
HTTPStatus.BAD_REQUEST, f"Extension '{ext_id}' doesn't exist."
|
|
)
|
|
try:
|
|
logger.info(f"Disabeling extension: {ext_id}.")
|
|
await update_user_extension(user_id=user.id, extension=ext_id, active=False)
|
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' disabled.")
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(f"Failed to disable '{ext_id}'."),
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/activate", dependencies=[Depends(check_admin)])
|
|
async def api_activate_extension(ext_id: str) -> SimpleStatus:
|
|
try:
|
|
logger.info(f"Activating extension: '{ext_id}'.")
|
|
|
|
all_extensions = get_valid_extensions()
|
|
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
|
# if extension never loaded (was deactivated on server startup)
|
|
if ext_id not in sys.modules.keys():
|
|
# run extension start-up routine
|
|
core_app_extra.register_new_ext_routes(ext)
|
|
|
|
settings.lnbits_deactivated_extensions.discard(ext_id)
|
|
|
|
await update_installed_extension_state(ext_id=ext_id, active=True)
|
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' activated.")
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(f"Failed to activate '{ext_id}'."),
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/deactivate", dependencies=[Depends(check_admin)])
|
|
async def api_deactivate_extension(ext_id: str) -> SimpleStatus:
|
|
try:
|
|
logger.info(f"Deactivating extension: '{ext_id}'.")
|
|
|
|
all_extensions = get_valid_extensions()
|
|
ext = next((e for e in all_extensions if e.code == ext_id), None)
|
|
assert ext, f"Extension '{ext_id}' doesn't exist."
|
|
|
|
settings.lnbits_deactivated_extensions.add(ext_id)
|
|
|
|
await update_installed_extension_state(ext_id=ext_id, active=False)
|
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' deactivated.")
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=(f"Failed to deactivate '{ext_id}'."),
|
|
) from exc
|
|
|
|
|
|
@extension_router.delete("/{ext_id}")
|
|
async def api_uninstall_extension(
|
|
ext_id: str,
|
|
user: User = Depends(check_admin),
|
|
access_token: Optional[str] = Depends(check_access_token),
|
|
) -> SimpleStatus:
|
|
installed_extensions = await get_installed_extensions()
|
|
|
|
extensions = [e for e in installed_extensions if e.id == ext_id]
|
|
if len(extensions) == 0:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.NOT_FOUND,
|
|
detail=f"Unknown extension id: {ext_id}",
|
|
)
|
|
|
|
# check that other extensions do not depend on this one
|
|
for valid_ext_id in [ext.code for ext in get_valid_extensions()]:
|
|
installed_ext = next(
|
|
(ext for ext in installed_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:
|
|
# call stop while the old routes are still active
|
|
await stop_extension_background_work(ext_id, user.id, access_token)
|
|
|
|
settings.lnbits_deactivated_extensions.add(ext_id)
|
|
|
|
for ext_info in extensions:
|
|
ext_info.clean_extension_files()
|
|
await delete_installed_extension(ext_id=ext_info.id)
|
|
|
|
logger.success(f"Extension '{ext_id}' uninstalled.")
|
|
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
|
) from exc
|
|
|
|
|
|
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
|
|
async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
|
|
try:
|
|
extension_releases: List[ExtensionRelease] = (
|
|
await InstallableExtension.get_extension_releases(ext_id)
|
|
)
|
|
|
|
installed_ext = await get_installed_extension(ext_id)
|
|
if not installed_ext:
|
|
return extension_releases
|
|
|
|
for release in extension_releases:
|
|
payment_info = installed_ext.find_existing_payment(release.pay_link)
|
|
if payment_info:
|
|
release.paid_sats = payment_info.amount
|
|
|
|
return extension_releases
|
|
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/invoice/install", dependencies=[Depends(check_admin)])
|
|
async def get_pay_to_install_invoice(
|
|
ext_id: str, data: CreateExtension
|
|
) -> ReleasePaymentInfo:
|
|
try:
|
|
assert (
|
|
ext_id == data.ext_id
|
|
), f"Wrong extension id. Expected {ext_id}, but got {data.ext_id}"
|
|
assert data.cost_sats, "A non-zero amount must be specified."
|
|
release = await InstallableExtension.get_extension_release(
|
|
data.ext_id, data.source_repo, data.archive, data.version
|
|
)
|
|
assert release, "Release not found."
|
|
assert release.pay_link, "Pay link not found for release."
|
|
|
|
payment_info = await fetch_release_payment_info(
|
|
release.pay_link, data.cost_sats
|
|
)
|
|
assert payment_info and payment_info.payment_request, "Cannot request invoice."
|
|
invoice = bolt11_decode(payment_info.payment_request)
|
|
|
|
assert invoice.amount_msat is not None, "Invoic amount is missing."
|
|
invoice_amount = int(invoice.amount_msat / 1000)
|
|
assert (
|
|
invoice_amount == data.cost_sats
|
|
), f"Wrong invoice amount: {invoice_amount}."
|
|
assert (
|
|
payment_info.payment_hash == invoice.payment_hash
|
|
), "Wrong invoice payment hash."
|
|
|
|
return payment_info
|
|
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice"
|
|
) from exc
|
|
|
|
|
|
@extension_router.put("/{ext_id}/invoice/enable")
|
|
async def get_pay_to_enable_invoice(
|
|
ext_id: str, data: PayToEnableInfo, user: User = Depends(check_user_exists)
|
|
):
|
|
try:
|
|
assert data.amount and data.amount > 0, "A non-zero amount must be specified."
|
|
|
|
ext = await get_installed_extension(ext_id)
|
|
assert ext, f"Extension '{ext_id}' not found."
|
|
assert ext.pay_to_enable, f"Payment Info not found for extension '{ext_id}'."
|
|
assert (
|
|
ext.pay_to_enable.required
|
|
), f"Payment not required for extension '{ext_id}'."
|
|
assert ext.pay_to_enable.wallet and ext.pay_to_enable.amount, (
|
|
f"Payment wallet or amount missing for extension '{ext_id}'."
|
|
"Please contact the administrator."
|
|
)
|
|
assert (
|
|
data.amount >= ext.pay_to_enable.amount
|
|
), f"Minimum amount is {ext.pay_to_enable.amount} sats."
|
|
|
|
payment_hash, payment_request = await create_invoice(
|
|
wallet_id=ext.pay_to_enable.wallet,
|
|
amount=data.amount,
|
|
memo=f"Enable '{ext.name}' extension.",
|
|
)
|
|
|
|
user_ext = await get_user_extension(user.id, ext_id)
|
|
user_ext_info = (
|
|
user_ext.extra if user_ext and user_ext.extra else UserExtensionInfo()
|
|
)
|
|
user_ext_info.payment_hash_to_enable = payment_hash
|
|
await update_user_extension_extra(user.id, ext_id, user_ext_info)
|
|
|
|
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
|
|
|
except AssertionError as exc:
|
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc
|
|
except Exception as exc:
|
|
logger.warning(exc)
|
|
raise HTTPException(
|
|
HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice."
|
|
) from exc
|
|
|
|
|
|
@extension_router.get(
|
|
"/release/{org}/{repo}/{tag_name}",
|
|
dependencies=[Depends(check_admin)],
|
|
)
|
|
async def get_extension_release(org: str, repo: str, tag_name: str):
|
|
try:
|
|
config = await fetch_github_release_config(org, repo, tag_name)
|
|
if not config:
|
|
return {}
|
|
|
|
return {
|
|
"min_lnbits_version": config.min_lnbits_version,
|
|
"is_version_compatible": config.is_version_compatible(),
|
|
"warning": config.warning,
|
|
}
|
|
except Exception as exc:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
|
|
) from exc
|
|
|
|
|
|
@extension_router.delete(
|
|
"/{ext_id}/db",
|
|
dependencies=[Depends(check_admin)],
|
|
)
|
|
async def delete_extension_db(ext_id: str):
|
|
try:
|
|
db_version = (await get_dbversions()).get(ext_id, None)
|
|
if not db_version:
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.BAD_REQUEST,
|
|
detail=f"Unknown extension id: {ext_id}",
|
|
)
|
|
await drop_extension_db(ext_id=ext_id)
|
|
await delete_dbversion(ext_id=ext_id)
|
|
logger.success(f"Database removed for extension '{ext_id}'")
|
|
return SimpleStatus(
|
|
success=True, message=f"DB deleted for '{ext_id}' extension."
|
|
)
|
|
except HTTPException as ex:
|
|
logger.error(ex)
|
|
raise ex
|
|
except Exception as exc:
|
|
logger.error(exc)
|
|
raise HTTPException(
|
|
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
detail=f"Cannot delete data for extension '{ext_id}'",
|
|
) from exc
|