fix: validate texture asset names server-side

This commit is contained in:
Tom Boullay
2026-04-28 00:17:28 +02:00
parent 2679d29ab4
commit 9dc0232e4a
5 changed files with 72 additions and 48 deletions
+10 -2
View File
@@ -1,15 +1,19 @@
import { getAssetFamily } from './asset-naming'
export type AssetCategory = 'color' | 'roughness' | 'normal' | 'metalness' | 'assets'
export type AssetCategory = 'color' | 'diffuse' | 'roughness' | 'normal' | 'metalness' | 'opacity' | 'assets'
export function classifyAssetCategory(filename: string): AssetCategory {
const name = filename.replace(/\.[^.]+$/, '')
const family = getAssetFamily(name.split('_')[0])
if (family === 'color' || family === 'diffuse') {
if (family === 'color') {
return 'color'
}
if (family === 'diffuse') {
return 'diffuse'
}
if (family === 'roughness') {
return 'roughness'
}
@@ -22,5 +26,9 @@ export function classifyAssetCategory(filename: string): AssetCategory {
return 'metalness'
}
if (family === 'opacity') {
return 'opacity'
}
return 'assets'
}
+38
View File
@@ -29,6 +29,44 @@ export function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undef
return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase())
}
function getFileStem(filename: string) {
return filename.replace(/\.[^.]+$/, '')
}
export function getTextureNamingError(filename: string) {
const stem = getFileStem(filename)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
const extension = filename.split('.').pop()
if (family && targetParts.every(Boolean)) return null
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
if (aliasSuggestion && targetParts.every(Boolean)) {
const target = targetParts.join('_')
return `Convention invalide : ${filename}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
}
const reversedParts = stem.split('_')
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
const reversedAliasSuggestion = reversedParts.length > 1
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
: undefined
if (reversedFamily) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${filename}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
}
if (reversedAliasSuggestion) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${filename}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
}
return `Asset inconnu : ${filename}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
}
export function formatAssetFamilies() {
return ASSET_FAMILIES.join(', ')
}
+3 -1
View File
@@ -58,13 +58,15 @@ export function buildCommitMessage(
const sectionTitles: Record<AssetCategory, string> = {
color: '🎨 Textures (color)',
diffuse: '🖌 Textures (diffuse)',
roughness: '🪶 Textures (roughness)',
normal: '🧭 Textures (normal)',
metalness: '🔩 Textures (metalness)',
opacity: '🪟 Textures (opacity)',
assets: '🧩 Assets',
}
for (const category of ['color', 'roughness', 'normal', 'metalness', 'assets'] as const) {
for (const category of ['color', 'diffuse', 'roughness', 'normal', 'metalness', 'opacity', 'assets'] as const) {
const entries = grouped.get(category)
if (!entries || entries.length === 0) continue
lines.push('')
+19 -5
View File
@@ -1,7 +1,8 @@
import { extname } from 'path'
import { NextRequest } from 'next/server'
import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE, TEXTURE_EXTENSIONS } from './constants'
import { getTextureNamingError } from './asset-naming'
import type { ParsedFile } from './types'
interface ParsedUpload {
@@ -48,10 +49,10 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const texName = textureNames[i] || ''
const originalSafe = sanitizeFilename(file.name)
const ext = extname(originalSafe).toLowerCase()
const originalExt = extname(originalSafe).toLowerCase()
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
throw new Error(`Extension non autorisee: "${ext}"`)
if (!ALL_ALLOWED_EXTENSIONS.has(originalExt)) {
throw new Error(`Extension non autorisee: "${originalExt}"`)
}
let filename: string
@@ -61,7 +62,20 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
filename = originalSafe
}
const isModel = MODEL_EXTENSIONS.has(ext)
const filenameExt = extname(filename).toLowerCase()
if (filenameExt !== originalExt) {
throw new Error(`Nom de fichier incoherent : ${filename} ne correspond pas a l'extension originale ${originalExt}`)
}
const textureNamingError = TEXTURE_EXTENSIONS.has(filenameExt)
? getTextureNamingError(filename)
: null
if (textureNamingError) {
throw new Error(textureNamingError)
}
const isModel = MODEL_EXTENSIONS.has(filenameExt)
if (isModel) {
if (filename.toLowerCase() !== 'model.gltf') {
throw new Error('Le modele doit etre nomme model.gltf')
+2 -40
View File
@@ -1,5 +1,5 @@
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { formatAssetFamilies, getAssetFamily, getForbiddenAssetFamilyAlias } from '@/lib/asset-naming'
import { getTextureNamingError } from '@/lib/asset-naming'
import { getErrorMessage, isRecord } from '@/lib/guards'
import type { TextureFile } from '@/lib/client-types'
@@ -37,44 +37,6 @@ function getFileExtension(filename: string) {
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
}
function getFileStem(filename: string) {
return filename.replace(/\.[^.]+$/, '')
}
function getTextureNamingError(file: File) {
const stem = getFileStem(file.name)
const [prefix, ...targetParts] = stem.split('_')
const family = getAssetFamily(prefix)
const extension = file.name.split('.').pop()
if (family && targetParts.every(Boolean)) return null
const aliasSuggestion = getForbiddenAssetFamilyAlias(prefix)
if (aliasSuggestion && targetParts.every(Boolean)) {
const target = targetParts.join('_')
return `Convention invalide : ${file.name}. Utilisez ${aliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${aliasSuggestion}.${extension} pour tout le modele.`
}
const reversedParts = stem.split('_')
const reversedFamily = reversedParts.length > 1 ? getAssetFamily(reversedParts[reversedParts.length - 1]) : undefined
const reversedAliasSuggestion = reversedParts.length > 1
? getForbiddenAssetFamilyAlias(reversedParts[reversedParts.length - 1])
: undefined
if (reversedFamily) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${file.name}. Utilisez ${reversedFamily}_${target}.${extension} pour cibler "${target}", ou ${reversedFamily}.${extension} pour tout le modele.`
}
if (reversedAliasSuggestion) {
const target = reversedParts.slice(0, -1).join('_')
return `Convention invalide : ${file.name}. Utilisez ${reversedAliasSuggestion}_${target}.${extension} pour cibler "${target}", ou ${reversedAliasSuggestion}.${extension} pour tout le modele.`
}
return `Asset inconnu : ${file.name}. Familles autorisees : ${formatAssetFamilies()}. Utilisez asset.png pour tout le modele ou asset_objet.png pour cibler un objet.`
}
async function getGltfWarnings(model: File, supportFiles: File[]) {
const warnings: string[] = []
let parsed: unknown
@@ -132,7 +94,7 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
const textureNamingErrors = supportFiles
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
.map(getTextureNamingError)
.map((file) => getTextureNamingError(file.name))
.filter((error): error is string => Boolean(error))
errors.push(...textureNamingErrors)