Adds image library for admins (#3066)

This commit is contained in:
Arc 2025-03-27 09:16:20 +00:00 committed by GitHub
parent 35f7821183
commit 63adcb6780
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 233 additions and 3 deletions

View file

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

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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