fix: validate texture asset names server-side
This commit is contained in:
@@ -1,15 +1,19 @@
|
|||||||
import { getAssetFamily } from './asset-naming'
|
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 {
|
export function classifyAssetCategory(filename: string): AssetCategory {
|
||||||
const name = filename.replace(/\.[^.]+$/, '')
|
const name = filename.replace(/\.[^.]+$/, '')
|
||||||
const family = getAssetFamily(name.split('_')[0])
|
const family = getAssetFamily(name.split('_')[0])
|
||||||
|
|
||||||
if (family === 'color' || family === 'diffuse') {
|
if (family === 'color') {
|
||||||
return 'color'
|
return 'color'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (family === 'diffuse') {
|
||||||
|
return 'diffuse'
|
||||||
|
}
|
||||||
|
|
||||||
if (family === 'roughness') {
|
if (family === 'roughness') {
|
||||||
return 'roughness'
|
return 'roughness'
|
||||||
}
|
}
|
||||||
@@ -22,5 +26,9 @@ export function classifyAssetCategory(filename: string): AssetCategory {
|
|||||||
return 'metalness'
|
return 'metalness'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (family === 'opacity') {
|
||||||
|
return 'opacity'
|
||||||
|
}
|
||||||
|
|
||||||
return 'assets'
|
return 'assets'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,44 @@ export function getForbiddenAssetFamilyAlias(value: string): AssetFamily | undef
|
|||||||
return FORBIDDEN_ASSET_FAMILY_ALIASES.get(value.toLowerCase())
|
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() {
|
export function formatAssetFamilies() {
|
||||||
return ASSET_FAMILIES.join(', ')
|
return ASSET_FAMILIES.join(', ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,15 @@ export function buildCommitMessage(
|
|||||||
|
|
||||||
const sectionTitles: Record<AssetCategory, string> = {
|
const sectionTitles: Record<AssetCategory, string> = {
|
||||||
color: '🎨 Textures (color)',
|
color: '🎨 Textures (color)',
|
||||||
|
diffuse: '🖌 Textures (diffuse)',
|
||||||
roughness: '🪶 Textures (roughness)',
|
roughness: '🪶 Textures (roughness)',
|
||||||
normal: '🧭 Textures (normal)',
|
normal: '🧭 Textures (normal)',
|
||||||
metalness: '🔩 Textures (metalness)',
|
metalness: '🔩 Textures (metalness)',
|
||||||
|
opacity: '🪟 Textures (opacity)',
|
||||||
assets: '🧩 Assets',
|
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)
|
const entries = grouped.get(category)
|
||||||
if (!entries || entries.length === 0) continue
|
if (!entries || entries.length === 0) continue
|
||||||
lines.push('')
|
lines.push('')
|
||||||
|
|||||||
+19
-5
@@ -1,7 +1,8 @@
|
|||||||
import { extname } from 'path'
|
import { extname } from 'path'
|
||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { sanitizeFilename } from './sanitize'
|
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'
|
import type { ParsedFile } from './types'
|
||||||
|
|
||||||
interface ParsedUpload {
|
interface ParsedUpload {
|
||||||
@@ -48,10 +49,10 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
const texName = textureNames[i] || ''
|
const texName = textureNames[i] || ''
|
||||||
|
|
||||||
const originalSafe = sanitizeFilename(file.name)
|
const originalSafe = sanitizeFilename(file.name)
|
||||||
const ext = extname(originalSafe).toLowerCase()
|
const originalExt = extname(originalSafe).toLowerCase()
|
||||||
|
|
||||||
if (!ALL_ALLOWED_EXTENSIONS.has(ext)) {
|
if (!ALL_ALLOWED_EXTENSIONS.has(originalExt)) {
|
||||||
throw new Error(`Extension non autorisee: "${ext}"`)
|
throw new Error(`Extension non autorisee: "${originalExt}"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let filename: string
|
let filename: string
|
||||||
@@ -61,7 +62,20 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
|
|||||||
filename = originalSafe
|
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 (isModel) {
|
||||||
if (filename.toLowerCase() !== 'model.gltf') {
|
if (filename.toLowerCase() !== 'model.gltf') {
|
||||||
throw new Error('Le modele doit etre nomme model.gltf')
|
throw new Error('Le modele doit etre nomme model.gltf')
|
||||||
|
|||||||
+2
-40
@@ -1,5 +1,5 @@
|
|||||||
import { ASSET_EXTENSIONS, TEXTURE_EXTENSIONS } from '@/lib/constants'
|
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 { getErrorMessage, isRecord } from '@/lib/guards'
|
||||||
import type { TextureFile } from '@/lib/client-types'
|
import type { TextureFile } from '@/lib/client-types'
|
||||||
|
|
||||||
@@ -37,44 +37,6 @@ function getFileExtension(filename: string) {
|
|||||||
return filename.slice(filename.lastIndexOf('.')).toLowerCase()
|
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[]) {
|
async function getGltfWarnings(model: File, supportFiles: File[]) {
|
||||||
const warnings: string[] = []
|
const warnings: string[] = []
|
||||||
let parsed: unknown
|
let parsed: unknown
|
||||||
@@ -132,7 +94,7 @@ export async function validateFolder(files: File[]): Promise<ValidationResult> {
|
|||||||
|
|
||||||
const textureNamingErrors = supportFiles
|
const textureNamingErrors = supportFiles
|
||||||
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
.filter((file) => TEXTURE_EXTENSIONS.has(getFileExtension(file.name)))
|
||||||
.map(getTextureNamingError)
|
.map((file) => getTextureNamingError(file.name))
|
||||||
.filter((error): error is string => Boolean(error))
|
.filter((error): error is string => Boolean(error))
|
||||||
|
|
||||||
errors.push(...textureNamingErrors)
|
errors.push(...textureNamingErrors)
|
||||||
|
|||||||
Reference in New Issue
Block a user