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`。
|
- 上传图片只支持 `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 后丢失用户上传图片。
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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('/')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user