feat(media): support external https image urls for entities
Allow entities to use full https:// URLs as their image path Validate external URLs to prevent http://, data:, or credentials Update API responses and frontend components to handle external sources
This commit is contained in:
@@ -379,7 +379,7 @@
|
||||
- 物品图标
|
||||
- 栖息地
|
||||
- 上传图片只支持 `png`、`jpg/jpeg`、`webp`、`gif`。
|
||||
- 上传图片由服务端保存到受控上传目录,不接受任意外部 URL,也不信任客户端传入的最终文件路径。
|
||||
- 上传图片由服务端保存到受控上传目录,不信任客户端传入的最终文件路径;实体当前图片也可引用完整的 `https://` 外部图片 URL,但不接受 `http://`、`data:`、`javascript:` 或带用户名 / 密码的 URL。
|
||||
- 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例:
|
||||
- `items/甜蜜蜜/20260501002000.png`
|
||||
- `pokemon/Pikachu/20260501002000.png`
|
||||
@@ -395,7 +395,8 @@
|
||||
- `byte_size`
|
||||
- `created_by_user_id`
|
||||
- `created_at`
|
||||
- 实体表只保存当前显示图片的相对路径;历史上传记录不会因为切换当前图片而删除。
|
||||
- 实体表保存当前显示图片引用:上传图片相对路径、受支持静态资源路径或完整 `https://` 外部图片 URL;历史上传记录不会因为切换当前图片而删除。
|
||||
- 公共实体 API 返回图片时包含当前图片引用 `path` 和可直接展示的 `url`;完整外部 URL 的 `path` 和 `url` 相同,不再拼接受控上传或静态资源域名。
|
||||
- 公共 API 对外返回图片上传历史只包含:`id`、`path`、`url`、`uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联,只能在受保护编辑接口返回 `entityId`。
|
||||
- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。
|
||||
- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
|
||||
import { pool, query, queryOne } from './db.ts';
|
||||
import {
|
||||
isExternalImageUrl,
|
||||
isUploadImagePath,
|
||||
linkEntityImageUpload,
|
||||
listEntityImageUploads,
|
||||
@@ -192,7 +193,7 @@ type PokemonImage = {
|
||||
version: string;
|
||||
variant: string;
|
||||
description: string;
|
||||
source?: 'sprite' | 'upload';
|
||||
source?: 'sprite' | 'upload' | 'external';
|
||||
};
|
||||
|
||||
type EntityImageValue = {
|
||||
@@ -846,6 +847,10 @@ function sqlLiteral(value: string): string {
|
||||
function uploadedImageJson(pathExpression: string): string {
|
||||
return `
|
||||
CASE
|
||||
WHEN lower(${pathExpression}) LIKE 'https://%' THEN json_build_object(
|
||||
'path', ${pathExpression},
|
||||
'url', ${pathExpression}
|
||||
)
|
||||
WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)}
|
||||
OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object(
|
||||
'path', ${pathExpression},
|
||||
@@ -863,6 +868,15 @@ function uploadedImageJson(pathExpression: string): string {
|
||||
function pokemonImageJson(alias: string): string {
|
||||
return `
|
||||
CASE
|
||||
WHEN lower(${alias}.image_path) LIKE 'https://%' THEN json_build_object(
|
||||
'path', ${alias}.image_path,
|
||||
'url', ${alias}.image_path,
|
||||
'style', '',
|
||||
'version', '',
|
||||
'variant', ${alias}.name,
|
||||
'description', '',
|
||||
'source', 'external'
|
||||
)
|
||||
WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
|
||||
'path', ${alias}.image_path,
|
||||
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
|
||||
@@ -891,6 +905,9 @@ function imagePathLabel(path: string | null | undefined): string {
|
||||
if (cleanPath === '') {
|
||||
return '';
|
||||
}
|
||||
if (isExternalImageUrl(cleanPath)) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
const parts = cleanPath.split('/');
|
||||
return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
|
||||
@@ -1247,6 +1264,9 @@ function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' |
|
||||
if (imagePath === '') {
|
||||
return '';
|
||||
}
|
||||
if (isExternalImageUrl(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
@@ -1261,6 +1281,9 @@ function cleanItemOrArtifactImagePath(value: unknown): string {
|
||||
if (imagePath === '') {
|
||||
return '';
|
||||
}
|
||||
if (isExternalImageUrl(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
if (isItemStaticImagePath(imagePath)) {
|
||||
return imagePath;
|
||||
}
|
||||
@@ -1983,7 +2006,12 @@ function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
||||
if (!image) {
|
||||
return '';
|
||||
}
|
||||
return image.source === 'upload' || isUploadImagePath(image.path) ? imagePathLabel(image.path) : `${image.style} - ${image.version} - ${image.variant}`;
|
||||
return image.source === 'upload'
|
||||
|| image.source === 'external'
|
||||
|| isUploadImagePath(image.path)
|
||||
|| isExternalImageUrl(image.path)
|
||||
? imagePathLabel(image.path)
|
||||
: `${image.style} - ${image.version} - ${image.variant}`;
|
||||
}
|
||||
|
||||
function pokemonImageDataIdFromPath(path: string): number | null {
|
||||
@@ -2013,6 +2041,18 @@ function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isExternalImageUrl(path)) {
|
||||
return {
|
||||
path,
|
||||
url: path,
|
||||
style: '',
|
||||
version: '',
|
||||
variant: `#${displayId}`,
|
||||
description: '',
|
||||
source: 'external'
|
||||
};
|
||||
}
|
||||
|
||||
if (isUploadImagePath(path)) {
|
||||
if (!path.startsWith('pokemon/')) {
|
||||
throw validationError('server.validation.imagePathInvalid');
|
||||
|
||||
@@ -53,6 +53,20 @@ export function isUploadImagePath(value: string | null | undefined): boolean {
|
||||
return isUploadEntityType(entityType);
|
||||
}
|
||||
|
||||
export function isExternalImageUrl(value: string | null | undefined): boolean {
|
||||
const cleanUrl = value?.trim() ?? '';
|
||||
if (cleanUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(cleanUrl);
|
||||
return url.protocol === 'https:' && url.username === '' && url.password === '';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadImageUrl(relativePath: string): string {
|
||||
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export interface PokemonImage extends EntityImage {
|
||||
version: string;
|
||||
variant: string;
|
||||
description: string;
|
||||
source?: 'sprite' | 'upload';
|
||||
source?: 'sprite' | 'upload' | 'external';
|
||||
}
|
||||
|
||||
export interface EditInfo {
|
||||
|
||||
@@ -371,7 +371,7 @@ function pokemonImageAlt() {
|
||||
if (!pokemon.value?.image) {
|
||||
return '';
|
||||
}
|
||||
return pokemon.value.image.source === 'upload'
|
||||
return pokemon.value.image.source === 'upload' || pokemon.value.image.source === 'external'
|
||||
? t('media.imageAlt', { name: pokemon.value.name })
|
||||
: t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant });
|
||||
}
|
||||
@@ -380,7 +380,13 @@ function pokemonImageLabel() {
|
||||
if (!pokemon.value?.image) {
|
||||
return '';
|
||||
}
|
||||
return pokemon.value.image.source === 'upload' ? t('media.uploadedImage') : `${pokemon.value.image.version} - ${pokemon.value.image.variant}`;
|
||||
if (pokemon.value.image.source === 'upload') {
|
||||
return t('media.uploadedImage');
|
||||
}
|
||||
if (pokemon.value.image.source === 'external') {
|
||||
return t('media.image');
|
||||
}
|
||||
return `${pokemon.value.image.version} - ${pokemon.value.image.variant}`;
|
||||
}
|
||||
|
||||
function openImageModal() {
|
||||
|
||||
@@ -586,12 +586,17 @@ function pokemonImageLabel(image: PokemonImage) {
|
||||
if (image.source === 'upload') {
|
||||
return t('media.uploadedImage');
|
||||
}
|
||||
if (image.source === 'external') {
|
||||
return t('media.image');
|
||||
}
|
||||
return `${image.version} - ${image.variant}`;
|
||||
}
|
||||
|
||||
function pokemonImageAlt(image: PokemonImage) {
|
||||
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title'));
|
||||
return image.source === 'upload' ? t('media.imageAlt', { name }) : t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||
return image.source === 'upload' || image.source === 'external'
|
||||
? t('media.imageAlt', { name })
|
||||
: t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||
}
|
||||
|
||||
function selectPokemonImage(image: PokemonImage) {
|
||||
|
||||
Reference in New Issue
Block a user