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:
2026-05-11 23:21:34 +08:00
parent 929c148c56
commit 8caa95e78e
6 changed files with 74 additions and 8 deletions

View File

@@ -379,7 +379,7 @@
- 物品图标 - 物品图标
- 栖息地 - 栖息地
- 上传图片只支持 `png``jpg/jpeg``webp``gif` - 上传图片只支持 `png``jpg/jpeg``webp``gif`
- 上传图片由服务端保存到受控上传目录,不接受任意外部 URL也不信任客户端传入的最终文件路径。 - 上传图片由服务端保存到受控上传目录,不信任客户端传入的最终文件路径;实体当前图片也可引用完整的 `https://` 外部图片 URL但不接受 `http://``data:``javascript:` 或带用户名 / 密码的 URL
- 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例: - 上传路径由服务端按实体类型、实体展示名称和时间戳生成,格式示例:
- `items/甜蜜蜜/20260501002000.png` - `items/甜蜜蜜/20260501002000.png`
- `pokemon/Pikachu/20260501002000.png` - `pokemon/Pikachu/20260501002000.png`
@@ -395,7 +395,8 @@
- `byte_size` - `byte_size`
- `created_by_user_id` - `created_by_user_id`
- `created_at` - `created_at`
- 实体表保存当前显示图片的相对路径;历史上传记录不会因为切换当前图片而删除。 - 实体表保存当前显示图片引用:上传图片相对路径、受支持静态资源路径或完整 `https://` 外部图片 URL;历史上传记录不会因为切换当前图片而删除。
- 公共实体 API 返回图片时包含当前图片引用 `path` 和可直接展示的 `url`;完整外部 URL 的 `path``url` 相同,不再拼接受控上传或静态资源域名。
- 公共 API 对外返回图片上传历史只包含:`id``path``url``uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联只能在受保护编辑接口返回 `entityId` - 公共 API 对外返回图片上传历史只包含:`id``path``url``uploadedAt` 和上传者必要署名 `uploadedBy`;不返回 `entity_name`、原始文件名、MIME、文件大小、服务器绝对文件路径或内部存储元数据。若编辑接口确需实体关联只能在受保护编辑接口返回 `entityId`
- 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。 - 图片上传本身不直接改变实体内容;用户仍需保存实体编辑表单后,当前图片选择才成为实体行为并写入现有编辑审计。
- Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。 - Docker 运行时上传目录必须使用 volume 持久化,避免重新 build 后丢失用户上传图片。

View File

@@ -1,6 +1,7 @@
import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts';
import { pool, query, queryOne } from './db.ts'; import { pool, query, queryOne } from './db.ts';
import { import {
isExternalImageUrl,
isUploadImagePath, isUploadImagePath,
linkEntityImageUpload, linkEntityImageUpload,
listEntityImageUploads, listEntityImageUploads,
@@ -192,7 +193,7 @@ type PokemonImage = {
version: string; version: string;
variant: string; variant: string;
description: string; description: string;
source?: 'sprite' | 'upload'; source?: 'sprite' | 'upload' | 'external';
}; };
type EntityImageValue = { type EntityImageValue = {
@@ -846,6 +847,10 @@ function sqlLiteral(value: string): string {
function uploadedImageJson(pathExpression: string): string { function uploadedImageJson(pathExpression: string): string {
return ` return `
CASE CASE
WHEN lower(${pathExpression}) LIKE 'https://%' THEN json_build_object(
'path', ${pathExpression},
'url', ${pathExpression}
)
WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)} WHEN ${pathExpression} LIKE ${sqlLiteral(`${itemStaticImagePathPrefix}%`)}
OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object( OR ${pathExpression} LIKE ${sqlLiteral(`${habitatStaticImagePathPrefix}%`)} THEN json_build_object(
'path', ${pathExpression}, 'path', ${pathExpression},
@@ -863,6 +868,15 @@ function uploadedImageJson(pathExpression: string): string {
function pokemonImageJson(alias: string): string { function pokemonImageJson(alias: string): string {
return ` return `
CASE 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( WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object(
'path', ${alias}.image_path, 'path', ${alias}.image_path,
'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path, 'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path,
@@ -891,6 +905,9 @@ function imagePathLabel(path: string | null | undefined): string {
if (cleanPath === '') { if (cleanPath === '') {
return ''; return '';
} }
if (isExternalImageUrl(cleanPath)) {
return cleanPath;
}
const parts = cleanPath.split('/'); const parts = cleanPath.split('/');
return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath; return parts.length >= 3 ? `${parts[1]} / ${parts[2]}` : cleanPath;
@@ -1247,6 +1264,9 @@ function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' |
if (imagePath === '') { if (imagePath === '') {
return ''; return '';
} }
if (isExternalImageUrl(imagePath)) {
return imagePath;
}
if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) { if (entityType === 'habitats' && isHabitatStaticImagePath(imagePath)) {
return imagePath; return imagePath;
} }
@@ -1261,6 +1281,9 @@ function cleanItemOrArtifactImagePath(value: unknown): string {
if (imagePath === '') { if (imagePath === '') {
return ''; return '';
} }
if (isExternalImageUrl(imagePath)) {
return imagePath;
}
if (isItemStaticImagePath(imagePath)) { if (isItemStaticImagePath(imagePath)) {
return imagePath; return imagePath;
} }
@@ -1983,7 +2006,12 @@ function pokemonImageLabel(image: PokemonImage | null | undefined): string {
if (!image) { if (!image) {
return ''; 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 { function pokemonImageDataIdFromPath(path: string): number | null {
@@ -2013,6 +2041,18 @@ function cleanPokemonImage(value: unknown, displayId: number): PokemonImage | nu
return null; return null;
} }
if (isExternalImageUrl(path)) {
return {
path,
url: path,
style: '',
version: '',
variant: `#${displayId}`,
description: '',
source: 'external'
};
}
if (isUploadImagePath(path)) { if (isUploadImagePath(path)) {
if (!path.startsWith('pokemon/')) { if (!path.startsWith('pokemon/')) {
throw validationError('server.validation.imagePathInvalid'); throw validationError('server.validation.imagePathInvalid');

View File

@@ -53,6 +53,20 @@ export function isUploadImagePath(value: string | null | undefined): boolean {
return isUploadEntityType(entityType); 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 { export function uploadImageUrl(relativePath: string): string {
return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`; return `${uploadPublicBaseUrl}${relativePath.split('/').map(encodeURIComponent).join('/')}`;
} }

View File

@@ -178,7 +178,7 @@ export interface PokemonImage extends EntityImage {
version: string; version: string;
variant: string; variant: string;
description: string; description: string;
source?: 'sprite' | 'upload'; source?: 'sprite' | 'upload' | 'external';
} }
export interface EditInfo { export interface EditInfo {

View File

@@ -371,7 +371,7 @@ function pokemonImageAlt() {
if (!pokemon.value?.image) { if (!pokemon.value?.image) {
return ''; 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('media.imageAlt', { name: pokemon.value.name })
: t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }); : t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant });
} }
@@ -380,7 +380,13 @@ function pokemonImageLabel() {
if (!pokemon.value?.image) { if (!pokemon.value?.image) {
return ''; 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() { function openImageModal() {

View File

@@ -586,12 +586,17 @@ function pokemonImageLabel(image: PokemonImage) {
if (image.source === 'upload') { if (image.source === 'upload') {
return t('media.uploadedImage'); return t('media.uploadedImage');
} }
if (image.source === 'external') {
return t('media.image');
}
return `${image.version} - ${image.variant}`; return `${image.version} - ${image.variant}`;
} }
function pokemonImageAlt(image: PokemonImage) { function pokemonImageAlt(image: PokemonImage) {
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title')); 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) { function selectPokemonImage(image: PokemonImage) {