[feat] Pay to enable extension (#2516)

* feat: add payment tab

* feat: add buttons

* feat: persist `pay to enable` changes

* fix: do not disable extension on upgrade

* fix: show releases tab first

* feat: extract `enableExtension` logic

* refactor: rename routes

* feat: show dialog for paying extension

* feat: create invoice to enable

* refactor: extract enable/disable extension logic

* feat: add extra info to UserExtensions

* feat: check payment for extension enable

* fix: parsing

* feat: admins must not pay

* fix: code checks

* fix: test

* refactor: extract extension activate/deactivate to the `api` side

* feat: add `get_user_extensions `

* feat: return explicit `requiresPayment`

* feat: add `isPaymentRequired` to extension list

* fix: `paid_to_enable` status

* fix: ui layout

* feat: show QR Code

* feat: wait for invoice to be paid

* test: removed deprecated test and dead code

* feat: add re-check button

* refactor: rename paths for endpoints

* feat: i18n

* feat: add `{"success": True}`

* test: fix listener

* fix: rebase errors

* chore: update bundle

* fix: return error status code for the HTML error pages

* fix: active extension loading from file system

* chore: temp commit

* fix: premature optimisation

* chore: make check

* refactor: remove extracted logic

* chore: code format

* fix: enable by default after install

* fix: use `discard` instead of `remove` for `set`

* chore: code format

* fix: better error code

* fix: check for stop function before invoking

* feat: check if the wallet belongs to the admin user

* refactor: return 402 Requires Payment

* chore: more typing

* chore: temp checkout different branch for tests

* fix: too much typing

* fix: remove try-except

* fix: typo

* fix: manual format

* fix: merge issue

* remove this line

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan 2024-05-28 14:07:33 +03:00 committed by GitHub
parent 7c68a02eee
commit d72cf40439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 785 additions and 189 deletions

View File

@ -16,7 +16,11 @@ from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from lnbits.core.crud import get_dbversions, get_installed_extensions from lnbits.core.crud import (
get_dbversions,
get_installed_extensions,
update_installed_extension_state,
)
from lnbits.core.helpers import migrate_extension_database from lnbits.core.helpers import migrate_extension_database
from lnbits.core.tasks import ( # watchdog_task from lnbits.core.tasks import ( # watchdog_task
killswitch_task, killswitch_task,
@ -42,7 +46,6 @@ from .core import init_core_routers
from .core.db import core_app_extra from .core.db import core_app_extra
from .core.services import check_admin_settings, check_webpush_settings from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import ( from .extension_manager import (
Extension, Extension,
InstallableExtension, InstallableExtension,

View File

@ -29,7 +29,6 @@ from .core.crud import (
delete_wallet_by_id, delete_wallet_by_id,
delete_wallet_payment, delete_wallet_payment,
get_dbversions, get_dbversions,
get_inactive_extensions,
get_installed_extension, get_installed_extension,
get_installed_extensions, get_installed_extensions,
get_payments, get_payments,
@ -154,6 +153,7 @@ async def migrate_databases():
# `installed_extensions` table has been created # `installed_extensions` table has been created
await load_disabled_extension_list() await load_disabled_extension_list()
# todo: revisit, use installed extensions
for ext in get_valid_extensions(False): for ext in get_valid_extensions(False):
current_version = current_versions.get(ext.code, 0) current_version = current_versions.get(ext.code, 0)
try: try:
@ -315,8 +315,8 @@ async def check_invalid_payments(
async def load_disabled_extension_list() -> None: async def load_disabled_extension_list() -> None:
"""Update list of extensions that have been explicitly disabled""" """Update list of extensions that have been explicitly disabled"""
inactive_extensions = await get_inactive_extensions() inactive_extensions = await get_installed_extensions(active=False)
settings.lnbits_deactivated_extensions.update(inactive_extensions) settings.lnbits_deactivated_extensions.update([e.id for e in inactive_extensions])
@extensions.command("list") @extensions.command("list")

View File

@ -7,7 +7,7 @@ from .views.auth_api import auth_router
from .views.extension_api import extension_router from .views.extension_api import extension_router
# this compat is needed for usermanager extension # this compat is needed for usermanager extension
from .views.generic import generic_router, update_user_extension from .views.generic import generic_router
from .views.node_api import node_router, public_node_router, super_node_router from .views.node_api import node_router, public_node_router, super_node_router
from .views.payment_api import payment_router from .views.payment_api import payment_router
from .views.public_api import public_router from .views.public_api import public_router

View File

@ -9,7 +9,12 @@ from passlib.context import CryptContext
from lnbits.core.db import db from lnbits.core.db import db
from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page from lnbits.db import DB_TYPE, SQLITE, Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension from lnbits.extension_manager import (
InstallableExtension,
PayToEnableInfo,
UserExtension,
UserExtensionInfo,
)
from lnbits.settings import ( from lnbits.settings import (
AdminSettings, AdminSettings,
EditableSettings, EditableSettings,
@ -364,6 +369,7 @@ async def add_installed_extension(
"installed_release": ( "installed_release": (
dict(ext.installed_release) if ext.installed_release else None dict(ext.installed_release) if ext.installed_release else None
), ),
"pay_to_enable": (dict(ext.pay_to_enable) if ext.pay_to_enable else None),
"dependencies": ext.dependencies, "dependencies": ext.dependencies,
"payments": [dict(p) for p in ext.payments] if ext.payments else None, "payments": [dict(p) for p in ext.payments] if ext.payments else None,
} }
@ -373,8 +379,8 @@ async def add_installed_extension(
await (conn or db).execute( await (conn or db).execute(
""" """
INSERT INTO installed_extensions INSERT INTO installed_extensions
(id, version, name, short_description, icon, stars, meta) (id, version, name, active, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(version, name, active, short_description, icon, stars, meta) = (version, name, active, short_description, icon, stars, meta) =
(?, ?, ?, ?, ?, ?, ?) (?, ?, ?, ?, ?, ?, ?)
""", """,
@ -382,13 +388,14 @@ async def add_installed_extension(
ext.id, ext.id,
version, version,
ext.name, ext.name,
ext.active,
ext.short_description, ext.short_description,
ext.icon, ext.icon,
ext.stars, ext.stars,
json.dumps(meta), json.dumps(meta),
version, version,
ext.name, ext.name,
False, ext.active,
ext.short_description, ext.short_description,
ext.icon, ext.icon,
ext.stars, ext.stars,
@ -408,6 +415,17 @@ async def update_installed_extension_state(
) )
async def update_extension_pay_to_enable(
ext_id: str, payment_info: PayToEnableInfo, conn: Optional[Connection] = None
) -> None:
ext = await get_installed_extension(ext_id, conn)
if not ext:
return
ext.pay_to_enable = payment_info
await add_installed_extension(ext, conn)
async def delete_installed_extension( async def delete_installed_extension(
*, ext_id: str, conn: Optional[Connection] = None *, ext_id: str, conn: Optional[Connection] = None
) -> None: ) -> None:
@ -450,21 +468,44 @@ async def get_installed_extension(
async def get_installed_extensions( async def get_installed_extensions(
active: Optional[bool] = None,
conn: Optional[Connection] = None, conn: Optional[Connection] = None,
) -> List["InstallableExtension"]: ) -> List["InstallableExtension"]:
rows = await (conn or db).fetchall( rows = await (conn or db).fetchall(
"SELECT * FROM installed_extensions", "SELECT * FROM installed_extensions",
(), (),
) )
return [InstallableExtension.from_row(row) for row in rows] all_extensions = [InstallableExtension.from_row(row) for row in rows]
if active is None:
return all_extensions
return [e for e in all_extensions if e.active == active]
async def get_inactive_extensions(*, conn: Optional[Connection] = None) -> List[str]: async def get_user_extension(
inactive_extensions = await (conn or db).fetchall( user_id: str, extension: str, conn: Optional[Connection] = None
"""SELECT id FROM installed_extensions WHERE NOT active""", ) -> Optional[UserExtension]:
(), row = await (conn or db).fetchone(
"""
SELECT extension, active, extra as _extra FROM extensions
WHERE "user" = ? AND extension = ?
""",
(user_id, extension),
) )
return [ext[0] for ext in inactive_extensions] return UserExtension.from_row(row) if row else None
async def get_user_extensions(
user_id: str, conn: Optional[Connection] = None
) -> List[UserExtension]:
rows = await (conn or db).fetchall(
"""
SELECT extension, active, extra as _extra FROM extensions
WHERE "user" = ?
""",
(user_id,),
)
return [UserExtension.from_row(row) for row in rows]
async def update_user_extension( async def update_user_extension(
@ -489,6 +530,22 @@ async def get_user_active_extensions_ids(
return [e[0] for e in rows] return [e[0] for e in rows]
async def update_user_extension_extra(
user_id: str,
extension: str,
extra: UserExtensionInfo,
conn: Optional[Connection] = None,
) -> None:
extra_json = json.dumps(dict(extra))
await (conn or db).execute(
"""
INSERT INTO extensions ("user", extension, extra) VALUES (?, ?, ?)
ON CONFLICT ("user", extension) DO UPDATE SET extra = ?
""",
(user_id, extension, extra_json, extra_json),
)
# wallets # wallets
# ------- # -------

View File

@ -77,7 +77,9 @@ async def _stop_extension_background_work(ext_id) -> bool:
stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None) stop_fn_name = next((fn for fn in stop_fns if hasattr(old_module, fn)), None)
assert stop_fn_name, "No stop function found for '{ext.module_name}'" assert stop_fn_name, "No stop function found for '{ext.module_name}'"
await getattr(old_module, stop_fn_name)() stop_fn = getattr(old_module, stop_fn_name)
if stop_fn:
await stop_fn()
logger.info(f"Stopped background work for extension '{ext.module_name}'.") logger.info(f"Stopped background work for extension '{ext.module_name}'.")
except Exception as ex: except Exception as ex:

View File

@ -513,3 +513,10 @@ async def m019_balances_view_based_on_wallets(db):
GROUP BY apipayments.wallet GROUP BY apipayments.wallet
""" """
) )
async def m020_add_column_column_to_user_extensions(db):
"""
Adds extra column to user extensions.
"""
await db.execute("ALTER TABLE extensions ADD COLUMN extra TEXT")

View File

@ -651,7 +651,7 @@ def fee_reserve_total(amount_msat: int, internal: bool = False) -> int:
async def send_payment_notification(wallet: Wallet, payment: Payment): async def send_payment_notification(wallet: Wallet, payment: Payment):
await websocket_updater( await websocket_updater(
wallet.id, wallet.inkey,
json.dumps( json.dumps(
{ {
"wallet_balance": wallet.balance, "wallet_balance": wallet.balance,
@ -660,6 +660,10 @@ async def send_payment_notification(wallet: Wallet, payment: Payment):
), ),
) )
await websocket_updater(
payment.payment_hash, json.dumps({"pending": payment.pending})
)
async def update_wallet_balance(wallet_id: str, amount: int): async def update_wallet_balance(wallet_id: str, amount: int):
payment_hash, _ = await create_invoice( payment_hash, _ = await create_invoice(

View File

@ -188,10 +188,7 @@
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled" v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat flat
color="grey-5" color="grey-5"
type="a" @click="disableExtension(extension)"
:href="['{{
url_for('install.extensions')
}}', '?disable=', extension.id].join('')"
:label="$t('disable')" :label="$t('disable')"
></q-btn> ></q-btn>
<q-badge <q-badge
@ -199,15 +196,13 @@
v-text="$t('admin_only')" v-text="$t('admin_only')"
> >
</q-badge> </q-badge>
<q-btn <q-btn
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)" v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
flat flat
color="primary" color="primary"
type="a" @click="enableExtensionForUser(extension)"
:href="['{{ :label="$t(extension.isPaymentRequired ? 'pay_to_enable': 'enable')"
url_for('install.extensions')
}}', '?enable=', extension.id].join('')"
:label="$t('enable')"
> >
<q-tooltip> <q-tooltip>
<span v-text="$t('enable_extension_details')"> <span v-text="$t('enable_extension_details')">
@ -215,7 +210,7 @@
></q-btn> ></q-btn>
<q-btn <q-btn
@click="showUpgrade(extension)" @click="showManageExtension(extension)"
flat flat
color="primary" color="primary"
v-if="g.user.admin" v-if="g.user.admin"
@ -313,7 +308,7 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showUpgradeDialog"> <q-dialog v-model="showManageExtensionDialog">
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card"> <q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
<q-card-section> <q-card-section>
<div v-if="selectedRelease.paymentRequest"> <div v-if="selectedRelease.paymentRequest">
@ -352,10 +347,30 @@
</div> </div>
</q-card> </q-card>
<q-card v-else class="q-pa-lg lnbits__dialog-card"> <q-card v-else class="q-pa-lg lnbits__dialog-card">
<q-card-section> <q-tabs
<div class="text-h6" v-text="selectedExtension?.name"></div> v-model="manageExtensionTab"
</q-card-section> active-color="primary"
<div class="col-12 col-md-5 q-gutter-y-md" v-if="selectedExtensionRepos"> align="justify"
>
<q-tab
name="releases"
:label="$t('releases')"
@update="val => manageExtensionTab = val.name"
></q-tab>
<q-tab
v-if="selectedExtension && selectedExtension.isInstalled"
name="sell"
:label="$t('sell')"
@update="val => manageExtensionTab = val.name"
></q-tab>
</q-tabs>
<div
v-show="manageExtensionTab === 'releases'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
v-if="selectedExtensionRepos"
>
<q-card <q-card
flat flat
bordered bordered
@ -463,7 +478,7 @@
emit-value emit-value
v-model="release.wallet" v-model="release.wallet"
:options="g.user.walletOptions" :options="g.user.walletOptions"
label="Wallet *" :label="$t('wallet_required')"
class="q-mt-sm" class="q-mt-sm"
> >
</q-select> </q-select>
@ -479,7 +494,7 @@
<q-btn <q-btn
unelevated unelevated
color="primary" color="primary"
@click="showQRCode(release)" @click="showInstallQRCode(release)"
class="q-mt-sm float-right" class="q-mt-sm float-right"
:label="$t('show_qr')" :label="$t('show_qr')"
></q-btn> ></q-btn>
@ -556,8 +571,6 @@
</q-card-section> </q-card-section>
</q-expansion-item> </q-expansion-item>
</q-card> </q-card>
</div>
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
v-if="selectedExtension?.isInstalled" v-if="selectedExtension?.isInstalled"
@ -581,6 +594,164 @@
v-text="$t('close')" v-text="$t('close')"
></q-btn> ></q-btn>
</div> </div>
</div>
<q-spinner v-else color="primary" size="2.55em"></q-spinner>
<div
v-if="selectedExtension"
v-show="manageExtensionTab === 'sell'"
class="col-12 col-md-5 q-gutter-y-md q-mt-md"
>
<q-toggle
v-model="selectedExtension.payToEnable.required"
:label="$t('sell_require')"
color="secondary"
style="max-height: 21px"
></q-toggle>
<q-select
v-if="selectedExtension.payToEnable.required"
filled
dense
emit-value
v-model="selectedExtension.payToEnable.wallet"
:options="g.user.walletOptions"
label="Wallet *"
class="q-mt-md"
></q-select>
<q-input
v-if="selectedExtension.payToEnable.required"
filled
dense
v-model.number="selectedExtension.payToEnable.amount"
:label="$t('amount_sats')"
type="number"
min="1"
class="q-mt-md"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
@click="updatePayToInstallData(selectedExtension)"
flat
color="green"
v-text="$t('update_payment')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showPayToEnableDialog">
<q-card v-if="selectedExtension" class="q-pa-md">
<q-card-section>
<p>
<span
v-text="$t('sell_info', {name: selectedExtension.name, amount: selectedExtension.payToEnable.amount})"
></span>
</p>
<p>
<span v-text="$t('already_paid_question')"></span>
<q-badge
@click="enableExtension(selectedExtension)"
color="primary"
class="cursor-pointer"
rounded
>
<strong> <span v-text="$t('recheck')"></span> </strong
></q-badge>
</p>
</q-card-section>
<q-card-section v-if="selectedExtension.payToEnable.showQRCode">
<div class="row q-mt-lg">
<div v-if="selectedExtension.payToEnable.paymentRequest" class="col">
<a
:href="'lightning:' + selectedExtension.payToEnable.paymentRequest"
>
<q-responsive :ratio="1" class="q-mx-xl">
<lnbits-qrcode
:value="'lightning:' + selectedExtension.payToEnable.paymentRequest.toUpperCase()"
></lnbits-qrcode>
</q-responsive>
</a>
</div>
<div v-else class="col">
<q-spinner color="primary" size="2.55em"></q-spinner>
</div>
</div>
<div class="row q-mt-lg">
<div class="col">
<q-btn
v-if="selectedExtension.payToEnable.paymentRequest"
outline
color="grey"
@click="copyText(selectedExtension.payToEnable.paymentRequest)"
:label="$t('copy_invoice')"
></q-btn>
</div>
<div class="col">
<q-btn
v-close-popup
flat
color="grey"
class="float-right q-ml-lg"
v-text="$t('close')"
></q-btn>
</div>
</div>
</q-card-section>
<q-card-section v-else>
<div class="row q-mt-lg">
<div class="col">
<div>
<q-input
filled
dense
type="number"
v-model.number="selectedExtension.payToEnable.paidAmount"
:min="selectedExtension.payToEnable.amount"
suffix="sat"
class="q-mt-sm"
>
</q-input>
<q-select
filled
dense
v-model="selectedExtension.payToEnable.paymentWallet"
emit-value
:options="g.user.walletOptions"
:label="$t('wallet_required')"
class="q-mt-sm"
>
</q-select>
<q-separator class="q-mb-lg"></q-separator>
<q-btn
unelevated
color="primary"
class="q-mt-sm"
@click="payAndEnable(selectedExtension)"
:disabled="!selectedExtension.payToEnable.paymentWallet"
:label="$t('pay_from_wallet')"
></q-btn>
<q-btn
unelevated
@click="showEnableQRCode(selectedExtension)"
color="primary"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
</div>
</div>
</div>
</q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
</div> </div>
@ -592,10 +763,12 @@
return { return {
searchTerm: '', searchTerm: '',
tab: 'all', tab: 'all',
manageExtensionTab: 'releases',
filteredExtensions: null, filteredExtensions: null,
showUninstallDialog: false, showUninstallDialog: false,
showUpgradeDialog: false, showManageExtensionDialog: false,
showDropDbDialog: false, showDropDbDialog: false,
showPayToEnableDialog: false,
dropDbExtensionId: '', dropDbExtensionId: '',
selectedExtension: null, selectedExtension: null,
selectedExtensionRepos: null, selectedExtensionRepos: null,
@ -649,7 +822,7 @@
const extension = this.selectedExtension const extension = this.selectedExtension
extension.inProgress = true extension.inProgress = true
this.showUpgradeDialog = false this.showManageExtensionDialog = false
release.payment_hash = release.payment_hash =
release.payment_hash || this.getPaylinkHash(release.pay_link) release.payment_hash || this.getPaylinkHash(release.pay_link)
@ -684,7 +857,7 @@
}, },
uninstallExtension: async function () { uninstallExtension: async function () {
const extension = this.selectedExtension const extension = this.selectedExtension
this.showUpgradeDialog = false this.showManageExtensionDialog = false
this.showUninstallDialog = false this.showUninstallDialog = false
extension.inProgress = true extension.inProgress = true
LNbits.api LNbits.api
@ -717,7 +890,7 @@
dropExtensionDb: async function () { dropExtensionDb: async function () {
const extension = this.selectedExtension const extension = this.selectedExtension
this.showUpgradeDialog = false this.showManageExtensionDialog = false
this.showDropDbDialog = false this.showDropDbDialog = false
this.dropDbExtensionId = '' this.dropDbExtensionId = ''
extension.inProgress = true extension.inProgress = true
@ -745,14 +918,96 @@
const action = extension.isActive ? 'activate' : 'deactivate' const action = extension.isActive ? 'activate' : 'deactivate'
LNbits.api LNbits.api
.request( .request(
'GET', 'PUT',
"{{ url_for('install.extensions') }}" + `/api/v1/extension/${extension.id}/${action}`,
'?' + this.g.user.wallets[0].adminkey
action +
'=' +
extension.id
) )
.then(response => {}) .then(response => {
this.$q.notify({
type: 'positive',
message: `Extension '${extension.id}' ${action}d!`
})
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.inProgress = false
})
},
enableExtensionForUser: function (extension) {
if (extension.isPaymentRequired) {
this.showPayToEnable(extension)
return
}
this.enableExtension(extension)
},
enableExtension: function (extension) {
LNbits.api
.request(
'PUT',
`/api/v1/extension/${extension.id}/enable`,
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
type: 'positive',
message: 'Extension enabled!'
})
setTimeout(() => {
window.location.reload()
}, 300)
})
.catch(err => {
console.warn(err)
LNbits.utils.notifyApiError(err)
})
},
disableExtension: function (extension) {
LNbits.api
.request(
'PUT',
`/api/v1/extension/${extension.id}/disable`,
this.g.user.wallets[0].adminkey
)
.then(response => {
this.$q.notify({
type: 'positive',
message: 'Extension disabled!'
})
setTimeout(() => {
window.location.reload()
}, 300)
})
.catch(err => {
console.warn(error)
LNbits.utils.notifyApiError(err)
})
},
showPayToEnable: function (extension) {
this.selectedExtension = extension
this.selectedExtension.payToEnable.paidAmount =
extension.payToEnable.amount
this.selectedExtension.payToEnable.showQRCode = false
this.showPayToEnableDialog = true
},
updatePayToInstallData: function (extension) {
LNbits.api
.request(
'PUT',
`/api/v1/extension/${extension.id}/sell`,
this.g.user.wallets[0].adminkey,
{
required: extension.payToEnable.required,
amount: extension.payToEnable.amount,
wallet: extension.payToEnable.wallet
}
)
.then(response => {
this.$q.notify({
type: 'positive',
message: 'Payment info updated!'
})
this.showManageExtensionDialog = false
})
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
extension.inProgress = false extension.inProgress = false
@ -760,7 +1015,7 @@
}, },
showUninstall: function () { showUninstall: function () {
this.showUpgradeDialog = false this.showManageExtensionDialog = false
this.showUninstallDialog = true this.showUninstallDialog = true
this.uninstallAndDropDb = false this.uninstallAndDropDb = false
}, },
@ -769,11 +1024,12 @@
this.showDropDbDialog = true this.showDropDbDialog = true
}, },
showUpgrade: async function (extension) { showManageExtension: async function (extension) {
this.selectedExtension = extension this.selectedExtension = extension
this.selectedRelease = null this.selectedRelease = null
this.selectedExtensionRepos = null this.selectedExtensionRepos = null
this.showUpgradeDialog = true this.manageExtensionTab = 'releases'
this.showManageExtensionDialog = true
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
@ -816,8 +1072,8 @@
async payAndInstall(release) { async payAndInstall(release) {
try { try {
this.selectedExtension.inProgress = true this.selectedExtension.inProgress = true
this.showUpgradeDialog = false this.showManageExtensionDialog = false
const paymentInfo = await this.requestPayment( const paymentInfo = await this.requestPaymentForInstall(
this.selectedExtension.id, this.selectedExtension.id,
release release
) )
@ -838,11 +1094,32 @@
this.selectedExtension.inProgress = false this.selectedExtension.inProgress = false
} }
}, },
async showQRCode(release) { async payAndEnable(extension) {
try {
const paymentInfo = await this.requestPaymentForEnable(
extension.id,
extension.payToEnable.paidAmount
)
const wallet = this.g.user.wallets.find(
w => w.id === extension.payToEnable.paymentWallet
)
const {data} = await LNbits.api.payInvoice(
wallet,
paymentInfo.payment_request
)
this.enableExtension(extension)
this.showPayToEnableDialog = false
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
}
},
async showInstallQRCode(release) {
this.selectedRelease = release this.selectedRelease = release
try { try {
const data = await this.requestPayment( const data = await this.requestPaymentForInstall(
this.selectedExtension.id, this.selectedExtension.id,
release release
) )
@ -865,10 +1142,44 @@
} }
}, },
async requestPayment(extId, release) { async showEnableQRCode(extension) {
try {
extension.payToEnable.showQRCode = true
this.selectedExtension = _.clone(extension)
const data = await this.requestPaymentForEnable(
extension.id,
extension.payToEnable.paidAmount
)
extension.payToEnable.paymentRequest = data.payment_request
this.selectedExtension = _.clone(extension)
const url = new URL(window.location)
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
url.pathname = `/api/v1/ws/${data.payment_hash}`
const ws = new WebSocket(url)
ws.addEventListener('message', async ({data}) => {
const payment = JSON.parse(data)
if (payment.pending === false) {
this.$q.notify({
type: 'positive',
message: 'Invoice Paid!'
})
this.enableExtension(extension)
ws.close()
}
})
} catch (err) {
console.warn(err)
LNbits.utils.notifyApiError(err)
}
},
async requestPaymentForInstall(extId, release) {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
`/api/v1/extension/invoice`, `/api/v1/extension/${extId}/invoice/install`,
this.g.user.wallets[0].adminkey, this.g.user.wallets[0].adminkey,
{ {
ext_id: extId, ext_id: extId,
@ -881,6 +1192,18 @@
return data return data
}, },
async requestPaymentForEnable(extId, amount) {
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/${extId}/invoice/enable`,
this.g.user.wallets[0].adminkey,
{
amount
}
)
return data
},
clearHangingInvoice(release) { clearHangingInvoice(release) {
this.forgetPaylinkHash(release.pay_link) this.forgetPaylinkHash(release.pay_link)
release.payment_hash = null release.payment_hash = null

View File

@ -1,3 +1,4 @@
import sys
from http import HTTPStatus from http import HTTPStatus
from typing import ( from typing import (
List, List,
@ -18,17 +19,23 @@ from lnbits.core.helpers import (
stop_extension_background_work, stop_extension_background_work,
) )
from lnbits.core.models import ( from lnbits.core.models import (
SimpleStatus,
User, User,
) )
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import ( from lnbits.decorators import (
check_access_token, check_access_token,
check_admin, check_admin,
check_user_exists,
) )
from lnbits.extension_manager import ( from lnbits.extension_manager import (
CreateExtension, CreateExtension,
Extension, Extension,
ExtensionRelease, ExtensionRelease,
InstallableExtension, InstallableExtension,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
fetch_github_release_config, fetch_github_release_config,
fetch_release_payment_info, fetch_release_payment_info,
get_valid_extensions, get_valid_extensions,
@ -43,6 +50,11 @@ from ..crud import (
get_dbversions, get_dbversions,
get_installed_extension, get_installed_extension,
get_installed_extensions, 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( extension_router = APIRouter(
@ -88,18 +100,18 @@ async def api_install_extension(
db_version = (await get_dbversions()).get(data.ext_id, 0) db_version = (await get_dbversions()).get(data.ext_id, 0)
await migrate_extension_database(extension, db_version) await migrate_extension_database(extension, db_version)
ext_info.active = True
await add_installed_extension(ext_info) await add_installed_extension(ext_info)
if extension.is_upgrade_extension: if extension.is_upgrade_extension:
# call stop while the old routes are still active # call stop while the old routes are still active
await stop_extension_background_work(data.ext_id, user.id, access_token) await stop_extension_background_work(data.ext_id, user.id, access_token)
settings.lnbits_deactivated_extensions.add(data.ext_id)
# mount routes for the new version # mount routes for the new version
core_app_extra.register_new_ext_routes(extension) core_app_extra.register_new_ext_routes(extension)
ext_info.notify_upgrade(extension.upgrade_hash) ext_info.notify_upgrade(extension.upgrade_hash)
settings.lnbits_deactivated_extensions.discard(data.ext_id)
return extension return extension
except AssertionError as exc: except AssertionError as exc:
@ -116,18 +128,171 @@ async def api_install_extension(
) from exc ) 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}") @extension_router.delete("/{ext_id}")
async def api_uninstall_extension( async def api_uninstall_extension(
ext_id: str, ext_id: str,
user: User = Depends(check_admin), user: User = Depends(check_admin),
access_token: Optional[str] = Depends(check_access_token), access_token: Optional[str] = Depends(check_access_token),
): ) -> SimpleStatus:
installed_extensions = await get_installed_extensions() installed_extensions = await get_installed_extensions()
extensions = [e for e in installed_extensions if e.id == ext_id] extensions = [e for e in installed_extensions if e.id == ext_id]
if len(extensions) == 0: if len(extensions) == 0:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, status_code=HTTPStatus.NOT_FOUND,
detail=f"Unknown extension id: {ext_id}", detail=f"Unknown extension id: {ext_id}",
) )
@ -156,6 +321,7 @@ async def api_uninstall_extension(
await delete_installed_extension(ext_id=ext_info.id) await delete_installed_extension(ext_id=ext_info.id)
logger.success(f"Extension '{ext_id}' uninstalled.") logger.success(f"Extension '{ext_id}' uninstalled.")
return SimpleStatus(success=True, message=f"Extension '{ext_id}' uninstalled.")
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc) status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
@ -163,7 +329,7 @@ async def api_uninstall_extension(
@extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)]) @extension_router.get("/{ext_id}/releases", dependencies=[Depends(check_admin)])
async def get_extension_releases(ext_id: str): async def get_extension_releases(ext_id: str) -> List[ExtensionRelease]:
try: try:
extension_releases: List[ExtensionRelease] = ( extension_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id) await InstallableExtension.get_extension_releases(ext_id)
@ -186,30 +352,35 @@ async def get_extension_releases(ext_id: str):
) from exc ) from exc
@extension_router.put("/invoice", dependencies=[Depends(check_admin)]) @extension_router.put("/{ext_id}/invoice/install", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension): async def get_pay_to_install_invoice(
ext_id: str, data: CreateExtension
) -> ReleasePaymentInfo:
try: try:
assert data.cost_sats, "A non-zero amount must be specified" 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( release = await InstallableExtension.get_extension_release(
data.ext_id, data.source_repo, data.archive, data.version data.ext_id, data.source_repo, data.archive, data.version
) )
assert release, "Release not found" assert release, "Release not found."
assert release.pay_link, "Pay link not found for release" assert release.pay_link, "Pay link not found for release."
payment_info = await fetch_release_payment_info( payment_info = await fetch_release_payment_info(
release.pay_link, data.cost_sats release.pay_link, data.cost_sats
) )
assert payment_info and payment_info.payment_request, "Cannot request invoice" assert payment_info and payment_info.payment_request, "Cannot request invoice."
invoice = bolt11_decode(payment_info.payment_request) invoice = bolt11_decode(payment_info.payment_request)
assert invoice.amount_msat is not None, "Invoic amount is missing" assert invoice.amount_msat is not None, "Invoic amount is missing."
invoice_amount = int(invoice.amount_msat / 1000) invoice_amount = int(invoice.amount_msat / 1000)
assert ( assert (
invoice_amount == data.cost_sats invoice_amount == data.cost_sats
), f"Wrong invoice amount: {invoice_amount}." ), f"Wrong invoice amount: {invoice_amount}."
assert ( assert (
payment_info.payment_hash == invoice.payment_hash payment_info.payment_hash == invoice.payment_hash
), "Wroong invoice payment hash" ), "Wrong invoice payment hash."
return payment_info return payment_info
@ -222,6 +393,51 @@ async def get_extension_invoice(data: CreateExtension):
) from exc ) 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( @extension_router.get(
"/release/{org}/{repo}/{tag_name}", "/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)], dependencies=[Depends(check_admin)],
@ -258,6 +474,9 @@ async def delete_extension_db(ext_id: str):
await drop_extension_db(ext_id=ext_id) await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id) await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{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: except HTTPException as ex:
logger.error(ex) logger.error(ex)
raise ex raise ex

View File

@ -1,4 +1,3 @@
import sys
from http import HTTPStatus from http import HTTPStatus
from pathlib import Path from pathlib import Path
from typing import Annotated, List, Optional, Union from typing import Annotated, List, Optional, Union
@ -11,7 +10,6 @@ from fastapi.routing import APIRouter
from loguru import logger from loguru import logger
from pydantic.types import UUID4 from pydantic.types import UUID4
from lnbits.core.db import core_app_extra
from lnbits.core.helpers import to_valid_user_id from lnbits.core.helpers import to_valid_user_id
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_admin, check_user_exists from lnbits.decorators import check_admin, check_user_exists
@ -24,11 +22,8 @@ from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import ( from ..crud import (
create_wallet, create_wallet,
get_dbversions, get_dbversions,
get_inactive_extensions,
get_installed_extensions, get_installed_extensions,
get_user, get_user,
update_installed_extension_state,
update_user_extension,
) )
generic_router = APIRouter( generic_router = APIRouter(
@ -73,19 +68,8 @@ async def robots():
return HTMLResponse(content=data, media_type="text/plain") return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get( @generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
"/extensions", name="install.extensions", response_class=HTMLResponse async def extensions(request: Request, user: User = Depends(check_user_exists)):
)
async def extensions_install(
request: Request,
user: User = Depends(check_user_exists),
activate: str = Query(None),
deactivate: str = Query(None),
enable: str = Query(None),
disable: str = Query(None),
):
await toggle_extension(enable, disable, user.id)
try: try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions() installed_exts: List["InstallableExtension"] = await get_installed_extensions()
installed_exts_ids = [e.id for e in installed_exts] installed_exts_ids = [e.id for e in installed_exts]
@ -100,6 +84,11 @@ async def extensions_install(
installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None) installed_ext = next((ie for ie in installed_exts if e.id == ie.id), None)
if installed_ext: if installed_ext:
e.installed_release = installed_ext.installed_release e.installed_release = installed_ext.installed_release
if installed_ext.pay_to_enable and not user.admin:
# not a security leak, but better not to share the wallet id
installed_ext.pay_to_enable.wallet = None
e.pay_to_enable = installed_ext.pay_to_enable
# use the installed extension values # use the installed extension values
e.name = installed_ext.name e.name = installed_ext.name
e.short_description = installed_ext.short_description e.short_description = installed_ext.short_description
@ -111,26 +100,10 @@ async def extensions_install(
installed_exts_ids = [] installed_exts_ids = []
try: try:
ext_id = activate or deactivate all_ext_ids = [ext.code for ext in get_valid_extensions()]
all_extensions = get_valid_extensions() inactive_extensions = [
ext = next((e for e in all_extensions if e.code == ext_id), None) e.id for e in await get_installed_extensions(active=False)
if ext_id and user.admin: ]
if deactivate:
settings.lnbits_deactivated_extensions.add(deactivate)
elif activate:
# 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.remove(activate)
await update_installed_extension_state(
ext_id=ext_id, active=activate is not None
)
all_ext_ids = [ext.code for ext in all_extensions]
inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions() db_version = await get_dbversions()
extensions = [ extensions = [
{ {
@ -152,6 +125,8 @@ async def extensions_install(
"installedRelease": ( "installedRelease": (
dict(ext.installed_release) if ext.installed_release else None dict(ext.installed_release) if ext.installed_release else None
), ),
"payToEnable": (dict(ext.pay_to_enable) if ext.pay_to_enable else {}),
"isPaymentRequired": ext.requires_payment,
} }
for ext in installable_exts for ext in installable_exts
] ]
@ -199,7 +174,7 @@ async def wallet(
user_wallet = user.get_wallet(wallet_id) user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted: if not user_wallet or user_wallet.deleted:
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, "error.html", {"err": "Wallet not found"} request, "error.html", {"err": "Wallet not found"}, HTTPStatus.NOT_FOUND
) )
resp = template_renderer().TemplateResponse( resp = template_renderer().TemplateResponse(
@ -414,29 +389,3 @@ async def hex_to_uuid4(hex_value: str):
raise HTTPException( raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(exc) status_code=HTTPStatus.BAD_REQUEST, detail=str(exc)
) from exc ) from exc
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
)

View File

@ -40,8 +40,14 @@ def render_html_error(request: Request, exc: Exception) -> Optional[Response]:
response.set_cookie("is_access_token_expired", "true") response.set_cookie("is_access_token_expired", "true")
return response return response
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return template_renderer().TemplateResponse( return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"} request, "error.html", {"err": f"Error: {exc!s}"}, status_code
) )
return None return None

View File

@ -85,6 +85,39 @@ class ReleasePaymentInfo(BaseModel):
payment_request: Optional[str] = None payment_request: Optional[str] = None
class PayToEnableInfo(BaseModel):
required: Optional[bool] = False
amount: Optional[int] = None
wallet: Optional[str] = None
class UserExtensionInfo(BaseModel):
paid_to_enable: Optional[bool] = False
payment_hash_to_enable: Optional[str] = None
class UserExtension(BaseModel):
extension: str
active: bool
extra: Optional[UserExtensionInfo] = None
@property
def is_paid(self) -> bool:
if not self.extra:
return False
return self.extra.paid_to_enable is True
@classmethod
def from_row(cls, data: dict) -> "UserExtension":
ext = UserExtension(**data)
ext.extra = (
UserExtensionInfo(**json.loads(data["_extra"] or "{}"))
if "_extra" in data
else None
)
return ext
def download_url(url, save_path): def download_url(url, save_path):
with request.urlopen(url, timeout=60) as dl_file: with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file: with open(save_path, "wb") as out_file:
@ -235,6 +268,7 @@ class ExtensionManager:
@property @property
def extensions(self) -> List[Extension]: def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive
output: List[Extension] = [] output: List[Extension] = []
for extension_folder in self._extension_folders: for extension_folder in self._extension_folders:
@ -353,6 +387,7 @@ class ExtensionRelease(BaseModel):
class InstallableExtension(BaseModel): class InstallableExtension(BaseModel):
id: str id: str
name: str name: str
active: Optional[bool] = False
short_description: Optional[str] = None short_description: Optional[str] = None
icon: Optional[str] = None icon: Optional[str] = None
dependencies: List[str] = [] dependencies: List[str] = []
@ -362,6 +397,7 @@ class InstallableExtension(BaseModel):
latest_release: Optional[ExtensionRelease] = None latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = [] payments: List[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None archive: Optional[str] = None
@property @property
@ -412,6 +448,12 @@ class InstallableExtension(BaseModel):
return self.installed_release.version return self.installed_release.version
return "" return ""
@property
def requires_payment(self) -> bool:
if not self.pay_to_enable:
return False
return self.pay_to_enable.required is True
async def download_archive(self): async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).") logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path ext_zip_file = self.zip_path
@ -548,8 +590,11 @@ class InstallableExtension(BaseModel):
ext = InstallableExtension(**data) ext = InstallableExtension(**data)
if "installed_release" in meta: if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"]) ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("pay_to_enable"):
ext.pay_to_enable = PayToEnableInfo(**meta["pay_to_enable"])
if meta.get("payments"): if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]] ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext return ext
@classmethod @classmethod

File diff suppressed because one or more lines are too long

View File

@ -118,6 +118,7 @@ window.localisation.en = {
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data', drop_db: 'Remove Data',
enable: 'Enable', enable: 'Enable',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
disable: 'Disable', disable: 'Disable',
installed: 'Installed', installed: 'Installed',
@ -144,6 +145,7 @@ window.localisation.en = {
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',
fee: 'Fee', fee: 'Fee',
amount: 'Amount', amount: 'Amount',
amount_sats: 'Amount (sats)',
tag: 'Tag', tag: 'Tag',
unit: 'Unit', unit: 'Unit',
description: 'Description', description: 'Description',
@ -245,8 +247,16 @@ window.localisation.en = {
extension_paid_sats: 'You have already paid %{paid_sats} sats.', extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.', release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet', pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *',
show_qr: 'Show QR', show_qr: 'Show QR',
retry_install: 'Retry Install', retry_install: 'Retry Install',
new_payment: 'Make New Payment', new_payment: 'Make New Payment',
hide_empty_wallets: 'Hide empty wallets' update_payment: 'Update Payment',
already_paid_question: 'Have you already paid?',
sell: 'Sell',
sell_require: 'Ask payment to enable extension',
sell_info:
'The %{name} extension requires a payment of minimum %{amount} sats to enable.',
hide_empty_wallets: 'Hide empty wallets',
recheck: 'Recheck'
} }

View File

@ -50,32 +50,3 @@ async def test_get_extensions_no_user(client):
response = await client.get("extensions") response = await client.get("extensions")
# bad request # bad request
assert response.status_code == 401, f"{response.url} {response.status_code}" assert response.status_code == 401, f"{response.url} {response.status_code}"
# check GET /extensions: enable extension
# TODO: test fails because of removing lnurlp extension
# @pytest.mark.asyncio
# async def test_get_extensions_enable(client, to_user):
# response = await client.get(
# "extensions", params={"usr": to_user.id, "enable": "lnurlp"}
# )
# assert response.status_code == 200, f"{response.url} {response.status_code}"
# check GET /extensions: enable and disable extensions, expect code 400 bad request
# @pytest.mark.asyncio
# async def test_get_extensions_enable_and_disable(client, to_user):
# response = await client.get(
# "extensions",
# params={"usr": to_user.id, "enable": "lnurlp", "disable": "lnurlp"},
# )
# assert response.status_code == 400, f"{response.url} {response.status_code}"
# check GET /extensions: enable nonexistent extension, expect code 400 bad request
@pytest.mark.asyncio
async def test_get_extensions_enable_nonexistent_extension(client, to_user):
response = await client.get(
"extensions", params={"usr": to_user.id, "enable": "12341234"}
)
assert response.status_code == 400, f"{response.url} {response.status_code}"

View File

@ -92,7 +92,7 @@ async def from_wallet(from_user):
async def from_wallet_ws(from_wallet, test_client): async def from_wallet_ws(from_wallet, test_client):
# wait a bit in order to avoid receiving topup notification # wait a bit in order to avoid receiving topup notification
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
with test_client.websocket_connect(f"/api/v1/ws/{from_wallet.id}") as ws: with test_client.websocket_connect(f"/api/v1/ws/{from_wallet.inkey}") as ws:
yield ws yield ws
@ -131,7 +131,7 @@ async def to_wallet(to_user):
async def to_wallet_ws(to_wallet, test_client): async def to_wallet_ws(to_wallet, test_client):
# wait a bit in order to avoid receiving topup notification # wait a bit in order to avoid receiving topup notification
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
with test_client.websocket_connect(f"/api/v1/ws/{to_wallet.id}") as ws: with test_client.websocket_connect(f"/api/v1/ws/{to_wallet.inkey}") as ws:
yield ws yield ws