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

@@ -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');

View File

@@ -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('/')}`;
}