summaryrefslogtreecommitdiff
path: root/components/settings/account-form.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/settings/account-form.tsx')
-rw-r--r--components/settings/account-form.tsx283
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