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:
try:
installed = check_installed_extension_files(ext)
installed = await check_installed_extension_files(ext)
if not installed:
await restore_installed_extension(app, ext)
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:
return True
zip_files = glob.glob(os.path.join(settings.lnbits_data_folder, "zips", "*.zip"))
if f"./{str(ext.zip_path)}" not in zip_files:
ext.download_archive()
await ext.download_archive()
ext.extract_archive()
return False

View file

@ -397,7 +397,10 @@ async def install_extension(
return False, "No release selected"
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)
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 }")
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)

View file

@ -322,6 +322,7 @@ async def add_installed_extension(
dict(ext.installed_release) if ext.installed_release else None
),
"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 ""

View file

@ -307,7 +307,40 @@
</q-dialog>
<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>
<div class="text-h6" v-text="selectedExtension?.name"></div>
</q-card-section>
@ -376,19 +409,104 @@
color="pink"
size="70px"
></q-icon>
Cannot get the release details.
<span v-text="$t('release_details_error')"></span>
</div>
<q-card v-else>
<q-card-section v-if="release.is_version_compatible">
<span
v-if="release.requiresPayment && !release.paid_sats"
v-text="$t('extension_cost', {cost: release.cost_sats})"
class="q-mb-lg"
></span>
<span
v-if="release.requiresPayment && release.paid_sats"
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"
>{%raw%}{{ $t('install') }}{%endraw%}</q-btn
: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-btn v-else @click="showUninstall()" flat color="red">
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn
</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
v-if="release.html_url"
class="text-secondary float-right"
@ -464,8 +582,10 @@
dropDbExtensionId: '',
selectedExtension: null,
selectedExtensionRepos: null,
selectedRelease: null,
uninstallAndDropDb: false,
maxStars: 5,
paylinkWebsocket: null,
user: null
}
},
@ -504,10 +624,18 @@
.filter(extensionNameContains(term))
this.tab = tab
},
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
extension.inProgress = true
this.showUpgradeDialog = false
release.payment_hash =
release.payment_hash || this.getPaylinkHash(release.pay_link)
LNbits.api
.request(
'POST',
@ -516,7 +644,9 @@
{
ext_id: extension.id,
archive: release.archive,
source_repo: release.source_repo
source_repo: release.source_repo,
payment_hash: release.payment_hash,
version: release.version
}
)
.then(response => {
@ -530,8 +660,9 @@
this.tab = 'installed'
})
.catch(err => {
LNbits.utils.notifyApiError(err)
console.warn(err)
extension.inProgress = false
LNbits.utils.notifyApiError(err)
})
},
uninstallExtension: async function () {
@ -623,8 +754,10 @@
showUpgrade: async function (extension) {
this.selectedExtension = extension
this.showUpgradeDialog = true
this.selectedRelease = null
this.selectedExtensionRepos = null
this.showUpgradeDialog = true
try {
const {data} = await LNbits.api.request(
'GET',
@ -648,6 +781,12 @@
if (release.isInstalled) {
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)
return repos
}, {})
@ -656,6 +795,122 @@
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) {
if (extension.installedRelease && extension.latestRelease) {
return (

View file

@ -62,6 +62,7 @@ from lnbits.extension_manager import (
ExtensionRelease,
InstallableExtension,
fetch_github_release_config,
fetch_release_payment_info,
get_valid_extensions,
)
from lnbits.helpers import generate_filter_params_openapi, url_for
@ -83,6 +84,7 @@ from ..crud import (
delete_wallet,
drop_extension_db,
get_dbversions,
get_installed_extension,
get_payments,
get_payments_history,
get_payments_paginated,
@ -796,7 +798,7 @@ async def api_install_extension(
access_token: Optional[str] = Depends(check_access_token),
):
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:
raise HTTPException(
@ -808,13 +810,17 @@ async def api_install_extension(
status_code=HTTPStatus.BAD_REQUEST, detail="Incompatible extension version"
)
release.payment_hash = data.payment_hash
ext_info = InstallableExtension(
id=data.ext_id, name=data.ext_id, installed_release=release, icon=release.icon
)
ext_info.download_archive()
try:
installed_ext = await get_installed_extension(data.ext_id)
ext_info.payments = installed_ext.payments if installed_ext else []
await ext_info.download_archive()
ext_info.extract_archive()
extension = Extension.from_installable_ext(ext_info)
@ -838,7 +844,8 @@ async def api_install_extension(
ext_info.nofiy_upgrade()
return extension
except AssertionError as e:
raise HTTPException(HTTPStatus.BAD_REQUEST, str(e))
except Exception as ex:
logger.warning(ex)
ext_info.clean_extension_files()
@ -907,6 +914,15 @@ async def get_extension_releases(ext_id: str):
ExtensionRelease
] = await InstallableExtension.get_extension_releases(ext_id)
installed_ext = await get_installed_extension(ext_id)
if not installed_ext:
return extension_releases
for release in extension_releases:
payment_info = installed_ext.find_existing_payment(release.pay_link)
if payment_info:
release.paid_sats = payment_info.amount
return extension_releases
except Exception as 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/v1/extension/release/{org}/{repo}/{tag_name}",
dependencies=[Depends(check_admin)],

View file

@ -1,16 +1,15 @@
import asyncio
import hashlib
import json
import os
import shutil
import sys
import zipfile
from http import HTTPStatus
from pathlib import Path
from typing import Any, List, NamedTuple, Optional, Tuple
from urllib import request
import httpx
from fastapi import HTTPException
from loguru import logger
from packaging import version
from pydantic import BaseModel
@ -33,6 +32,7 @@ class ExplicitRelease(BaseModel):
warning: Optional[str]
info_notification: Optional[str]
critical_notification: Optional[str]
pay_link: Optional[str]
def is_version_compatible(self):
if not self.min_lnbits_version:
@ -78,8 +78,15 @@ class ExtensionConfig(BaseModel):
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):
with request.urlopen(url) as dl_file:
with request.urlopen(url, timeout=60) as dl_file:
with open(save_path, "wb") as out_file:
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()
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:
if not path:
return ""
@ -260,6 +282,26 @@ class ExtensionRelease(BaseModel):
repo: 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
def from_github_release(
cls, source_repo: str, r: "GitHubRepoRelease"
@ -290,12 +332,13 @@ class ExtensionRelease(BaseModel):
is_version_compatible=e.is_version_compatible(),
warning=e.warning,
html_url=e.html_url,
pay_link=e.pay_link,
repo=e.repo,
icon=e.icon,
)
@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:
github_releases = await fetch_github_releases(org, repo)
return [
@ -318,6 +361,7 @@ class InstallableExtension(BaseModel):
featured = False
latest_release: Optional[ExtensionRelease] = None
installed_release: Optional[ExtensionRelease] = None
payments: List[ReleasePaymentInfo] = []
archive: Optional[str] = None
@property
@ -368,30 +412,32 @@ class InstallableExtension(BaseModel):
return self.installed_release.version
return ""
def download_archive(self):
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
if ext_zip_file.is_file():
os.remove(ext_zip_file)
try:
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:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Cannot fetch extension archive file",
)
raise AssertionError("Cannot fetch extension archive file")
archive_hash = file_hash(ext_zip_file)
if self.installed_release.hash and self.installed_release.hash != archive_hash:
# remove downloaded archive
if ext_zip_file.is_file():
os.remove(ext_zip_file)
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="File hash missmatch. Will not install.",
)
raise AssertionError("File hash missmatch. Will not install.")
def extract_archive(self):
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):
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
def from_row(cls, data: dict) -> "InstallableExtension":
meta = json.loads(data["meta"])
ext = InstallableExtension(**data)
if "installed_release" in meta:
ext.installed_release = ExtensionRelease(**meta["installed_release"])
if meta.get("payments"):
ext.payments = [ReleasePaymentInfo(**p) for p in meta["payments"]]
return ext
@classmethod
def from_rows(cls, rows: List[Any] = []) -> List["InstallableExtension"]:
return [InstallableExtension.from_row(row) for row in rows]
@classmethod
async def from_github_release(
cls, github_release: GitHubRelease
@ -565,17 +651,19 @@ class InstallableExtension(BaseModel):
try:
manifest = await fetch_manifest(url)
for r in manifest.repos:
if r.id == ext_id:
repo_releases = await ExtensionRelease.all_releases(
if r.id != ext_id:
continue
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
extension_releases += repo_releases
for e in manifest.extensions:
if e.id == ext_id:
extension_releases += [
ExtensionRelease.from_explicit_release(url, e)
]
if e.id != ext_id:
continue
explicit_release = ExtensionRelease.from_explicit_release(url, e)
await explicit_release.check_payment_requirements()
extension_releases.append(explicit_release)
except Exception as e:
logger.warning(f"Manifest {url} failed with '{str(e)}'")
@ -584,7 +672,7 @@ class InstallableExtension(BaseModel):
@classmethod
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"]:
all_releases: List[
ExtensionRelease
@ -592,7 +680,9 @@ class InstallableExtension(BaseModel):
selected_release = [
r
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
@ -602,6 +692,9 @@ class CreateExtension(BaseModel):
ext_id: str
archive: 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]:

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',
look_and_feel: 'Look and Feel',
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
// so the service worker reinitializes the cache
const CACHE_VERSION = 117
const CACHE_VERSION = 121
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => {

835
package-lock.json generated

File diff suppressed because it is too large Load diff