From ee57cc221ff2edafd3c0f12a181214c602ed257e Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 22 Jul 2025 02:57:00 +0000 Subject: (대표님, 최겸) 이메일 템플릿, 벤더데이터 변경사항 대응, 기술영업 변경요구사항 구현 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/settings/account-form.tsx | 283 ++++++++++++++++++++++++----------- 1 file changed, 195 insertions(+), 88 deletions(-) (limited to 'components/settings/account-form.tsx') diff --git a/components/settings/account-form.tsx b/components/settings/account-form.tsx index 97cad9e5..e2435a2b 100644 --- a/components/settings/account-form.tsx +++ b/components/settings/account-form.tsx @@ -24,8 +24,6 @@ import { useSession } from "next-auth/react"; import { updateUserProfileImage } from "@/lib/users/service" - - const accountFormSchema = z.object({ name: z .string() @@ -36,56 +34,95 @@ const accountFormSchema = z.object({ message: "Name must not be longer than 30 characters.", }), email: z.string().email(), - company: z - .string() - .min(2, { - message: "Name must be at least 2 characters.", - }) - .max(30, { - message: "Name must not be longer than 30 characters.", - }), - 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: "", - company: "", email: "", imageFile: null, }, }) - // Fetch data in useEffect - React.useEffect(() => { - console.log("Form state changed: ", form.getValues()); + // 안전한 이미지 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) { - // Also reset the form's default values form.reset({ name: data.user_name || "", - company: data.company_name || "", email: data.user_email || "", - imageFile: data.user_image, // no file to begin with + 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) @@ -97,12 +134,9 @@ export function AccountForm() { } }, [userId, form]) - async function onSubmit(data: AccountFormValues) { - // RHF가 추적한 dirtyFields를 가져옵니다. const { dirtyFields } = form.formState - // 변경된 필드가 전혀 없다면 => 업데이트 스킵 if (Object.keys(dirtyFields).length === 0) { toast({ title: "No changes", @@ -111,18 +145,26 @@ export function AccountForm() { return } - // 바뀐 파일만 업로드 let imageFile: File | null = null if (dirtyFields.imageFile && data.imageFile && data.imageFile.length > 0) { - // 새로 업로드한 파일 - imageFile = data.imageFile[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 } - // FormData 생성 const formData = new FormData() formData.append("userId", userId) formData.append("name", data.name) - formData.append("company", data.company) formData.append("email", data.email) if (imageFile) { @@ -130,7 +172,6 @@ export function AccountForm() { } try { - // 서버 액션(또는 API) 호출 await updateUserProfileImage(formData) toast({ @@ -147,6 +188,72 @@ export function AccountForm() { } } + // 파일 유효성 검증 함수 + 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 (
@@ -186,32 +293,6 @@ export function AccountForm() { )} /> - ( - - Company - - - - - This is the name that will be displayed on your profile and in - emails. - - - - )} - /> - - - - {/* 이미지 업로드 */} Profile Image -
- { - field.onChange(e.target.files) - if (e.target.files && e.target.files.length > 0) { - // 로컬 미리보기 URL - const file = e.target.files[0] - const url = URL.createObjectURL(file) - setPreviewUrl(url) - } - }} - /> - - {previewUrl ? ( - Local Preview - ) : ( - typeof field.value === "string" && - field.value && ( - Server 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. + Upload your profile image (PNG, JPG, JPEG, WebP, max 5MB). @@ -260,4 +367,4 @@ export function AccountForm() { ) -} +} \ No newline at end of file -- cgit v1.2.3