From f69fbbd0e1d4bac8d7261abb2a981fec5d449d42 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 12 Dec 2025 15:59:18 +0100 Subject: [PATCH] Refactor image URL handling: Update API routes to return image URLs via the API instead of direct paths, ensuring consistency across avatar and background uploads. Introduce normalization functions for avatar and background URLs to maintain compatibility with existing URLs. --- app/api/admin/avatars/upload/route.ts | 4 +- app/api/admin/images/list/route.ts | 2 +- app/api/admin/images/upload/route.ts | 4 +- app/api/avatars/[filename]/route.ts | 78 ++++++++++++++++++++++ app/api/backgrounds/[filename]/route.ts | 86 +++++++++++++++++++++++++ app/api/profile/avatar/route.ts | 4 +- app/api/register/avatar/route.ts | 4 +- components/Avatar.tsx | 4 +- hooks/usePreferences.ts | 7 +- lib/avatars.ts | 53 +++++++++++++++ lib/preferences.ts | 5 +- 11 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 app/api/avatars/[filename]/route.ts create mode 100644 app/api/backgrounds/[filename]/route.ts create mode 100644 lib/avatars.ts diff --git a/app/api/admin/avatars/upload/route.ts b/app/api/admin/avatars/upload/route.ts index 5e50558..366016c 100644 --- a/app/api/admin/avatars/upload/route.ts +++ b/app/api/admin/avatars/upload/route.ts @@ -57,8 +57,8 @@ export async function POST(request: Request) { const buffer = Buffer.from(bytes); await writeFile(filepath, buffer); - // Retourner l'URL de l'image - const imageUrl = `/uploads/avatars/${filename}`; + // Retourner l'URL de l'image via l'API + const imageUrl = `/api/avatars/${filename}`; return NextResponse.json({ url: imageUrl }); } catch (error) { console.error("Error uploading avatar:", error); diff --git a/app/api/admin/images/list/route.ts b/app/api/admin/images/list/route.ts index a660ab7..268c16a 100644 --- a/app/api/admin/images/list/route.ts +++ b/app/api/admin/images/list/route.ts @@ -26,7 +26,7 @@ export async function GET() { (file) => file.match(/\.(jpg|jpeg|png|gif|webp|svg)$/i) && !file.startsWith(".") ); - images.push(...imageFiles.map((file) => `/uploads/backgrounds/${file}`)); + images.push(...imageFiles.map((file) => `/api/backgrounds/${file}`)); } return NextResponse.json({ images }); diff --git a/app/api/admin/images/upload/route.ts b/app/api/admin/images/upload/route.ts index 776d3a8..5189b55 100644 --- a/app/api/admin/images/upload/route.ts +++ b/app/api/admin/images/upload/route.ts @@ -48,8 +48,8 @@ export async function POST(request: Request) { const buffer = Buffer.from(bytes); await writeFile(filepath, buffer); - // Retourner l'URL de l'image - const imageUrl = `/uploads/backgrounds/${filename}`; + // Retourner l'URL de l'image via l'API + const imageUrl = `/api/backgrounds/${filename}`; return NextResponse.json({ url: imageUrl }); } catch (error) { console.error("Error uploading image:", error); diff --git a/app/api/avatars/[filename]/route.ts b/app/api/avatars/[filename]/route.ts new file mode 100644 index 0000000..121bd09 --- /dev/null +++ b/app/api/avatars/[filename]/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readFile } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + try { + const { filename } = await params; + + // Sécuriser le nom de fichier pour éviter les path traversal + if ( + !filename || + filename.includes("..") || + filename.includes("/") || + filename.includes("\\") + ) { + return NextResponse.json( + { error: "Nom de fichier invalide" }, + { status: 400 } + ); + } + + // Décoder le nom de fichier (au cas où il contient des caractères encodés) + const decodedFilename = decodeURIComponent(filename); + + // Chemin vers le fichier avatar + const avatarsDir = join(process.cwd(), "public", "uploads", "avatars"); + const filepath = join(avatarsDir, decodedFilename); + + // Vérifier que le fichier existe + if (!existsSync(filepath)) { + return NextResponse.json({ error: "Avatar non trouvé" }, { status: 404 }); + } + + // Lire le fichier + const fileBuffer = await readFile(filepath); + + // Déterminer le type MIME basé sur l'extension + const extension = decodedFilename.split(".").pop()?.toLowerCase(); + let contentType = "image/jpeg"; // par défaut + + switch (extension) { + case "png": + contentType = "image/png"; + break; + case "gif": + contentType = "image/gif"; + break; + case "webp": + contentType = "image/webp"; + break; + case "svg": + contentType = "image/svg+xml"; + break; + case "jpg": + case "jpeg": + default: + contentType = "image/jpeg"; + } + + // Retourner l'image avec les bons headers + return new NextResponse(fileBuffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (error) { + console.error("Error serving avatar:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de l'avatar" }, + { status: 500 } + ); + } +} diff --git a/app/api/backgrounds/[filename]/route.ts b/app/api/backgrounds/[filename]/route.ts new file mode 100644 index 0000000..8bf9c8f --- /dev/null +++ b/app/api/backgrounds/[filename]/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { readFile } from "fs/promises"; +import { join } from "path"; +import { existsSync } from "fs"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ filename: string }> } +) { + try { + const { filename } = await params; + + // Sécuriser le nom de fichier pour éviter les path traversal + if ( + !filename || + filename.includes("..") || + filename.includes("/") || + filename.includes("\\") + ) { + return NextResponse.json( + { error: "Nom de fichier invalide" }, + { status: 400 } + ); + } + + // Décoder le nom de fichier (au cas où il contient des caractères encodés) + const decodedFilename = decodeURIComponent(filename); + + // Chemin vers le fichier background + const backgroundsDir = join( + process.cwd(), + "public", + "uploads", + "backgrounds" + ); + const filepath = join(backgroundsDir, decodedFilename); + + // Vérifier que le fichier existe + if (!existsSync(filepath)) { + return NextResponse.json( + { error: "Image de fond non trouvée" }, + { status: 404 } + ); + } + + // Lire le fichier + const fileBuffer = await readFile(filepath); + + // Déterminer le type MIME basé sur l'extension + const extension = decodedFilename.split(".").pop()?.toLowerCase(); + let contentType = "image/jpeg"; // par défaut + + switch (extension) { + case "png": + contentType = "image/png"; + break; + case "gif": + contentType = "image/gif"; + break; + case "webp": + contentType = "image/webp"; + break; + case "svg": + contentType = "image/svg+xml"; + break; + case "jpg": + case "jpeg": + default: + contentType = "image/jpeg"; + } + + // Retourner l'image avec les bons headers + return new NextResponse(fileBuffer, { + headers: { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch (error) { + console.error("Error serving background:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération de l'image de fond" }, + { status: 500 } + ); + } +} diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index f93a199..1daa85c 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -56,8 +56,8 @@ export async function POST(request: Request) { const buffer = Buffer.from(bytes); await writeFile(filepath, buffer); - // Retourner l'URL de l'image - const imageUrl = `/uploads/avatars/${filename}`; + // Retourner l'URL de l'image via l'API + const imageUrl = `/api/avatars/${filename}`; return NextResponse.json({ url: imageUrl }); } catch (error) { console.error("Error uploading avatar:", error); diff --git a/app/api/register/avatar/route.ts b/app/api/register/avatar/route.ts index f5cd247..bd2573d 100644 --- a/app/api/register/avatar/route.ts +++ b/app/api/register/avatar/route.ts @@ -50,8 +50,8 @@ export async function POST(request: Request) { const buffer = Buffer.from(bytes); await writeFile(filepath, buffer); - // Retourner l'URL de l'image - const imageUrl = `/uploads/avatars/${filename}`; + // Retourner l'URL de l'image via l'API + const imageUrl = `/api/avatars/${filename}`; return NextResponse.json({ url: imageUrl }); } catch (error) { console.error("Error uploading avatar:", error); diff --git a/components/Avatar.tsx b/components/Avatar.tsx index f39b636..5f23bfb 100644 --- a/components/Avatar.tsx +++ b/components/Avatar.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useRef } from "react"; +import { normalizeAvatarUrl } from "@/lib/avatars"; interface AvatarProps { src: string | null | undefined; @@ -42,7 +43,8 @@ export default function Avatar({ }, [src]); const sizeClass = sizeClasses[size]; - const displaySrc = src && !avatarError ? src : null; + const normalizedSrc = normalizeAvatarUrl(src); + const displaySrc = normalizedSrc && !avatarError ? normalizedSrc : null; const initial = fallbackText || username.charAt(0).toUpperCase(); return ( diff --git a/hooks/usePreferences.ts b/hooks/usePreferences.ts index 83c122b..a7f45d4 100644 --- a/hooks/usePreferences.ts +++ b/hooks/usePreferences.ts @@ -1,4 +1,5 @@ import { useEffect, useState, useRef } from "react"; +import { normalizeBackgroundUrl } from "@/lib/avatars"; interface Preferences { homeBackground: string | null; @@ -49,8 +50,10 @@ export function useBackgroundImage( if (preferences) { const imageKey = `${page}Background` as keyof Preferences; const customImage = preferences[imageKey]; - const targetImage = customImage || defaultImage; - + const rawImage = customImage || defaultImage; + // Normaliser l'URL pour utiliser l'API si nécessaire + const targetImage = normalizeBackgroundUrl(rawImage) || defaultImage; + // Ne changer que si l'image est vraiment différente if (targetImage !== prevImageRef.current) { prevImageRef.current = targetImage; diff --git a/lib/avatars.ts b/lib/avatars.ts new file mode 100644 index 0000000..c4491f4 --- /dev/null +++ b/lib/avatars.ts @@ -0,0 +1,53 @@ +/** + * Normalise une URL d'avatar pour utiliser la route API + * Gère la compatibilité avec les anciennes URLs directes + */ +export function normalizeAvatarUrl( + avatarUrl: string | null | undefined +): string | null { + if (!avatarUrl) { + return null; + } + + // Si c'est déjà une URL API, la retourner telle quelle + if (avatarUrl.startsWith("/api/avatars/")) { + return avatarUrl; + } + + // Si c'est une ancienne URL directe, la convertir en URL API + if (avatarUrl.startsWith("/uploads/avatars/")) { + const filename = avatarUrl.replace("/uploads/avatars/", ""); + return `/api/avatars/${filename}`; + } + + // Si c'est une URL complète (http/https) ou un chemin relatif autre, la retourner telle quelle + // (pour les avatars par défaut comme /avatar-1.jpg) + return avatarUrl; +} + +/** + * Normalise une URL d'image de fond pour utiliser la route API + * Gère la compatibilité avec les anciennes URLs directes + */ +export function normalizeBackgroundUrl( + backgroundUrl: string | null | undefined +): string | null { + if (!backgroundUrl) { + return null; + } + + // Si c'est déjà une URL API, la retourner telle quelle + if (backgroundUrl.startsWith("/api/backgrounds/")) { + return backgroundUrl; + } + + // Si c'est une ancienne URL directe, la convertir en URL API + if (backgroundUrl.startsWith("/uploads/backgrounds/")) { + const filename = backgroundUrl.replace("/uploads/backgrounds/", ""); + return `/api/backgrounds/${filename}`; + } + + // Si c'est une URL complète (http/https) ou un chemin relatif autre, la retourner telle quelle + // (pour les images par défaut comme /got-2.jpg) + return backgroundUrl; +} diff --git a/lib/preferences.ts b/lib/preferences.ts index 6db5a77..7a822ac 100644 --- a/lib/preferences.ts +++ b/lib/preferences.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/prisma"; +import { normalizeBackgroundUrl } from "@/lib/avatars"; export async function getBackgroundImage( page: "home" | "events" | "leaderboard", @@ -16,7 +17,9 @@ export async function getBackgroundImage( const imageKey = `${page}Background` as keyof typeof sitePreferences; const customImage = sitePreferences[imageKey]; - return (customImage as string | null) || defaultImage; + const imageUrl = (customImage as string | null) || defaultImage; + // Normaliser l'URL pour utiliser l'API si nécessaire + return normalizeBackgroundUrl(imageUrl) || defaultImage; } catch (error) { console.error("Error fetching background image:", error); return defaultImage;