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:
Vlad Stan 2024-02-22 15:16:41 +02:00 committed by GitHub
parent 54c6faa4b6
commit d6c8ad1d0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1102 additions and 333 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

File diff suppressed because it is too large Load diff