Paid extensions (#2229)
* fix: download archive file `async` * feat: add `pay_link` property * feat: basic install using internal wallet for payment * fix: pop-up issues * chore: refactor * feat: detect paid extensions * fix: payment check * feat: small stuff * feat: show external invoice * fix: regression for extension install * feat: store previos successful payments * refactor: simplify, almost works * chore: gugu gaga * fix: pay and install * fix: do not pay invoice on the back-end * chore: code clean-up * feat: basic websocker listener * feat: use websocket to watch for invoice payment * feat: remember hanging invoices * refactor: extract `localStorage` methods * chore: code format * chore: code clean-up after test * feat: remember previous payment_hashes * chore: code format * refactor: rename `ExtensionPaymentInfo` to `ReleasePaymentInfo` * refactor: method rename * fix: release version matters now * chore: code format * refactor: method rename * refactor: extract method `_restore_payment_info` * refactor: extract method * chore: rollback `CACHE_VERSION` * chore: code format * feat: i18n * chore: update bundle * refactor: public method name * chore: code format * fix: websocket connection * Update installation.md (#2259) * Update installation.md (#2260) * fix: try to fix `openapi` error * chore: bundle * chore:bundle --------- Co-authored-by: benarc <ben@arc.wales> Co-authored-by: Arc <33088785+arcbtc@users.noreply.github.com>
This commit is contained in:
parent
54c6faa4b6
commit
d6c8ad1d0d
10 changed files with 1102 additions and 333 deletions
|
|
@ -194,7 +194,7 @@ async def check_installed_extensions(app: FastAPI):
|
||||||
|
|
||||||
for ext in installed_extensions:
|
for ext in installed_extensions:
|
||||||
try:
|
try:
|
||||||
installed = check_installed_extension_files(ext)
|
installed = await check_installed_extension_files(ext)
|
||||||
if not installed:
|
if not installed:
|
||||||
await restore_installed_extension(app, ext)
|
await restore_installed_extension(app, ext)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -252,14 +252,14 @@ async def build_all_installed_extensions_list(
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
async def check_installed_extension_files(ext: InstallableExtension) -> bool:
|
||||||
if ext.has_installed_version:
|
if ext.has_installed_version:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
|
||||||
|
|
||||||
if f"./{str(ext.zip_path)}" not in zip_files:
|
if f"./{str(ext.zip_path)}" not in zip_files:
|
||||||
ext.download_archive()
|
await ext.download_archive()
|
||||||
ext.extract_archive()
|
ext.extract_archive()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -397,7 +397,10 @@ async def install_extension(
|
||||||
return False, "No release selected"
|
return False, "No release selected"
|
||||||
|
|
||||||
data = CreateExtension(
|
data = CreateExtension(
|
||||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
ext_id=extension,
|
||||||
|
archive=release.archive,
|
||||||
|
source_repo=release.source_repo,
|
||||||
|
version=release.version,
|
||||||
)
|
)
|
||||||
await _call_install_extension(data, url, admin_user)
|
await _call_install_extension(data, url, admin_user)
|
||||||
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
click.echo(f"Extension '{extension}' ({release.version}) installed.")
|
||||||
|
|
@ -445,7 +448,10 @@ async def update_extension(
|
||||||
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
click.echo(f"Updating '{extension}' extension to version: {release.version }")
|
||||||
|
|
||||||
data = CreateExtension(
|
data = CreateExtension(
|
||||||
ext_id=extension, archive=release.archive, source_repo=release.source_repo
|
ext_id=extension,
|
||||||
|
archive=release.archive,
|
||||||
|
source_repo=release.source_repo,
|
||||||
|
version=release.version,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _call_install_extension(data, url, admin_user)
|
await _call_install_extension(data, url, admin_user)
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,7 @@ async def add_installed_extension(
|
||||||
dict(ext.installed_release) if ext.installed_release else None
|
dict(ext.installed_release) if ext.installed_release else None
|
||||||
),
|
),
|
||||||
"dependencies": ext.dependencies,
|
"dependencies": ext.dependencies,
|
||||||
|
"payments": [dict(p) for p in ext.payments] if ext.payments else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
version = ext.installed_release.version if ext.installed_release else ""
|
version = ext.installed_release.version if ext.installed_release else ""
|
||||||
|
|
|
||||||
|
|
@ -307,7 +307,40 @@
|
||||||
</q-dialog>
|
</q-dialog>
|
||||||
|
|
||||||
<q-dialog v-model="showUpgradeDialog">
|
<q-dialog v-model="showUpgradeDialog">
|
||||||
<q-card class="q-pa-lg lnbits__dialog-card">
|
<q-card v-if="selectedRelease" class="q-pa-lg lnbits__dialog-card">
|
||||||
|
<q-card-section>
|
||||||
|
<div v-if="selectedRelease.paymentRequest">
|
||||||
|
<a :href="'lightning:' + selectedRelease.paymentRequest">
|
||||||
|
<q-responsive :ratio="1" class="q-mx-xl">
|
||||||
|
<lnbits-qrcode
|
||||||
|
:value="'lightning:' + selectedRelease.paymentRequest.toUpperCase()"
|
||||||
|
></lnbits-qrcode>
|
||||||
|
</q-responsive>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<q-spinner color="primary" size="2.55em"></q-spinner>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<div class="row q-mt-lg">
|
||||||
|
<div class="col">
|
||||||
|
<q-btn
|
||||||
|
v-if="selectedRelease.paymentRequest"
|
||||||
|
outline
|
||||||
|
color="grey"
|
||||||
|
@click="copyText(selectedRelease.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">
|
||||||
|
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
<q-card v-else class="q-pa-lg lnbits__dialog-card">
|
||||||
<q-card-section>
|
<q-card-section>
|
||||||
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
<div class="text-h6" v-text="selectedExtension?.name"></div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -376,19 +409,104 @@
|
||||||
color="pink"
|
color="pink"
|
||||||
size="70px"
|
size="70px"
|
||||||
></q-icon>
|
></q-icon>
|
||||||
Cannot get the release details.
|
<span v-text="$t('release_details_error')"></span>
|
||||||
</div>
|
</div>
|
||||||
<q-card v-else>
|
<q-card v-else>
|
||||||
<q-card-section v-if="release.is_version_compatible">
|
<q-card-section v-if="release.is_version_compatible">
|
||||||
<q-btn
|
<span
|
||||||
v-if="!release.isInstalled"
|
v-if="release.requiresPayment && !release.paid_sats"
|
||||||
@click="installExtension(release)"
|
v-text="$t('extension_cost', {cost: release.cost_sats})"
|
||||||
color="primary unelevated mt-lg pt-lg"
|
class="q-mb-lg"
|
||||||
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
|
></span>
|
||||||
>
|
<span
|
||||||
<q-btn v-else @click="showUninstall()" flat color="red">
|
v-if="release.requiresPayment && release.paid_sats"
|
||||||
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
|
class="q-mb-lg"
|
||||||
|
v-text="$t('extension_paid_sats', {paid_sats: release.paid_sats})"
|
||||||
|
></span>
|
||||||
|
<div
|
||||||
|
v-if="!release.requiresPayment || (release.requiresPayment && release.paid_sats)"
|
||||||
>
|
>
|
||||||
|
<q-btn
|
||||||
|
v-if="!release.isInstalled"
|
||||||
|
@click="installExtension(release)"
|
||||||
|
color="primary unelevated mt-lg pt-lg"
|
||||||
|
:label="$t('install')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="release.requiresPayment && !release.paid_sats">
|
||||||
|
<div v-if="!release.payment_hash">
|
||||||
|
<q-input
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
v-model.number="release.paidAmount"
|
||||||
|
type="number"
|
||||||
|
:min="release.cost_sats"
|
||||||
|
suffix="sat"
|
||||||
|
class="q-mt-sm"
|
||||||
|
>
|
||||||
|
</q-input>
|
||||||
|
<q-select
|
||||||
|
filled
|
||||||
|
dense
|
||||||
|
emit-value
|
||||||
|
v-model="release.wallet"
|
||||||
|
:options="g.user.walletOptions"
|
||||||
|
label="Wallet *"
|
||||||
|
class="q-mt-sm"
|
||||||
|
>
|
||||||
|
</q-select>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="payAndInstall(release)"
|
||||||
|
:disabled="!release.wallet"
|
||||||
|
class="q-mt-sm"
|
||||||
|
:label="$t('pay_from_wallet')"
|
||||||
|
></q-btn>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
@click="showQRCode(release)"
|
||||||
|
class="q-mt-sm float-right"
|
||||||
|
:label="$t('show_qr')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<br />
|
||||||
|
<span
|
||||||
|
class="q-mb-lg q-mt-lg"
|
||||||
|
v-text="'There is a previous pending invoice for this release.'"
|
||||||
|
></span>
|
||||||
|
<br />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="installExtension(release)"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm"
|
||||||
|
:label="$t('retry_install')"
|
||||||
|
></q-btn>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
@click="clearHangingInvoice(release)"
|
||||||
|
color="primary"
|
||||||
|
class="q-mt-sm float-right"
|
||||||
|
:label="$t('new_payment')"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<q-btn
|
||||||
|
v-if="release.isInstalled"
|
||||||
|
@click="showUninstall()"
|
||||||
|
:label="$t('uninstall')"
|
||||||
|
flat
|
||||||
|
color="red"
|
||||||
|
></q-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="release.html_url"
|
v-if="release.html_url"
|
||||||
class="text-secondary float-right"
|
class="text-secondary float-right"
|
||||||
|
|
@ -464,8 +582,10 @@
|
||||||
dropDbExtensionId: '',
|
dropDbExtensionId: '',
|
||||||
selectedExtension: null,
|
selectedExtension: null,
|
||||||
selectedExtensionRepos: null,
|
selectedExtensionRepos: null,
|
||||||
|
selectedRelease: null,
|
||||||
uninstallAndDropDb: false,
|
uninstallAndDropDb: false,
|
||||||
maxStars: 5,
|
maxStars: 5,
|
||||||
|
paylinkWebsocket: null,
|
||||||
user: null
|
user: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -504,10 +624,18 @@
|
||||||
.filter(extensionNameContains(term))
|
.filter(extensionNameContains(term))
|
||||||
this.tab = tab
|
this.tab = tab
|
||||||
},
|
},
|
||||||
|
|
||||||
installExtension: async function (release) {
|
installExtension: async function (release) {
|
||||||
|
// no longer required to check if the invoice was paid
|
||||||
|
// the install logic has been triggered one way or another
|
||||||
|
this.unsubscribeFromPaylinkWs()
|
||||||
|
|
||||||
const extension = this.selectedExtension
|
const extension = this.selectedExtension
|
||||||
extension.inProgress = true
|
extension.inProgress = true
|
||||||
this.showUpgradeDialog = false
|
this.showUpgradeDialog = false
|
||||||
|
release.payment_hash =
|
||||||
|
release.payment_hash || this.getPaylinkHash(release.pay_link)
|
||||||
|
|
||||||
LNbits.api
|
LNbits.api
|
||||||
.request(
|
.request(
|
||||||
'POST',
|
'POST',
|
||||||
|
|
@ -516,7 +644,9 @@
|
||||||
{
|
{
|
||||||
ext_id: extension.id,
|
ext_id: extension.id,
|
||||||
archive: release.archive,
|
archive: release.archive,
|
||||||
source_repo: release.source_repo
|
source_repo: release.source_repo,
|
||||||
|
payment_hash: release.payment_hash,
|
||||||
|
version: release.version
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
|
|
@ -530,8 +660,9 @@
|
||||||
this.tab = 'installed'
|
this.tab = 'installed'
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
LNbits.utils.notifyApiError(err)
|
console.warn(err)
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
uninstallExtension: async function () {
|
uninstallExtension: async function () {
|
||||||
|
|
@ -623,8 +754,10 @@
|
||||||
|
|
||||||
showUpgrade: async function (extension) {
|
showUpgrade: async function (extension) {
|
||||||
this.selectedExtension = extension
|
this.selectedExtension = extension
|
||||||
this.showUpgradeDialog = true
|
this.selectedRelease = null
|
||||||
this.selectedExtensionRepos = null
|
this.selectedExtensionRepos = null
|
||||||
|
this.showUpgradeDialog = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {data} = await LNbits.api.request(
|
const {data} = await LNbits.api.request(
|
||||||
'GET',
|
'GET',
|
||||||
|
|
@ -648,6 +781,12 @@
|
||||||
if (release.isInstalled) {
|
if (release.isInstalled) {
|
||||||
repos[release.source_repo].isInstalled = true
|
repos[release.source_repo].isInstalled = true
|
||||||
}
|
}
|
||||||
|
if (release.pay_link) {
|
||||||
|
release.requiresPayment = true
|
||||||
|
release.paidAmount = release.cost_sats
|
||||||
|
release.payment_hash = this.getPaylinkHash(release.pay_link)
|
||||||
|
}
|
||||||
|
|
||||||
repos[release.source_repo].releases.push(release)
|
repos[release.source_repo].releases.push(release)
|
||||||
return repos
|
return repos
|
||||||
}, {})
|
}, {})
|
||||||
|
|
@ -656,6 +795,122 @@
|
||||||
extension.inProgress = false
|
extension.inProgress = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async payAndInstall(release) {
|
||||||
|
try {
|
||||||
|
this.selectedExtension.inProgress = true
|
||||||
|
this.showUpgradeDialog = false
|
||||||
|
const paymentInfo = await this.requestPayment(
|
||||||
|
this.selectedExtension.id,
|
||||||
|
release
|
||||||
|
)
|
||||||
|
this.rememberPaylinkHash(release.pay_link, paymentInfo.payment_hash)
|
||||||
|
const wallet = this.g.user.wallets.find(w => w.id === release.wallet)
|
||||||
|
const {data} = await LNbits.api.payInvoice(
|
||||||
|
wallet,
|
||||||
|
paymentInfo.payment_request
|
||||||
|
)
|
||||||
|
|
||||||
|
release.payment_hash = data.payment_hash
|
||||||
|
|
||||||
|
await this.installExtension(release)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err)
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
} finally {
|
||||||
|
this.selectedExtension.inProgress = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async showQRCode(release) {
|
||||||
|
this.selectedRelease = release
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.requestPayment(
|
||||||
|
this.selectedExtension.id,
|
||||||
|
release
|
||||||
|
)
|
||||||
|
|
||||||
|
this.selectedRelease.paymentRequest = data.payment_request
|
||||||
|
this.selectedRelease.payment_hash = data.payment_hash
|
||||||
|
this.selectedRelease = _.clone(this.selectedRelease)
|
||||||
|
this.rememberPaylinkHash(
|
||||||
|
this.selectedRelease.pay_link,
|
||||||
|
this.selectedRelease.payment_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
this.subscribeToPaylinkWs(
|
||||||
|
this.selectedRelease.pay_link,
|
||||||
|
data.payment_hash
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err)
|
||||||
|
LNbits.utils.notifyApiError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async requestPayment(extId, release) {
|
||||||
|
const {data} = await LNbits.api.request(
|
||||||
|
'PUT',
|
||||||
|
`/api/v1/extension/invoice`,
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
{
|
||||||
|
ext_id: extId,
|
||||||
|
archive: release.archive,
|
||||||
|
source_repo: release.source_repo,
|
||||||
|
cost_sats: release.paidAmount,
|
||||||
|
version: release.version
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHangingInvoice(release) {
|
||||||
|
this.forgetPaylinkHash(release.pay_link)
|
||||||
|
release.payment_hash = null
|
||||||
|
},
|
||||||
|
|
||||||
|
rememberPaylinkHash(pay_link, payment_hash) {
|
||||||
|
this.$q.localStorage.set(
|
||||||
|
`lnbits.extensions.paylink.${pay_link}`,
|
||||||
|
payment_hash
|
||||||
|
)
|
||||||
|
},
|
||||||
|
getPaylinkHash(pay_link) {
|
||||||
|
return this.$q.localStorage.getItem(
|
||||||
|
`lnbits.extensions.paylink.${pay_link}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
forgetPaylinkHash(pay_link) {
|
||||||
|
this.$q.localStorage.remove(`lnbits.extensions.paylink.${pay_link}`)
|
||||||
|
},
|
||||||
|
subscribeToPaylinkWs(pay_link, payment_hash) {
|
||||||
|
const url = new URL(`${pay_link}/${payment_hash}`)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
this.paylinkWebsocket = new WebSocket(url)
|
||||||
|
this.paylinkWebsocket.addEventListener('message', async ({data}) => {
|
||||||
|
const resp = JSON.parse(data)
|
||||||
|
if (resp.paid) {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Invoice Paid!'
|
||||||
|
})
|
||||||
|
this.installExtension(this.selectedRelease)
|
||||||
|
} else {
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Invoice tracking lost!'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
unsubscribeFromPaylinkWs() {
|
||||||
|
try {
|
||||||
|
this.paylinkWebsocket && this.paylinkWebsocket.close()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
hasNewVersion: function (extension) {
|
hasNewVersion: function (extension) {
|
||||||
if (extension.installedRelease && extension.latestRelease) {
|
if (extension.installedRelease && extension.latestRelease) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ from lnbits.extension_manager import (
|
||||||
ExtensionRelease,
|
ExtensionRelease,
|
||||||
InstallableExtension,
|
InstallableExtension,
|
||||||
fetch_github_release_config,
|
fetch_github_release_config,
|
||||||
|
fetch_release_payment_info,
|
||||||
get_valid_extensions,
|
get_valid_extensions,
|
||||||
)
|
)
|
||||||
from lnbits.helpers import generate_filter_params_openapi, url_for
|
from lnbits.helpers import generate_filter_params_openapi, url_for
|
||||||
|
|
@ -83,6 +84,7 @@ from ..crud import (
|
||||||
delete_wallet,
|
delete_wallet,
|
||||||
drop_extension_db,
|
drop_extension_db,
|
||||||
get_dbversions,
|
get_dbversions,
|
||||||
|
get_installed_extension,
|
||||||
get_payments,
|
get_payments,
|
||||||
get_payments_history,
|
get_payments_history,
|
||||||
get_payments_paginated,
|
get_payments_paginated,
|
||||||
|
|
@ -796,7 +798,7 @@ async def api_install_extension(
|
||||||
access_token: Optional[str] = Depends(check_access_token),
|
access_token: Optional[str] = Depends(check_access_token),
|
||||||
):
|
):
|
||||||
release = await InstallableExtension.get_extension_release(
|
release = await InstallableExtension.get_extension_release(
|
||||||
data.ext_id, data.source_repo, data.archive
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
)
|
)
|
||||||
if not release:
|
if not release:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -808,13 +810,17 @@ async def api_install_extension(
|
||||||
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
release.payment_hash = data.payment_hash
|
||||||
ext_info = InstallableExtension(
|
ext_info = InstallableExtension(
|
||||||
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
|
||||||
)
|
)
|
||||||
|
|
||||||
ext_info.download_archive()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
installed_ext = await get_installed_extension(data.ext_id)
|
||||||
|
ext_info.payments = installed_ext.payments if installed_ext else []
|
||||||
|
|
||||||
|
await ext_info.download_archive()
|
||||||
|
|
||||||
ext_info.extract_archive()
|
ext_info.extract_archive()
|
||||||
|
|
||||||
extension = Extension.from_installable_ext(ext_info)
|
extension = Extension.from_installable_ext(ext_info)
|
||||||
|
|
@ -838,7 +844,8 @@ async def api_install_extension(
|
||||||
ext_info.nofiy_upgrade()
|
ext_info.nofiy_upgrade()
|
||||||
|
|
||||||
return extension
|
return extension
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
ext_info.clean_extension_files()
|
ext_info.clean_extension_files()
|
||||||
|
|
@ -907,6 +914,15 @@ async def get_extension_releases(ext_id: str):
|
||||||
ExtensionRelease
|
ExtensionRelease
|
||||||
] = await InstallableExtension.get_extension_releases(ext_id)
|
] = await InstallableExtension.get_extension_releases(ext_id)
|
||||||
|
|
||||||
|
installed_ext = await get_installed_extension(ext_id)
|
||||||
|
if not installed_ext:
|
||||||
|
return extension_releases
|
||||||
|
|
||||||
|
for release in extension_releases:
|
||||||
|
payment_info = installed_ext.find_existing_payment(release.pay_link)
|
||||||
|
if payment_info:
|
||||||
|
release.paid_sats = payment_info.amount
|
||||||
|
|
||||||
return extension_releases
|
return extension_releases
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|
@ -915,6 +931,40 @@ async def get_extension_releases(ext_id: str):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.put("/api/v1/extension/invoice", dependencies=[Depends(check_admin)])
|
||||||
|
async def get_extension_invoice(data: CreateExtension):
|
||||||
|
try:
|
||||||
|
assert data.cost_sats, "A non-zero amount must be specified"
|
||||||
|
release = await InstallableExtension.get_extension_release(
|
||||||
|
data.ext_id, data.source_repo, data.archive, data.version
|
||||||
|
)
|
||||||
|
assert release, "Release not found"
|
||||||
|
assert release.pay_link, "Pay link not found for release"
|
||||||
|
|
||||||
|
payment_info = await fetch_release_payment_info(
|
||||||
|
release.pay_link, data.cost_sats
|
||||||
|
)
|
||||||
|
assert payment_info and payment_info.payment_request, "Cannot request invoice"
|
||||||
|
invoice = bolt11.decode(payment_info.payment_request)
|
||||||
|
|
||||||
|
assert invoice.amount_msat is not None, "Invoic amount is missing"
|
||||||
|
invoice_amount = int(invoice.amount_msat / 1000)
|
||||||
|
assert (
|
||||||
|
invoice_amount == data.cost_sats
|
||||||
|
), f"Wrong invoice amount: {invoice_amount}."
|
||||||
|
assert (
|
||||||
|
payment_info.payment_hash == invoice.payment_hash
|
||||||
|
), "Wroong invoice payment hash"
|
||||||
|
|
||||||
|
return payment_info
|
||||||
|
|
||||||
|
except AssertionError as e:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning(ex)
|
||||||
|
raise HTTPException(HTTPStatus.INTERNAL_SERVER_ERROR, "Cannot request invoice")
|
||||||
|
|
||||||
|
|
||||||
@api_router.get(
|
@api_router.get(
|
||||||
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
"/api/v1/extension/release/{org}/{repo}/{tag_name}",
|
||||||
dependencies=[Depends(check_admin)],
|
dependencies=[Depends(check_admin)],
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
import zipfile
|
||||||
from http import HTTPStatus
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, NamedTuple, Optional, Tuple
|
from typing import Any, List, NamedTuple, Optional, Tuple
|
||||||
from urllib import request
|
from urllib import request
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from packaging import version
|
from packaging import version
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
|
||||||
warning: Optional[str]
|
warning: Optional[str]
|
||||||
info_notification: Optional[str]
|
info_notification: Optional[str]
|
||||||
critical_notification: Optional[str]
|
critical_notification: Optional[str]
|
||||||
|
pay_link: Optional[str]
|
||||||
|
|
||||||
def is_version_compatible(self):
|
def is_version_compatible(self):
|
||||||
if not self.min_lnbits_version:
|
if not self.min_lnbits_version:
|
||||||
|
|
@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
|
||||||
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
return version_parse(self.min_lnbits_version) <= version_parse(settings.version)
|
||||||
|
|
||||||
|
|
||||||
|
class ReleasePaymentInfo(BaseModel):
|
||||||
|
amount: Optional[int] = None
|
||||||
|
pay_link: Optional[str] = None
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
payment_request: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def download_url(url, save_path):
|
def download_url(url, save_path):
|
||||||
with request.urlopen(url) 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:
|
||||||
out_file.write(dl_file.read())
|
out_file.write(dl_file.read())
|
||||||
|
|
||||||
|
|
@ -155,6 +162,21 @@ async def github_api_get(url: str, error_msg: Optional[str]) -> Any:
|
||||||
return resp.json()
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_release_payment_info(
|
||||||
|
url: str, amount: Optional[int] = None
|
||||||
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
|
if amount:
|
||||||
|
url = f"{url}?amount={amount}"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return ReleasePaymentInfo(**resp.json())
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
def icon_to_github_url(source_repo: str, path: Optional[str]) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
|
||||||
repo: Optional[str] = None
|
repo: Optional[str] = None
|
||||||
icon: Optional[str] = None
|
icon: Optional[str] = None
|
||||||
|
|
||||||
|
pay_link: Optional[str] = None
|
||||||
|
cost_sats: Optional[int] = None
|
||||||
|
paid_sats: Optional[int] = 0
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def archive_url(self) -> str:
|
||||||
|
if not self.pay_link:
|
||||||
|
return self.archive
|
||||||
|
return (
|
||||||
|
f"{self.archive}?version=v{self.version}&payment_hash={self.payment_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_payment_requirements(self):
|
||||||
|
if not self.pay_link:
|
||||||
|
return
|
||||||
|
|
||||||
|
payment_info = await fetch_release_payment_info(self.pay_link)
|
||||||
|
self.cost_sats = payment_info.amount if payment_info else None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_github_release(
|
def from_github_release(
|
||||||
cls, source_repo: str, r: "GitHubRepoRelease"
|
cls, source_repo: str, r: "GitHubRepoRelease"
|
||||||
|
|
@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
|
||||||
is_version_compatible=e.is_version_compatible(),
|
is_version_compatible=e.is_version_compatible(),
|
||||||
warning=e.warning,
|
warning=e.warning,
|
||||||
html_url=e.html_url,
|
html_url=e.html_url,
|
||||||
|
pay_link=e.pay_link,
|
||||||
repo=e.repo,
|
repo=e.repo,
|
||||||
icon=e.icon,
|
icon=e.icon,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def all_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
async def get_github_releases(cls, org: str, repo: str) -> List["ExtensionRelease"]:
|
||||||
try:
|
try:
|
||||||
github_releases = await fetch_github_releases(org, repo)
|
github_releases = await fetch_github_releases(org, repo)
|
||||||
return [
|
return [
|
||||||
|
|
@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
|
||||||
featured = False
|
featured = False
|
||||||
latest_release: Optional[ExtensionRelease] = None
|
latest_release: Optional[ExtensionRelease] = None
|
||||||
installed_release: Optional[ExtensionRelease] = None
|
installed_release: Optional[ExtensionRelease] = None
|
||||||
|
payments: List[ReleasePaymentInfo] = []
|
||||||
archive: Optional[str] = None
|
archive: Optional[str] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
|
||||||
return self.installed_release.version
|
return self.installed_release.version
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
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
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
try:
|
try:
|
||||||
assert self.installed_release, "installed_release is none."
|
assert self.installed_release, "installed_release is none."
|
||||||
download_url(self.installed_release.archive, ext_zip_file)
|
|
||||||
|
self._restore_payment_info()
|
||||||
|
|
||||||
|
await asyncio.to_thread(
|
||||||
|
download_url, self.installed_release.archive_url, ext_zip_file
|
||||||
|
)
|
||||||
|
|
||||||
|
self._remember_payment_info()
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
raise HTTPException(
|
raise AssertionError("Cannot fetch extension archive file")
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="Cannot fetch extension archive file",
|
|
||||||
)
|
|
||||||
|
|
||||||
archive_hash = file_hash(ext_zip_file)
|
archive_hash = file_hash(ext_zip_file)
|
||||||
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
if self.installed_release.hash and self.installed_release.hash != archive_hash:
|
||||||
# remove downloaded archive
|
# remove downloaded archive
|
||||||
if ext_zip_file.is_file():
|
if ext_zip_file.is_file():
|
||||||
os.remove(ext_zip_file)
|
os.remove(ext_zip_file)
|
||||||
raise HTTPException(
|
raise AssertionError("File hash missmatch. Will not install.")
|
||||||
status_code=HTTPStatus.NOT_FOUND,
|
|
||||||
detail="File hash missmatch. Will not install.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def extract_archive(self):
|
def extract_archive(self):
|
||||||
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
logger.info(f"Extracting extension {self.name} ({self.installed_version}).")
|
||||||
|
|
@ -468,14 +514,54 @@ class InstallableExtension(BaseModel):
|
||||||
if version_parse(self.latest_release.version) < version_parse(release.version):
|
if version_parse(self.latest_release.version) < version_parse(release.version):
|
||||||
self.latest_release = release
|
self.latest_release = release
|
||||||
|
|
||||||
|
def find_existing_payment(
|
||||||
|
self, pay_link: Optional[str]
|
||||||
|
) -> Optional[ReleasePaymentInfo]:
|
||||||
|
if not pay_link:
|
||||||
|
return None
|
||||||
|
return next(
|
||||||
|
(p for p in self.payments if p.pay_link == pay_link),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _restore_payment_info(self):
|
||||||
|
if not self.installed_release:
|
||||||
|
return
|
||||||
|
if not self.installed_release.pay_link:
|
||||||
|
return
|
||||||
|
if self.installed_release.payment_hash:
|
||||||
|
return
|
||||||
|
payment_info = self.find_existing_payment(self.installed_release.pay_link)
|
||||||
|
if payment_info:
|
||||||
|
self.installed_release.payment_hash = payment_info.payment_hash
|
||||||
|
|
||||||
|
def _remember_payment_info(self):
|
||||||
|
if not self.installed_release or not self.installed_release.pay_link:
|
||||||
|
return
|
||||||
|
payment_info = ReleasePaymentInfo(
|
||||||
|
amount=self.installed_release.cost_sats,
|
||||||
|
pay_link=self.installed_release.pay_link,
|
||||||
|
payment_hash=self.installed_release.payment_hash,
|
||||||
|
)
|
||||||
|
self.payments = [
|
||||||
|
p for p in self.payments if p.pay_link != payment_info.pay_link
|
||||||
|
]
|
||||||
|
self.payments.append(payment_info)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_row(cls, data: dict) -> "InstallableExtension":
|
def from_row(cls, data: dict) -> "InstallableExtension":
|
||||||
meta = json.loads(data["meta"])
|
meta = json.loads(data["meta"])
|
||||||
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("payments"):
|
||||||
|
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
|
||||||
return ext
|
return ext
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
|
||||||
|
return [InstallableExtension.from_row(row) for row in rows]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def from_github_release(
|
async def from_github_release(
|
||||||
cls, github_release: GitHubRelease
|
cls, github_release: GitHubRelease
|
||||||
|
|
@ -565,17 +651,19 @@ class InstallableExtension(BaseModel):
|
||||||
try:
|
try:
|
||||||
manifest = await fetch_manifest(url)
|
manifest = await fetch_manifest(url)
|
||||||
for r in manifest.repos:
|
for r in manifest.repos:
|
||||||
if r.id == ext_id:
|
if r.id != ext_id:
|
||||||
repo_releases = await ExtensionRelease.all_releases(
|
continue
|
||||||
r.organisation, r.repository
|
repo_releases = await ExtensionRelease.get_github_releases(
|
||||||
)
|
r.organisation, r.repository
|
||||||
extension_releases += repo_releases
|
)
|
||||||
|
extension_releases += repo_releases
|
||||||
|
|
||||||
for e in manifest.extensions:
|
for e in manifest.extensions:
|
||||||
if e.id == ext_id:
|
if e.id != ext_id:
|
||||||
extension_releases += [
|
continue
|
||||||
ExtensionRelease.from_explicit_release(url, e)
|
explicit_release = ExtensionRelease.from_explicit_release(url, e)
|
||||||
]
|
await explicit_release.check_payment_requirements()
|
||||||
|
extension_releases.append(explicit_release)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
logger.warning(f"Manifest {url} failed with '{str(e)}'")
|
||||||
|
|
@ -584,7 +672,7 @@ class InstallableExtension(BaseModel):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_extension_release(
|
async def get_extension_release(
|
||||||
cls, ext_id: str, source_repo: str, archive: str
|
cls, ext_id: str, source_repo: str, archive: str, version: str
|
||||||
) -> Optional["ExtensionRelease"]:
|
) -> Optional["ExtensionRelease"]:
|
||||||
all_releases: List[
|
all_releases: List[
|
||||||
ExtensionRelease
|
ExtensionRelease
|
||||||
|
|
@ -592,7 +680,9 @@ class InstallableExtension(BaseModel):
|
||||||
selected_release = [
|
selected_release = [
|
||||||
r
|
r
|
||||||
for r in all_releases
|
for r in all_releases
|
||||||
if r.archive == archive and r.source_repo == source_repo
|
if r.archive == archive
|
||||||
|
and r.source_repo == source_repo
|
||||||
|
and r.version == version
|
||||||
]
|
]
|
||||||
|
|
||||||
return selected_release[0] if len(selected_release) != 0 else None
|
return selected_release[0] if len(selected_release) != 0 else None
|
||||||
|
|
@ -602,6 +692,9 @@ class CreateExtension(BaseModel):
|
||||||
ext_id: str
|
ext_id: str
|
||||||
archive: str
|
archive: str
|
||||||
source_repo: str
|
source_repo: str
|
||||||
|
version: str
|
||||||
|
cost_sats: Optional[int] = 0
|
||||||
|
payment_hash: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
def get_valid_extensions(include_deactivated: Optional[bool] = True) -> List[Extension]:
|
||||||
|
|
|
||||||
18
lnbits/static/bundle.min.js
vendored
18
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -239,5 +239,12 @@ This service is in BETA. LNbits holds no responsibility for loss of access to fu
|
||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
look_and_feel: 'Look and Feel',
|
look_and_feel: 'Look and Feel',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
color_scheme: 'Color Scheme'
|
color_scheme: 'Color Scheme',
|
||||||
|
extension_cost: 'This release requires a payment of minimum %{cost} sats.',
|
||||||
|
extension_paid_sats: 'You have already paid %{paid_sats} sats.',
|
||||||
|
release_details_error: 'Cannot get the release details.',
|
||||||
|
pay_from_wallet: 'Pay from Wallet',
|
||||||
|
show_qr: 'Show QR',
|
||||||
|
retry_install: 'Retry Install',
|
||||||
|
new_payment: 'Make New Payment'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// update cache version every time there is a new deployment
|
// update cache version every time there is a new deployment
|
||||||
// so the service worker reinitializes the cache
|
// so the service worker reinitializes the cache
|
||||||
const CACHE_VERSION = 117
|
const CACHE_VERSION = 121
|
||||||
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
|
||||||
|
|
||||||
const getApiKey = request => {
|
const getApiKey = request => {
|
||||||
|
|
|
||||||
907
package-lock.json
generated
907
package-lock.json
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue