"use client" import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" import { z } from "zod" import { toast } from "@/hooks/use-toast" import { Button } from "@/components/ui/button" import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { findUserById } from "@/lib/admin-users/service" import { useSession } from "next-auth/react"; import { updateUserProfileImage } from "@/lib/users/service" const accountFormSchema = z.object({ name: z .string() .min(2, { message: "Name must be at least 2 characters.", }) .max(30, { message: "Name must not be longer than 30 characters.", }), email: z.string().email(), imageFile: z.any().optional(), }) type AccountFormValues = z.infer export function AccountForm() { const { data: session } = useSession(); const userId = session?.user.id || "" const [currentImageUrl, setCurrentImageUrl] = React.useState(null) const [previewUrl, setPreviewUrl] = React.useState(null) const [imageError, setImageError] = React.useState(false) const form = useForm({ resolver: zodResolver(accountFormSchema), defaultValues: { name: "", email: "", imageFile: null, }, }) // 안전한 이미지 URL 검증 함수 const isValidImageUrl = (url: string): boolean => { try { // 1. 빈 문자열 체크 if (!url || typeof url !== 'string') return false // 2. 위험한 프로토콜 차단 const dangerousProtocols = ['javascript:', 'data:', 'vbscript:', 'file:', 'ftp:'] const lowerUrl = url.toLowerCase() if (dangerousProtocols.some(protocol => lowerUrl.startsWith(protocol))) { return false } // 3. 상대 경로 공격 방지 if (url.includes('../') || url.includes('..\\')) { return false } // 4. 허용된 경로만 통과 (프로젝트 구조에 맞게 조정) const allowedPaths = ['/profiles/', '/uploads/', '/images/'] const hasAllowedPath = allowedPaths.some(path => url.startsWith(path)) // 5. 또는 허용된 도메인만 통과 (필요한 경우) // const allowedDomains = ['yourdomain.com', 'cdn.yourdomain.com'] // if (url.startsWith('http')) { // const urlObj = new URL(url) // return allowedDomains.includes(urlObj.hostname) // } return hasAllowedPath } catch (error) { console.error('URL validation error:', error) return false } } // 안전한 이미지 URL 생성 함수 const getSafeImageUrl = (imagePath: string | null): string | null => { if (!imagePath) return null // 이미 전체 경로인 경우 if (imagePath.startsWith('/profiles/') || imagePath.startsWith('/uploads/')) { return isValidImageUrl(imagePath) ? imagePath : null } // 파일명만 있는 경우 안전한 경로로 조합 const safePath = `/profiles/${encodeURIComponent(imagePath)}` return isValidImageUrl(safePath) ? safePath : null } React.useEffect(() => { async function fetchUser() { try { const data = await findUserById(Number(userId)) if (data) { form.reset({ name: data.user_name || "", email: data.user_email || "", imageFile: null, }) // 안전한 이미지 URL 설정 const safeImageUrl = getSafeImageUrl(data.user_image) setCurrentImageUrl(safeImageUrl) setImageError(false) setPreviewUrl(null) } } catch (error) { console.error("Failed to fetch user data:", error) } } if (userId) { fetchUser() } }, [userId, form]) async function onSubmit(data: AccountFormValues) { const { dirtyFields } = form.formState if (Object.keys(dirtyFields).length === 0) { toast({ title: "No changes", description: "Nothing to update", }) return } let imageFile: File | null = null if (dirtyFields.imageFile && data.imageFile && data.imageFile.length > 0) { const file = data.imageFile[0] // 클라이언트 측 파일 검증 if (!isValidImageFile(file)) { toast({ title: "Invalid file", description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", variant: "destructive", }) return } imageFile = file } const formData = new FormData() formData.append("userId", userId) formData.append("name", data.name) formData.append("email", data.email) if (imageFile) { formData.append("file", imageFile) } try { await updateUserProfileImage(formData) toast({ title: "Account updated", description: "User updated successfully!", }) } catch (error: any) { toast({ title: "Error", description: `Error: ${error.message ?? error}`, variant: "destructive", }) } } // 파일 유효성 검증 함수 const isValidImageFile = (file: File): boolean => { // 1. 파일 크기 검증 (5MB 제한) const maxSize = 5 * 1024 * 1024 // 5MB if (file.size > maxSize) { return false } // 2. MIME 타입 검증 const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] if (!allowedTypes.includes(file.type)) { return false } // 3. 파일 확장자 검증 (추가 보안) const allowedExtensions = ['.jpg', '.jpeg', '.png', '.webp'] const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) if (!allowedExtensions.includes(fileExtension)) { return false } return true } // 이미지 로드 에러 처리 const handleImageError = () => { setImageError(true) setCurrentImageUrl(null) } // 안전한 이미지 표시 함수 const getDisplayImage = () => { if (previewUrl) { return ( Preview setPreviewUrl(null)} /> ) } if (currentImageUrl && !imageError) { return ( Current profile ) } return (
{imageError ? "Image load failed" : "No image"}
) } return (
( Name This is the name that will be displayed on your profile and in emails. )} /> ( Email This is the email that will be used on login. If you want change it, please be careful. )} /> ( Profile Image
{ const files = e.target.files field.onChange(files) if (files && files.length > 0) { const file = files[0] // 파일 유효성 검증 if (!isValidImageFile(file)) { toast({ title: "Invalid file", description: "Please select a valid image file (PNG, JPG, JPEG, WebP, max 5MB)", variant: "destructive", }) // 파일 입력 초기화 e.target.value = '' field.onChange(null) return } if (previewUrl) { URL.revokeObjectURL(previewUrl) } const url = URL.createObjectURL(file) setPreviewUrl(url) setImageError(false) } else { if (previewUrl) { URL.revokeObjectURL(previewUrl) } setPreviewUrl(null) } }} />
{getDisplayImage()}
{previewUrl && (

새 이미지 미리보기

)} {!previewUrl && currentImageUrl && !imageError && (

현재 프로필 이미지

)} {imageError && (

이미지를 불러올 수 없습니다

)}
Upload your profile image (PNG, JPG, JPEG, WebP, max 5MB).
)} /> ) }