diff options
Diffstat (limited to 'components/settings/account-form.tsx')
| -rw-r--r-- | components/settings/account-form.tsx | 283 |
1 files changed, 195 insertions, 88 deletions
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<typeof accountFormSchema> - - export function AccountForm() { - const { data: session } = useSession(); const userId = session?.user.id || "" - + const [currentImageUrl, setCurrentImageUrl] = React.useState<string | null>(null) const [previewUrl, setPreviewUrl] = React.useState<string | null>(null) + const [imageError, setImageError] = React.useState<boolean>(false) const form = useForm<AccountFormValues>({ 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 ( + <img + src={previewUrl} + alt="Preview" + width={200} + className="rounded-lg object-cover" + onError={() => setPreviewUrl(null)} + /> + ) + } + + if (currentImageUrl && !imageError) { + return ( + <img + src={currentImageUrl} + alt="Current profile" + width={200} + className="rounded-lg object-cover" + onError={handleImageError} + // 추가 보안: referrer policy 설정 + referrerPolicy="no-referrer" + /> + ) + } + + return ( + <div className="w-[200px] h-[200px] bg-gray-200 rounded-lg flex items-center justify-center"> + <span className="text-gray-500"> + {imageError ? "Image load failed" : "No image"} + </span> + </div> + ) + } return ( <Form {...form}> @@ -188,68 +295,68 @@ export function AccountForm() { <FormField control={form.control} - name="company" - render={({ field }) => ( - <FormItem> - <FormLabel>Company</FormLabel> - <FormControl> - <Input - placeholder="Your Company name" - {...field} - readOnly - className="cursor-not-allowed bg-slate-50" - /> - </FormControl> - <FormDescription> - This is the name that will be displayed on your profile and in - emails. - </FormDescription> - <FormMessage /> - </FormItem> - )} - /> - - - - {/* 이미지 업로드 */} - <FormField - control={form.control} name="imageFile" render={({ field }) => ( <FormItem> <FormLabel>Profile Image</FormLabel> <FormControl> - <div className="space-y-2"> - <Input - type="file" - accept="image/*" - onChange={(e) => { - 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 ? ( - <img src={previewUrl} alt="Local Preview" width={200}/> - ) : ( - typeof field.value === "string" && - field.value && ( - <img - src={`/profiles/${field.value}`} - alt="Server Image" - width={200} - /> - ) - )} - </div> + <div className="space-y-4"> + <Input + type="file" + accept="image/jpeg,image/jpg,image/png,image/webp" + onChange={(e) => { + 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) + } + }} + /> + + <div className="border-2 border-dashed border-gray-300 rounded-lg p-4"> + {getDisplayImage()} + </div> + + {previewUrl && ( + <p className="text-sm text-blue-600">새 이미지 미리보기</p> + )} + {!previewUrl && currentImageUrl && !imageError && ( + <p className="text-sm text-gray-600">현재 프로필 이미지</p> + )} + {imageError && ( + <p className="text-sm text-red-600">이미지를 불러올 수 없습니다</p> + )} + </div> </FormControl> <FormDescription> - Upload your profile image. + Upload your profile image (PNG, JPG, JPEG, WebP, max 5MB). </FormDescription> <FormMessage /> </FormItem> @@ -260,4 +367,4 @@ export function AccountForm() { </form> </Form> ) -} +}
\ No newline at end of file |
