diff --git a/DESIGN.md b/DESIGN.md index 0ae6819..9dc14ec 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 后丢失用户上传图片。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index c730276..c3dd7d9 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -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'); diff --git a/backend/src/uploads.ts b/backend/src/uploads.ts index 2aa1aa8..1aa395c 100644 --- a/backend/src/uploads.ts +++ b/backend/src/uploads.ts @@ -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('/')}`; } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1aee876..d5b3365 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index d3e78cf..696a4e8 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -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() { diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index 81bf411..3c5621d 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -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) {