[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 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.tasks import ( # watchdog_task
killswitch_task,
@ -42,7 +46,6 @@ from .core import init_core_routers
from .core.db import core_app_extra
from .core.services import check_admin_settings, check_webpush_settings
from .core.views.extension_api import add_installed_extension
from .core.views.generic import update_installed_extension_state
from .extension_manager import (
Extension,
InstallableExtension,

View file

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

View file

@ -7,7 +7,7 @@ from .views.auth_api import auth_router
from .views.extension_api import extension_router
# 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.payment_api import payment_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.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 (
AdminSettings,
EditableSettings,
@ -364,6 +369,7 @@ async def add_installed_extension(
"installed_release": (
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,
"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(
"""
INSERT INTO installed_extensions
(id, version, name, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(id, version, name, active, short_description, icon, stars, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET
(version, name, active, short_description, icon, stars, meta) =
(?, ?, ?, ?, ?, ?, ?)
""",
@ -382,13 +388,14 @@ async def add_installed_extension(
ext.id,
version,
ext.name,
ext.active,
ext.short_description,
ext.icon,
ext.stars,
json.dumps(meta),
version,
ext.name,
False,
ext.active,
ext.short_description,
ext.icon,
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(
*, ext_id: str, conn: Optional[Connection] = None
) -> None:
@ -450,21 +468,44 @@ async def get_installed_extension(
async def get_installed_extensions(
active: Optional[bool] = None,
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]
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]:
inactive_extensions = await (conn or db).fetchall(
"""SELECT id FROM installed_extensions WHERE NOT active""",
(),
async def get_user_extension(
user_id: str, extension: str, conn: Optional[Connection] = None
) -> 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(
@ -489,6 +530,22 @@ async def get_user_active_extensions_ids(
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
# -------

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)
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}'.")
except Exception as ex:

View file

@ -513,3 +513,10 @@ async def m019_balances_view_based_on_wallets(db):
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):
await websocket_updater(
wallet.id,
wallet.inkey,
json.dumps(
{
"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):
payment_hash, _ = await create_invoice(

View file

@ -188,10 +188,7 @@
v-if="user.extensions.includes(extension.id) && extension.isActive && extension.isInstalled"
flat
color="grey-5"
type="a"
:href="['{{
url_for('install.extensions')
}}', '?disable=', extension.id].join('')"
@click="disableExtension(extension)"
:label="$t('disable')"
></q-btn>
<q-badge
@ -199,15 +196,13 @@
v-text="$t('admin_only')"
>
</q-badge>
<q-btn
v-else-if="extension.isInstalled && extension.isActive && !user.extensions.includes(extension.id)"
flat
color="primary"
type="a"
:href="['{{
url_for('install.extensions')
}}', '?enable=', extension.id].join('')"
:label="$t('enable')"
@click="enableExtensionForUser(extension)"
:label="$t(extension.isPaymentRequired ? 'pay_to_enable': 'enable')"
>
<q-tooltip>
<span v-text="$t('enable_extension_details')">
@ -215,7 +210,7 @@
></q-btn>
<q-btn
@click="showUpgrade(extension)"
@click="showManageExtension(extension)"
flat
color="primary"
v-if="g.user.admin"
@ -313,7 +308,7 @@
</q-card>
</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-section>
<div v-if="selectedRelease.paymentRequest">
@ -352,10 +347,30 @@
</div>
</q-card>
<q-card v-else 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-tabs
v-model="manageExtensionTab"
active-color="primary"
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
flat
bordered
@ -463,7 +478,7 @@
emit-value
v-model="release.wallet"
:options="g.user.walletOptions"
label="Wallet *"
:label="$t('wallet_required')"
class="q-mt-sm"
>
</q-select>
@ -479,7 +494,7 @@
<q-btn
unelevated
color="primary"
@click="showQRCode(release)"
@click="showInstallQRCode(release)"
class="q-mt-sm float-right"
:label="$t('show_qr')"
></q-btn>
@ -556,33 +571,189 @@
</q-card-section>
</q-expansion-item>
</q-card>
<div class="row q-mt-lg">
<q-btn
v-if="selectedExtension?.isInstalled"
@click="showUninstall()"
flat
color="red"
v-text="$t('uninstall')"
></q-btn>
<q-btn
v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()"
flat
color="red"
:label="$t('drop_db')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
</div>
</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"
v-text="$t('uninstall')"
></q-btn>
<q-btn
v-else-if="selectedExtension?.hasDatabaseTables"
@click="showDropDb()"
flat
color="red"
:label="$t('drop_db')"
></q-btn>
<q-btn
v-close-popup
flat
color="grey"
class="q-ml-auto"
v-text="$t('close')"
></q-btn>
<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-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
@ -592,10 +763,12 @@
return {
searchTerm: '',
tab: 'all',
manageExtensionTab: 'releases',
filteredExtensions: null,
showUninstallDialog: false,
showUpgradeDialog: false,
showManageExtensionDialog: false,
showDropDbDialog: false,
showPayToEnableDialog: false,
dropDbExtensionId: '',
selectedExtension: null,
selectedExtensionRepos: null,
@ -649,7 +822,7 @@
const extension = this.selectedExtension
extension.inProgress = true
this.showUpgradeDialog = false
this.showManageExtensionDialog = false
release.payment_hash =
release.payment_hash || this.getPaylinkHash(release.pay_link)
@ -684,7 +857,7 @@
},
uninstallExtension: async function () {
const extension = this.selectedExtension
this.showUpgradeDialog = false
this.showManageExtensionDialog = false
this.showUninstallDialog = false
extension.inProgress = true
LNbits.api
@ -717,7 +890,7 @@
dropExtensionDb: async function () {
const extension = this.selectedExtension
this.showUpgradeDialog = false
this.showManageExtensionDialog = false
this.showDropDbDialog = false
this.dropDbExtensionId = ''
extension.inProgress = true
@ -745,14 +918,96 @@
const action = extension.isActive ? 'activate' : 'deactivate'
LNbits.api
.request(
'GET',
"{{ url_for('install.extensions') }}" +
'?' +
action +
'=' +
extension.id
'PUT',
`/api/v1/extension/${extension.id}/${action}`,
this.g.user.wallets[0].adminkey
)
.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 => {
LNbits.utils.notifyApiError(err)
extension.inProgress = false
@ -760,7 +1015,7 @@
},
showUninstall: function () {
this.showUpgradeDialog = false
this.showManageExtensionDialog = false
this.showUninstallDialog = true
this.uninstallAndDropDb = false
},
@ -769,11 +1024,12 @@
this.showDropDbDialog = true
},
showUpgrade: async function (extension) {
showManageExtension: async function (extension) {
this.selectedExtension = extension
this.selectedRelease = null
this.selectedExtensionRepos = null
this.showUpgradeDialog = true
this.manageExtensionTab = 'releases'
this.showManageExtensionDialog = true
try {
const {data} = await LNbits.api.request(
@ -816,8 +1072,8 @@
async payAndInstall(release) {
try {
this.selectedExtension.inProgress = true
this.showUpgradeDialog = false
const paymentInfo = await this.requestPayment(
this.showManageExtensionDialog = false
const paymentInfo = await this.requestPaymentForInstall(
this.selectedExtension.id,
release
)
@ -838,11 +1094,32 @@
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
try {
const data = await this.requestPayment(
const data = await this.requestPaymentForInstall(
this.selectedExtension.id,
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(
'PUT',
`/api/v1/extension/invoice`,
`/api/v1/extension/${extId}/invoice/install`,
this.g.user.wallets[0].adminkey,
{
ext_id: extId,
@ -881,6 +1192,18 @@
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) {
this.forgetPaylinkHash(release.pay_link)
release.payment_hash = null

View file

@ -1,3 +1,4 @@
import sys
from http import HTTPStatus
from typing import (
List,
@ -18,17 +19,23 @@ from lnbits.core.helpers import (
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_payment_info,
get_valid_extensions,
@ -43,6 +50,11 @@ from ..crud import (
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(
@ -88,18 +100,18 @@ async def api_install_extension(
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)
settings.lnbits_deactivated_extensions.add(data.ext_id)
# 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:
@ -116,18 +128,171 @@ async def api_install_extension(
) 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.BAD_REQUEST,
status_code=HTTPStatus.NOT_FOUND,
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)
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)
@ -163,7 +329,7 @@ async def api_uninstall_extension(
@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:
extension_releases: List[ExtensionRelease] = (
await InstallableExtension.get_extension_releases(ext_id)
@ -186,30 +352,35 @@ async def get_extension_releases(ext_id: str):
) from exc
@extension_router.put("/invoice", dependencies=[Depends(check_admin)])
async def get_extension_invoice(data: CreateExtension):
@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 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(
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"
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"
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"
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
), "Wroong invoice payment hash"
), "Wrong invoice payment hash."
return payment_info
@ -222,6 +393,51 @@ async def get_extension_invoice(data: CreateExtension):
) 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)],
@ -258,6 +474,9 @@ async def delete_extension_db(ext_id: str):
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

View file

@ -1,4 +1,3 @@
import sys
from http import HTTPStatus
from pathlib import Path
from typing import Annotated, List, Optional, Union
@ -11,7 +10,6 @@ from fastapi.routing import APIRouter
from loguru import logger
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.models import User
from lnbits.decorators import check_admin, check_user_exists
@ -24,11 +22,8 @@ from ...utils.exchange_rates import allowed_currencies, currencies
from ..crud import (
create_wallet,
get_dbversions,
get_inactive_extensions,
get_installed_extensions,
get_user,
update_installed_extension_state,
update_user_extension,
)
generic_router = APIRouter(
@ -73,19 +68,8 @@ async def robots():
return HTMLResponse(content=data, media_type="text/plain")
@generic_router.get(
"/extensions", 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),
enable: str = Query(None),
disable: str = Query(None),
):
await toggle_extension(enable, disable, user.id)
@generic_router.get("/extensions", name="extensions", response_class=HTMLResponse)
async def extensions(request: Request, user: User = Depends(check_user_exists)):
try:
installed_exts: List["InstallableExtension"] = await get_installed_extensions()
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)
if installed_ext:
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
e.name = installed_ext.name
e.short_description = installed_ext.short_description
@ -111,26 +100,10 @@ async def extensions_install(
installed_exts_ids = []
try:
ext_id = activate or deactivate
all_extensions = get_valid_extensions()
ext = next((e for e in all_extensions if e.code == ext_id), None)
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()
all_ext_ids = [ext.code for ext in get_valid_extensions()]
inactive_extensions = [
e.id for e in await get_installed_extensions(active=False)
]
db_version = await get_dbversions()
extensions = [
{
@ -152,6 +125,8 @@ async def extensions_install(
"installedRelease": (
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
]
@ -199,7 +174,7 @@ async def wallet(
user_wallet = user.get_wallet(wallet_id)
if not user_wallet or user_wallet.deleted:
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(
@ -414,29 +389,3 @@ async def hex_to_uuid4(hex_value: str):
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail=str(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")
return response
status_code: int = (
exc.status_code
if isinstance(exc, HTTPException)
else HTTPStatus.INTERNAL_SERVER_ERROR
)
return template_renderer().TemplateResponse(
request, "error.html", {"err": f"Error: {exc!s}"}
request, "error.html", {"err": f"Error: {exc!s}"}, status_code
)
return None

View file

@ -85,6 +85,39 @@ class ReleasePaymentInfo(BaseModel):
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):
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
@ -235,6 +268,7 @@ class ExtensionManager:
@property
def extensions(self) -> List[Extension]:
# todo: remove this property somehow, it is too expensive
output: List[Extension] = []
for extension_folder in self._extension_folders:
@ -353,6 +387,7 @@ class ExtensionRelease(BaseModel):
class InstallableExtension(BaseModel):
id: str
name: str
active: Optional[bool] = False
short_description: Optional[str] = None
icon: Optional[str] = None
dependencies: List[str] = []
@ -362,6 +397,7 @@ class InstallableExtension(BaseModel):
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
pay_to_enable: Optional[PayToEnableInfo] = None
archive: Optional[str] = None
@property
@ -412,6 +448,12 @@ class InstallableExtension(BaseModel):
return self.installed_release.version
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):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@ -548,8 +590,11 @@ class InstallableExtension(BaseModel):
ext = InstallableExtension(**data)
if "installed_release" in meta:
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"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod

File diff suppressed because one or more lines are too long

View file

@ -118,6 +118,7 @@ window.localisation.en = {
uninstall: 'Uninstall',
drop_db: 'Remove Data',
enable: 'Enable',
pay_to_enable: 'Pay To Enable',
enable_extension_details: 'Enable extension for current user',
disable: 'Disable',
installed: 'Installed',
@ -144,6 +145,7 @@ window.localisation.en = {
payment_hash: 'Payment Hash',
fee: 'Fee',
amount: 'Amount',
amount_sats: 'Amount (sats)',
tag: 'Tag',
unit: 'Unit',
description: 'Description',
@ -245,8 +247,16 @@ window.localisation.en = {
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
release_details_error: 'Cannot get the release details.',
pay_from_wallet: 'Pay from Wallet',
wallet_required: 'Wallet *',
show_qr: 'Show QR',
retry_install: 'Retry Install',
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")
# bad request
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):
# wait a bit in order to avoid receiving topup notification
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
@ -131,7 +131,7 @@ async def to_wallet(to_user):
async def to_wallet_ws(to_wallet, test_client):
# wait a bit in order to avoid receiving topup notification
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