Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions astrbot/dashboard/api/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,24 @@ async def list_plugin_config_files_by_id(
)


@router.get("/plugins/config-files/preview")
def preview_plugin_config_image_by_id(
plugin_id: str = Query(...),
config_key: str = Query(...),
path: str = Query(...),
_auth: AuthContext = Depends(require_plugin_scope),
service: ConfigFileService = Depends(get_config_file_service),
):
# A synchronous route keeps image decoding off the ASGI event loop.
return Response(
service.preview_config_image(
name=plugin_id, key_path=config_key, rel_path=path
),
media_type="image/png",
headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
)


@router.post("/plugins/config-files")
async def upload_plugin_config_files_by_id(
request: Request,
Expand Down
92 changes: 92 additions & 0 deletions astrbot/dashboard/services/config_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import asyncio
import copy
import inspect
import io
import os
import traceback
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from PIL import Image, ImageOps

from astrbot.core import file_token_service, logger
from astrbot.core.computer import computer_client
from astrbot.core.computer.booters.local import LocalShellComponent
Expand Down Expand Up @@ -47,6 +50,8 @@
("dashboard", "totp", "recovery_code_hash"),
)
MAX_FILE_BYTES = 500 * 1024 * 1024
MAX_PREVIEW_FILE_BYTES = 10 * 1024 * 1024
MAX_PREVIEW_PIXELS = 20_000_000


def try_cast(value: Any, type_: str):
Expand Down Expand Up @@ -1314,6 +1319,93 @@ def list_config_files_from_dashboard_args(self, args) -> dict:
key_path=args.get("key"),
)

def preview_config_image(self, *, name: str, key_path: str, rel_path: str) -> bytes:
"""Render a bounded, metadata-free preview of a file field's image.

Args:
name: Registered plugin name.
key_path: File configuration field in the plugin's schema.
rel_path: Uploaded file path relative to the plugin data directory.

Returns:
A PNG thumbnail with a maximum edge of 1024 pixels.

Raises:
ValueError: The plugin, field, or path is invalid.
ApiError: The file is missing, too large, or not a supported image.
"""
_, name, key_path, _, config = self.resolve_config_file_scope(
scope="plugin", name=name, key_path=key_path
)
meta = _get_schema_item(getattr(config, "schema", None), key_path)
if not meta or meta.get("type") != "file":
raise ValueError("Config item not found or not file type")

normalized = _normalize_rel_path(rel_path)
folder = f"files/{_config_key_to_folder(key_path)}"
if (
rel_path.startswith(("/", "\\"))
or not normalized
or not normalized.startswith(f"{folder}/")
):
raise ValueError("Invalid path parameter")

try:
plugin_root = self._plugin_root_path(name)
target = self._safe_plugin_path(plugin_root, normalized)
if target is None:
raise ValueError("Invalid path parameter")
# Keep this boundary unresolved: a symlink for the field directory
# must not grant access to another field's files.
target.relative_to(plugin_root / folder)
except (OSError, RuntimeError, ValueError) as exc:
raise ValueError("Invalid path parameter") from exc

try:
if not target.exists():
raise ApiError("Image file not found", status_code=404)
if not target.is_file():
raise ValueError("Invalid path parameter")
with target.open("rb") as source:
data = source.read(MAX_PREVIEW_FILE_BYTES + 1)
except FileNotFoundError as exc:
raise ApiError("Image file not found", status_code=404) from exc
except OSError as exc:
raise ApiError("Image file cannot be read", status_code=503) from exc
if len(data) > MAX_PREVIEW_FILE_BYTES:
raise ApiError("Image file is too large to preview", status_code=413)
try:
with Image.open(io.BytesIO(data)) as image:
if image.format not in {
"PNG",
"JPEG",
"WEBP",
"GIF",
"BMP",
"DIB",
"TIFF",
"ICO",
}:
raise ApiError("Unsupported image format", status_code=415)
if image.width * image.height > MAX_PREVIEW_PIXELS:
raise ApiError("Image dimensions are too large", status_code=413)
# Animated images intentionally preview only the first frame.
preview = ImageOps.exif_transpose(image)
preview.thumbnail((1024, 1024))
preview = preview.convert(
"RGBA"
if "A" in preview.getbands() or "transparency" in preview.info
else "RGB"
)
preview.info.clear()
output = io.BytesIO()
preview.save(output, format="PNG")
return output.getvalue()
except Image.DecompressionBombError as exc:
raise ApiError("Image dimensions are too large", status_code=413) from exc
except (OSError, SyntaxError, EOFError) as exc:
raise ApiError("Image preview is unavailable", status_code=415) from exc

@staticmethod
def _allowed_file_extensions(meta: dict) -> list[str]:
file_types = meta.get("file_types")
Expand Down
13 changes: 12 additions & 1 deletion dashboard/src/api/generated/openapi-v1/sdk.gen.ts

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions dashboard/src/api/generated/openapi-v1/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,21 @@ export type DeletePluginConfigFileByIdResponse = (SuccessEnvelope);

export type DeletePluginConfigFileByIdError = unknown;

export type PreviewPluginConfigImageByIdData = {
query: {
config_key: string;
/**
* Uploaded path relative to the plugin data directory
*/
path: string;
plugin_id: string;
};
};

export type PreviewPluginConfigImageByIdResponse = ((Blob | File));

export type PreviewPluginConfigImageByIdError = (unknown);

export type GetPluginReadmeByIdData = {
query: {
plugin_id: string;
Expand Down
7 changes: 7 additions & 0 deletions dashboard/src/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,13 @@ export const pluginApi = {
}),
);
},
previewConfigImage(pluginId: string, configKey: string, path: string, signal: AbortSignal) {
return openApiV1.previewPluginConfigImageById({
query: { plugin_id: pluginId, config_key: configKey, path },
responseType: 'blob',
signal,
}) as Promise<AxiosResponse<Blob>>;
},
uploadConfigFiles(pluginId: string, configKey: string, formData: FormData) {
return typed<any>(
openApiV1.uploadPluginConfigFilesById({
Expand Down
112 changes: 112 additions & 0 deletions dashboard/src/components/shared/ConfigImagePreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<template>
<div class="config-image-preview">
<v-btn v-if="imageUrl" class="preview-button" variant="text" width="48" height="48"
:aria-label="tm('fileUpload.previewImage', { name: fileName })" @click.stop="previewOpen = true">
<img :src="imageUrl" :alt="fileName" class="thumbnail" />
</v-btn>
<v-progress-circular v-else-if="loading" indeterminate size="20"
:aria-label="tm('fileUpload.loadingPreview')" />
<span v-else role="img" :aria-label="tm('fileUpload.previewUnavailable')"
:title="tm('fileUpload.previewUnavailable')" class="text-medium-emphasis">
<v-icon size="24">mdi-image-outline</v-icon>
</span>

<v-dialog v-model="previewOpen" max-width="960">
<v-card variant="flat">
<v-card-title class="text-h3 pa-4 pb-0 pl-6 d-flex align-center">
<span class="text-truncate">{{ fileName }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" :aria-label="tm('fileUpload.closePreview')"
@click="previewOpen = false" />
</v-card-title>
<v-card-text>
<img v-if="imageUrl" :src="imageUrl" :alt="fileName" class="full-preview" />
</v-card-text>
</v-card>
</v-dialog>
</div>
</template>

<script setup>
import { computed, ref, watch } from 'vue'
import { pluginApi } from '@/api/v1'
import { useModuleI18n } from '@/i18n/composables'

const props = defineProps({
pluginName: { type: String, required: true },
configKey: { type: String, required: true },
path: { type: String, required: true }
})

const { tm } = useModuleI18n('features/config')
const fileName = computed(() => props.path.split('/').pop() || props.path)
const imageUrl = ref('')
const loading = ref(false)
const previewOpen = ref(false)

watch(
() => [props.pluginName, props.configKey, props.path],
async (_, __, onCleanup) => {
const controller = new AbortController()
let active = true
let objectUrl = ''
imageUrl.value = ''
previewOpen.value = false
loading.value = true
onCleanup(() => {
active = false
controller.abort()
if (objectUrl) URL.revokeObjectURL(objectUrl)
})
try {
const response = await pluginApi.previewConfigImage(
props.pluginName, props.configKey, props.path, controller.signal
)
if (active && response.data instanceof Blob && response.data.type === 'image/png') {
objectUrl = URL.createObjectURL(response.data)
imageUrl.value = objectUrl
} else if (active) {
console.warn('Image preview response was not a PNG')
}
} catch (error) {
const status = error?.response?.status
// Do not log Axios errors: their request config can contain credentials.
if (active && !controller.signal.aborted && ![400, 404, 413, 415].includes(status)) {
console.warn('Image preview request failed', { status: status || 'network' })
}
} finally {
if (active) loading.value = false
}
},
{ immediate: true }
)
</script>

<style scoped>
.config-image-preview {
width: 56px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}

.preview-button {
min-width: 48px;
padding: 0;
}

.thumbnail {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 4px;
}

.full-preview {
display: block;
width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>
19 changes: 18 additions & 1 deletion dashboard/src/components/shared/FileConfigItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@
<v-list density="compact" lines="one">
<v-list-item v-for="item in mergedFileItems" :key="item.path">
<template #prepend>
<v-icon size="18">mdi-file</v-icon>
<div class="file-thumbnail mr-3">
<v-lazy v-if="dialog && item.status !== 'missing' && /\.(a?png|jpe?g|jpe|jfif|webp|gif|bmp|dib|tiff?|ico)$/i.test(item.path)"
:key="`${pluginName}:${configKey}:${item.path}:${previewRevision}`" min-height="48" width="56">
<ConfigImagePreview :plugin-name="pluginName" :config-key="configKey" :path="item.path" />
</v-lazy>
<v-icon v-else size="18">mdi-file</v-icon>
</div>
</template>
<v-list-item-title class="file-name">
{{ getDisplayName(item.path) }}
Expand Down Expand Up @@ -77,6 +83,7 @@ import { computed, ref, watch } from 'vue'
import { pluginApi } from '@/api/v1'
import { useToast } from '@/utils/toast'
import { useModuleI18n } from '@/i18n/composables'
import ConfigImagePreview from './ConfigImagePreview.vue'

const props = defineProps({
modelValue: {
Expand Down Expand Up @@ -109,6 +116,7 @@ const loadingFiles = ref(false)
const MAX_FILE_BYTES = 500 * 1024 * 1024
const MAX_FILE_MB = 500
const directoryFiles = ref([])
const previewRevision = ref(0)

const fileList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
Expand Down Expand Up @@ -277,6 +285,7 @@ const uploadFiles = async (files) => {
const updatedDirectory = new Set(directoryFiles.value)
uploaded.forEach((path) => updatedDirectory.add(path))
directoryFiles.value = Array.from(updatedDirectory)
previewRevision.value += 1
toast.success(tm('fileUpload.uploadSuccess', { count: uploaded.length }))
}

Expand Down Expand Up @@ -382,6 +391,14 @@ watch(
word-break: break-word;
}

.file-thumbnail {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 48px;
}

.upload-item {
cursor: pointer;
transition: background 0.2s ease;
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,10 @@
"fileUpload": {
"button": "Manage Files",
"dialogTitle": "Uploaded Files",
"previewImage": "Preview image: {name}",
"closePreview": "Close image preview",
"loadingPreview": "Loading image preview",
"previewUnavailable": "Image preview unavailable",
"dropzone": "Upload new file",
"allowedTypes": "Allowed types: {types}",
"empty": "No files uploaded",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/i18n/locales/ja-JP/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@
"title": "設定ファイルを編集"
},
"fileUpload": {
"previewImage": "画像をプレビュー:{name}",
"closePreview": "画像プレビューを閉じる",
"loadingPreview": "画像プレビューを読み込み中",
"previewUnavailable": "画像をプレビューできません",
"button": "ファイルを管理",
"dialogTitle": "アップロード済みファイル",
"dropzone": "新しいファイルをアップロード",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@
"title": "Редактирование файла"
},
"fileUpload": {
"previewImage": "Просмотр изображения: {name}",
"closePreview": "Закрыть просмотр изображения",
"loadingPreview": "Загрузка изображения",
"previewUnavailable": "Предпросмотр изображения недоступен",
"button": "Файлы",
"dialogTitle": "Загруженные файлы",
"dropzone": "Загрузить файлы",
Expand Down
4 changes: 4 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@
"title": "编辑配置文件"
},
"fileUpload": {
"previewImage": "预览图片:{name}",
"closePreview": "关闭图片预览",
"loadingPreview": "正在加载图片预览",
"previewUnavailable": "图片无法预览",
"button": "管理文件",
"dialogTitle": "已上传文件",
"dropzone": "上传新文件",
Expand Down
1 change: 1 addition & 0 deletions docs/en/dev/openapi-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ Manage plugins, plugin configuration, plugin sources, and marketplace data.
| `GET` | `/api/v1/plugins/config-files` | — |
| `POST` | `/api/v1/plugins/config-files` | — |
| `DELETE` | `/api/v1/plugins/config-files` | — |
| `GET` | `/api/v1/plugins/config-files/preview` | — |
| `GET` | `/api/v1/plugins/config/schema` | — |
| `PATCH` | `/api/v1/plugins/enabled` | — |
| `GET` | `/api/v1/plugins/extensions/{plugin_path}` | — |
Expand Down
Loading