Adds image library for admins (#3066)
This commit is contained in:
parent
35f7821183
commit
63adcb6780
7 changed files with 233 additions and 3 deletions
|
|
@ -167,6 +167,10 @@ def create_app() -> FastAPI:
|
||||||
static = StaticFiles(directory=static_path)
|
static = StaticFiles(directory=static_path)
|
||||||
app.mount("/static", static, name="static")
|
app.mount("/static", static, name="static")
|
||||||
|
|
||||||
|
images_path = os.path.abspath(os.path.join(settings.lnbits_data_folder, "images"))
|
||||||
|
os.makedirs(images_path, exist_ok=True)
|
||||||
|
app.mount("/library", StaticFiles(directory=images_path), name="library")
|
||||||
|
|
||||||
g().base_url = f"http://{settings.host}:{settings.port}"
|
g().base_url = f"http://{settings.host}:{settings.port}"
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|
|
||||||
60
lnbits/core/templates/admin/_tab_library.html
Normal file
60
lnbits/core/templates/admin/_tab_library.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<q-tab-panel name="library">
|
||||||
|
<q-card-section class="q-pa-none">
|
||||||
|
<h6 class="q-my-none q-mb-sm">
|
||||||
|
<span v-text="$t('image_library')"></span>
|
||||||
|
</h6>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="Add Image"
|
||||||
|
@click="$refs.imageInput.click()"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="imageInput"
|
||||||
|
accept="image/png, image/jpeg, image/gif"
|
||||||
|
style="display: none"
|
||||||
|
@change="onImageInput"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
<div class="row q-col-gutter-sm q-pa-sm">
|
||||||
|
<div
|
||||||
|
v-for="image in library_images"
|
||||||
|
:key="image.filename"
|
||||||
|
class="col-6 col-sm-4 col-md-3 col-lg-2"
|
||||||
|
style="max-width: 200px"
|
||||||
|
>
|
||||||
|
<q-card class="q-mb-sm">
|
||||||
|
<q-img :src="image.url" style="height: 150px" />
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
class="q-pt-md q-pb-md row items-center justify-between"
|
||||||
|
>
|
||||||
|
<small
|
||||||
|
><div class="text-caption ellipsis" v-text="image.filename"></div
|
||||||
|
></small>
|
||||||
|
<q-btn
|
||||||
|
dense
|
||||||
|
flat
|
||||||
|
size="sm"
|
||||||
|
icon="content_copy"
|
||||||
|
@click="copyText(image.url)"
|
||||||
|
:title="$t('copy')"
|
||||||
|
><q-tooltip>Copy image link</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
<q-btn
|
||||||
|
color="negative"
|
||||||
|
icon="delete"
|
||||||
|
flat
|
||||||
|
size="sm"
|
||||||
|
@click="deleteImage(image.filename)"
|
||||||
|
><q-tooltip>Delete image</q-tooltip></q-btn
|
||||||
|
>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="library_images.length === 0" class="q-pa-xl">
|
||||||
|
<div class="text-subtitle2 text-grey">No images uploaded yet.</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
@ -149,6 +149,14 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||||
><q-tooltip v-if="!$q.screen.gt.sm"
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
><span v-text="$t('audit')"></span></q-tooltip
|
><span v-text="$t('audit')"></span></q-tooltip
|
||||||
></q-tab>
|
></q-tab>
|
||||||
|
<q-tab
|
||||||
|
name="library"
|
||||||
|
icon="image"
|
||||||
|
:label="$q.screen.gt.sm ? $t('library') : null"
|
||||||
|
@update="val => tab = val.name"
|
||||||
|
><q-tooltip v-if="!$q.screen.gt.sm"
|
||||||
|
><span v-text="$t('library')"></span></q-tooltip
|
||||||
|
></q-tab>
|
||||||
<q-tab
|
<q-tab
|
||||||
style="word-break: break-all"
|
style="word-break: break-all"
|
||||||
name="site_customisation"
|
name="site_customisation"
|
||||||
|
|
@ -177,7 +185,8 @@ import window_vars with context %} {% block scripts %} {{ window_vars(user) }}
|
||||||
"admin/_tab_extensions.html" %} {% include
|
"admin/_tab_extensions.html" %} {% include
|
||||||
"admin/_tab_notifications.html" %} {% include
|
"admin/_tab_notifications.html" %} {% include
|
||||||
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
"admin/_tab_security.html" %} {% include "admin/_tab_theme.html"
|
||||||
%}{% include "admin/_tab_audit.html"%}
|
%}{% include "admin/_tab_audit.html"%}{% include
|
||||||
|
"admin/_tab_library.html"%}
|
||||||
</q-tab-panels>
|
</q-tab-panels>
|
||||||
</q-form>
|
</q-form>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
|
import glob
|
||||||
|
import imghdr
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
from io import BytesIO
|
||||||
from shutil import make_archive
|
from shutil import make_archive
|
||||||
from subprocess import Popen
|
from subprocess import Popen
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
import shortuuid
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from lnbits.core.models import User
|
from lnbits.core.models import User
|
||||||
|
|
@ -27,6 +31,7 @@ from .. import core_app_extra
|
||||||
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
from ..crud import delete_admin_settings, get_admin_settings, update_admin_settings
|
||||||
|
|
||||||
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
admin_router = APIRouter(tags=["Admin UI"], prefix="/admin")
|
||||||
|
file_upload = File(...)
|
||||||
|
|
||||||
|
|
||||||
@admin_router.get(
|
@admin_router.get(
|
||||||
|
|
@ -159,3 +164,91 @@ async def api_download_backup() -> FileResponse:
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
path=f"{last_filename}.zip", filename=filename, media_type="application/zip"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.post(
|
||||||
|
"/api/v1/images",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def upload_image(file: UploadFile = file_upload):
|
||||||
|
if not file or not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="No file provided")
|
||||||
|
|
||||||
|
ext = file.filename.split(".")[-1].lower()
|
||||||
|
if ext not in {"png", "jpg", "jpeg", "gif"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported file type")
|
||||||
|
|
||||||
|
contents = BytesIO()
|
||||||
|
total_size = 0
|
||||||
|
max_size = 500000
|
||||||
|
while chunk := await file.read(1024 * 1024):
|
||||||
|
total_size += len(chunk)
|
||||||
|
if total_size > max_size:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413, detail=f"File too large ({max_size / 1000} KB max)"
|
||||||
|
)
|
||||||
|
contents.write(chunk)
|
||||||
|
|
||||||
|
contents.seek(0)
|
||||||
|
|
||||||
|
kind = imghdr.what(None, h=contents.read(512))
|
||||||
|
if kind not in {"png", "jpeg", "gif"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid image file")
|
||||||
|
contents.seek(0)
|
||||||
|
|
||||||
|
filename = f"{shortuuid.uuid()[:5]}.{ext}"
|
||||||
|
image_folder = os.path.join(settings.lnbits_data_folder, "images")
|
||||||
|
file_path = os.path.join(image_folder, filename)
|
||||||
|
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents.read())
|
||||||
|
|
||||||
|
return {"filename": filename, "url": f"{settings.lnbits_baseurl}library/{filename}"}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get(
|
||||||
|
"/api/v1/images",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def list_uploaded_images():
|
||||||
|
image_folder = os.path.join(settings.lnbits_data_folder, "images")
|
||||||
|
if not os.path.exists(image_folder):
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = glob.glob(os.path.join(image_folder, "*"))
|
||||||
|
images = []
|
||||||
|
|
||||||
|
for file_path in files:
|
||||||
|
if os.path.isfile(file_path):
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
images.append(
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"url": f"{settings.lnbits_baseurl}library/{filename}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.delete(
|
||||||
|
"/api/v1/images/{filename}",
|
||||||
|
status_code=HTTPStatus.OK,
|
||||||
|
dependencies=[Depends(check_admin)],
|
||||||
|
)
|
||||||
|
async def delete_uploaded_image(
|
||||||
|
filename: str = Path(..., description="Name of the image file to delete")
|
||||||
|
):
|
||||||
|
image_folder = os.path.join(settings.lnbits_data_folder, "images")
|
||||||
|
file_path = os.path.join(image_folder, filename)
|
||||||
|
|
||||||
|
# Prevent dir traversal attack
|
||||||
|
if not os.path.abspath(file_path).startswith(os.path.abspath(image_folder)):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
|
||||||
|
os.remove(file_path)
|
||||||
|
return {"status": "success", "message": f"{filename} deleted"}
|
||||||
|
|
|
||||||
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -27,6 +27,7 @@ window.localisation.en = {
|
||||||
close_channel: 'Close Channel',
|
close_channel: 'Close Channel',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
restart: 'Restart server',
|
restart: 'Restart server',
|
||||||
|
image_library: 'Image Library',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
save_tooltip: 'Save your changes',
|
save_tooltip: 'Save your changes',
|
||||||
credit_debit: 'Credit / Debit',
|
credit_debit: 'Credit / Debit',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ window.AdminPageLogic = {
|
||||||
return {
|
return {
|
||||||
settings: {},
|
settings: {},
|
||||||
logs: [],
|
logs: [],
|
||||||
|
library_images: [],
|
||||||
serverlogEnabled: false,
|
serverlogEnabled: false,
|
||||||
lnbits_theme_options: [
|
lnbits_theme_options: [
|
||||||
'classic',
|
'classic',
|
||||||
|
|
@ -140,6 +141,7 @@ window.AdminPageLogic = {
|
||||||
async created() {
|
async created() {
|
||||||
await this.getSettings()
|
await this.getSettings()
|
||||||
await this.getAudit()
|
await this.getAudit()
|
||||||
|
await this.getUploadedImages()
|
||||||
this.balance = +'{{ balance|safe }}'
|
this.balance = +'{{ balance|safe }}'
|
||||||
const hash = window.location.hash.replace('#', '')
|
const hash = window.location.hash.replace('#', '')
|
||||||
if (hash === 'exchange_providers') {
|
if (hash === 'exchange_providers') {
|
||||||
|
|
@ -552,6 +554,67 @@ window.AdminPageLogic = {
|
||||||
.catch(LNbits.utils.notifyApiError)
|
.catch(LNbits.utils.notifyApiError)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
onImageInput(e) {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (file) {
|
||||||
|
this.uploadImage(file)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async uploadImage(file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api.request(
|
||||||
|
'POST',
|
||||||
|
'/admin/api/v1/images',
|
||||||
|
this.g.user.wallets[0].adminkey,
|
||||||
|
formData,
|
||||||
|
{headers: {'Content-Type': 'multipart/form-data'}}
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Image uploaded!',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
await this.getUploadedImages()
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getUploadedImages() {
|
||||||
|
try {
|
||||||
|
const response = await LNbits.api.request(
|
||||||
|
'GET',
|
||||||
|
'/admin/api/v1/images',
|
||||||
|
this.g.user.wallets[0].inkey
|
||||||
|
)
|
||||||
|
this.library_images = response.data
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteImage(filename) {
|
||||||
|
LNbits.utils
|
||||||
|
.confirmDialog('Are you sure you want to delete this image?')
|
||||||
|
.onOk(async () => {
|
||||||
|
try {
|
||||||
|
await LNbits.api.request(
|
||||||
|
'DELETE',
|
||||||
|
`/admin/api/v1/images/${filename}`,
|
||||||
|
this.g.user.wallets[0].adminkey
|
||||||
|
)
|
||||||
|
this.$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'Image deleted!',
|
||||||
|
icon: null
|
||||||
|
})
|
||||||
|
await this.getUploadedImages()
|
||||||
|
} catch (error) {
|
||||||
|
LNbits.utils.notifyApiError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
downloadBackup() {
|
downloadBackup() {
|
||||||
window.open('/admin/api/v1/backup', '_blank')
|
window.open('/admin/api/v1/backup', '_blank')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue