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
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.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):
row = await (conn or db).fetchone(
"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
# -------

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
// so the service worker reinitializes the cache
const CACHE_VERSION = 35
const CACHE_VERSION = 37
const CURRENT_CACHE = `lnbits-${CACHE_VERSION}-`
const getApiKey = request => {

View File

@ -256,6 +256,17 @@
{%raw%}{{ $t('confirm_continue') }}{%endraw%}
</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">
<q-btn outline color="grey" @click="uninstallExtension()"
>{%raw%}{{ $t('uninstall_confirm') }}{%endraw%}</q-btn
@ -267,6 +278,32 @@
</q-card>
</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-card class="q-pa-lg lnbits__dialog-card">
<q-card-section>
@ -395,6 +432,13 @@
>
{%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">
{%raw%}{{ $t('close') }}{%endraw%}</q-btn
>
@ -413,8 +457,11 @@
filteredExtensions: null,
showUninstallDialog: false,
showUpgradeDialog: false,
showDropDbDialog: false,
dropDbExtensionId: '',
selectedExtension: null,
selectedExtensionRepos: null,
uninstallAndDropDb: false,
maxStars: 5,
user: null
}
@ -503,6 +550,40 @@
this.filteredExtensions = this.extensions.concat([])
this.handleTabChanged('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 => {
LNbits.utils.notifyApiError(err)
@ -531,6 +612,11 @@
showUninstall: function () {
this.showUpgradeDialog = false
this.showUninstallDialog = true
this.uninstallAndDropDb = false
},
showDropDb: function () {
this.showDropDbDialog = true
},
showUpgrade: async function (extension) {

View File

@ -63,8 +63,10 @@ from .. import core_app, core_app_extra, db
from ..crud import (
add_installed_extension,
create_tinyurl,
delete_dbversion,
delete_installed_extension,
delete_tinyurl,
drop_extension_db,
get_dbversions,
get_payments,
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

View File

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

View File

@ -303,6 +303,21 @@ class Database(Compat):
async def reuse_conn(self, conn: Connection):
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):
GT = "gt"

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,5 @@
window.localisation.en = {
confirm: 'Yes',
server: 'Server',
theme: 'Theme',
funding: 'Funding',
@ -86,6 +87,7 @@ window.localisation.en = {
manage_extension_details: 'Install/uninstall extension',
install: 'Install',
uninstall: 'Uninstall',
drop_db: 'Remove Data',
open: 'Open',
enable: 'Enable',
enable_extension_details: 'Enable extension for current user',
@ -105,6 +107,11 @@ window.localisation.en = {
extension_uninstall_warning:
'You are about to remove the extension for all users.',
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',
payment_hash: 'Payment Hash',