Add option to drop extension db at un-install time or later (#1746)

* chore: remove un-used file
* feat: allow extension DB clean-up
* feat: i18n and bundle update
* chore: code format
* fix: button color
* chore: delete temp file
* chore: fix merge conflicts
* chore: add extra log
* chore: bump CACHE_VERSION to `37`
This commit is contained in:
Vlad Stan 2023-06-15 16:22:18 +02:00 committed by GitHub
parent 95281eba8c
commit 8c0e7725de
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 170 additions and 46 deletions

View file

@ -7,7 +7,7 @@ from uuid import UUID, uuid4
import shortuuid import shortuuid
from lnbits import bolt11 from lnbits import bolt11
from lnbits.db import Connection, Filters, Page from lnbits.db import Connection, Database, Filters, Page
from lnbits.extension_manager import InstallableExtension from lnbits.extension_manager import InstallableExtension
from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings from lnbits.settings import AdminSettings, EditableSettings, SuperSettings, settings
@ -142,6 +142,25 @@ async def delete_installed_extension(
) )
async def drop_extension_db(*, ext_id: str, conn: Optional[Connection] = None) -> None:
db_version = await (conn or db).fetchone(
"SELECT * FROM dbversions WHERE db = ?", (ext_id,)
)
# Check that 'ext_id' is a valid extension id and not a malicious string
assert db_version, f"Extension '{ext_id}' db version cannot be found"
is_file_based_db = await Database.clean_ext_db_files(ext_id)
if is_file_based_db:
return
# String formatting is required, params are not accepted for 'DROP SCHEMA'.
# The `ext_id` value is verified above.
await (conn or db).execute(
f"DROP SCHEMA IF EXISTS {ext_id} CASCADE",
(),
)
async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None): async def get_installed_extension(ext_id: str, conn: Optional[Connection] = None):
row = await (conn or db).fetchone( row = await (conn or db).fetchone(
"SELECT * FROM installed_extensions WHERE id = ?", "SELECT * FROM installed_extensions WHERE id = ?",
@ -781,6 +800,15 @@ async def update_migration_version(conn, db_name, version):
) )
async def delete_dbversion(*, ext_id: str, conn: Optional[Connection] = None) -> None:
await (conn or db).execute(
"""
DELETE FROM dbversions WHERE db = ?
""",
(ext_id,),
)
# tinyurl # tinyurl
# ------- # -------

View file

@ -1,43 +0,0 @@
new Vue({
el: '#vue',
data: function () {
return {
searchTerm: '',
filteredExtensions: [],
maxStars: 5,
user: null
}
},
mounted() {
this.filteredExtensions = this.g.extensions
},
watch: {
searchTerm(term) {
// Reset the filter
this.filteredExtensions = this.g.extensions
if (term !== '') {
// Filter the extensions list
function extensionNameContains(searchTerm) {
return function (extension) {
return (
extension.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
extension.shortDescription
.toLowerCase()
.includes(searchTerm.toLowerCase())
)
}
}
this.filteredExtensions = this.filteredExtensions.filter(
extensionNameContains(term)
)
}
}
},
created() {
if (window.user) {
this.user = LNbits.map.user(window.user)
}
},
mixins: [windowMixin]
})

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 = 35 const CACHE_VERSION = 37
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-` const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => { const getApiKey = request => {

View file

@ -256,6 +256,17 @@
{%raw%}{{ $t('confirm_continue') }}{%endraw%} {%raw%}{{ $t('confirm_continue') }}{%endraw%}
</p> </p>
<div class="row q-mt-lg">
<q-checkbox
v-model="uninstallAndDropDb"
value="false"
label="Cleanup database tables"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
{%raw%}{{ $t('extension_db_drop_info') }}{%endraw%}
</q-tooltip>
</q-checkbox>
</div>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn outline color="grey" @click="uninstallExtension()" <q-btn outline color="grey" @click="uninstallExtension()"
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn >{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
@ -267,6 +278,32 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="showDropDbDialog">
<q-card v-if="selectedExtension" class="q-pa-lg">
<h6 class="q-my-md text-primary">{%raw%}{{ $t('warning') }}{%endraw%}</h6>
<p>{%raw%}{{ $t('extension_db_drop_warning') }}{%endraw%} <br /></p>
<q-input
v-model="dropDbExtensionId"
:label="selectedExtension.id"
></q-input>
<br />
<p>{%raw%}{{ $t('confirm_continue') }}{%endraw%}</p>
<div class="row q-mt-lg">
<q-btn
:disable="dropDbExtensionId !== selectedExtension.id"
outline
color="red"
@click="dropExtensionDb()"
>{%raw%}{{ $t('confirm') }}{%endraw%}</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>{%raw%}{{ $t('cancel') }}{%endraw%}</q-btn
>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="showUpgradeDialog"> <q-dialog v-model="showUpgradeDialog">
<q-card class="q-pa-lg lnbits__dialog-card"> <q-card class="q-pa-lg lnbits__dialog-card">
<q-card-section> <q-card-section>
@ -395,6 +432,13 @@
> >
{%raw%}{{ $t('uninstall') }}{%endraw%}</q-btn {%raw%}{{ $t('uninstall') }}{%endraw%}</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"> <q-btn v-close-popup flat color="grey" class="q-ml-auto">
{%raw%}{{ $t('close') }}{%endraw%}</q-btn {%raw%}{{ $t('close') }}{%endraw%}</q-btn
> >
@ -413,8 +457,11 @@
filteredExtensions: null, filteredExtensions: null,
showUninstallDialog: false, showUninstallDialog: false,
showUpgradeDialog: false, showUpgradeDialog: false,
showDropDbDialog: false,
dropDbExtensionId: '',
selectedExtension: null, selectedExtension: null,
selectedExtensionRepos: null, selectedExtensionRepos: null,
uninstallAndDropDb: false,
maxStars: 5, maxStars: 5,
user: null user: null
} }
@ -503,6 +550,40 @@
this.filteredExtensions = this.extensions.concat([]) this.filteredExtensions = this.extensions.concat([])
this.handleTabChanged('installed') this.handleTabChanged('installed')
this.tab = 'installed' this.tab = 'installed'
this.$q.notify({
type: 'positive',
message: 'Extension uninstalled!'
})
if (this.uninstallAndDropDb) {
this.showDropDb()
}
})
.catch(err => {
LNbits.utils.notifyApiError(err)
extension.inProgress = false
})
},
dropExtensionDb: async function () {
const extension = this.selectedExtension
this.showUpgradeDialog = false
this.showDropDbDialog = false
this.dropDbExtensionId = ''
extension.inProgress = true
LNbits.api
.request(
'DELETE',
`/api/v1/extension/${extension.id}/db?usr=${this.g.user.id}`,
this.g.user.wallets[0].adminkey
)
.then(response => {
extension.installedRelease = null
extension.inProgress = false
extension.hasDatabaseTables = false
this.$q.notify({
type: 'positive',
message: 'Extension DB deleted!'
})
}) })
.catch(err => { .catch(err => {
LNbits.utils.notifyApiError(err) LNbits.utils.notifyApiError(err)
@ -531,6 +612,11 @@
showUninstall: function () { showUninstall: function () {
this.showUpgradeDialog = false this.showUpgradeDialog = false
this.showUninstallDialog = true this.showUninstallDialog = true
this.uninstallAndDropDb = false
},
showDropDb: function () {
this.showDropDbDialog = true
}, },
showUpgrade: async function (extension) { showUpgrade: async function (extension) {

View file

@ -63,8 +63,10 @@ from .. import core_app, core_app_extra, db
from ..crud import ( from ..crud import (
add_installed_extension, add_installed_extension,
create_tinyurl, create_tinyurl,
delete_dbversion,
delete_installed_extension, delete_installed_extension,
delete_tinyurl, delete_tinyurl,
drop_extension_db,
get_dbversions, get_dbversions,
get_payments, get_payments,
get_payments_paginated, get_payments_paginated,
@ -902,6 +904,32 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
) )
@core_app.delete(
"/api/v1/extension/{ext_id}/db",
dependencies=[Depends(check_admin)],
)
async def delete_extension_db(ext_id: str):
try:
db_version = (await get_dbversions()).get(ext_id, None)
if not db_version:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=f"Unknown extension id: {ext_id}",
)
await drop_extension_db(ext_id=ext_id)
await delete_dbversion(ext_id=ext_id)
logger.success(f"Database removed for extension '{ext_id}'")
except HTTPException as ex:
logger.error(ex)
raise ex
except Exception as ex:
logger.error(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail=f"Cannot delete data for extension '{ext_id}'",
)
# TINYURL # TINYURL

View file

@ -23,6 +23,7 @@ from ..crud import (
create_wallet, create_wallet,
delete_wallet, delete_wallet,
get_balance_check, get_balance_check,
get_dbversions,
get_inactive_extensions, get_inactive_extensions,
get_installed_extensions, get_installed_extensions,
get_user, get_user,
@ -113,6 +114,7 @@ async def extensions_install(
all_extensions = list(map(lambda e: e.code, get_valid_extensions())) all_extensions = list(map(lambda e: e.code, get_valid_extensions()))
inactive_extensions = await get_inactive_extensions() inactive_extensions = await get_inactive_extensions()
db_version = await get_dbversions()
extensions = list( extensions = list(
map( map(
lambda ext: { lambda ext: {
@ -124,6 +126,7 @@ async def extensions_install(
"isFeatured": ext.featured, "isFeatured": ext.featured,
"dependencies": ext.dependencies, "dependencies": ext.dependencies,
"isInstalled": ext.id in installed_exts_ids, "isInstalled": ext.id in installed_exts_ids,
"hasDatabaseTables": ext.id in db_version,
"isAvailable": ext.id in all_extensions, "isAvailable": ext.id in all_extensions,
"isActive": ext.id not in inactive_extensions, "isActive": ext.id not in inactive_extensions,
"latestRelease": dict(ext.latest_release) "latestRelease": dict(ext.latest_release)

View file

@ -303,6 +303,21 @@ class Database(Compat):
async def reuse_conn(self, conn: Connection): async def reuse_conn(self, conn: Connection):
yield conn yield conn
@classmethod
async def clean_ext_db_files(self, ext_id: str) -> bool:
"""
If the extension DB is stored directly on the filesystem (like SQLite) then delete the files and return True.
Otherwise do nothing and return False.
"""
if DB_TYPE == SQLITE:
db_file = os.path.join(settings.lnbits_data_folder, f"ext_{ext_id}.sqlite3")
if os.path.isfile(db_file):
os.remove(db_file)
return True
return False
class Operator(Enum): class Operator(Enum):
GT = "gt" GT = "gt"

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,5 @@
window.localisation.en = { window.localisation.en = {
confirm: 'Yes',
server: 'Server', server: 'Server',
theme: 'Theme', theme: 'Theme',
funding: 'Funding', funding: 'Funding',
@ -86,6 +87,7 @@ window.localisation.en = {
manage_extension_details: 'Install/uninstall extension', manage_extension_details: 'Install/uninstall extension',
install: 'Install', install: 'Install',
uninstall: 'Uninstall', uninstall: 'Uninstall',
drop_db: 'Remove Data',
open: 'Open', open: 'Open',
enable: 'Enable', enable: 'Enable',
enable_extension_details: 'Enable extension for current user', enable_extension_details: 'Enable extension for current user',
@ -105,6 +107,11 @@ window.localisation.en = {
extension_uninstall_warning: extension_uninstall_warning:
'You are about to remove the extension for all users.', 'You are about to remove the extension for all users.',
uninstall_confirm: 'Yes, Uninstall', uninstall_confirm: 'Yes, Uninstall',
extension_db_drop_info:
'All data for the extension will be permanently deleted. There is no way to undo this operation!',
extension_db_drop_warning:
'You are about to remove all data for the extension. Please type the extension name to continue:',
extension_min_lnbits_version: 'This release requires at least LNbits version', extension_min_lnbits_version: 'This release requires at least LNbits version',
payment_hash: 'Payment Hash', payment_hash: 'Payment Hash',