Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,53 @@ import type { NextConfig } from 'next';
import type { RemotePattern } from 'next/dist/shared/lib/image-config';

const isProd = process.env.NODE_ENV === 'production';
const isDev = process.env.NODE_ENV === 'development';

const nextConfig: NextConfig = {
// [보안] 보안 HTTP 헤더 설정.
// CSP는 스테이징 검증 중이므로 Content-Security-Policy-Report-Only 모드로 먼저 배포.
// 결제·로그인 E2E 검증 후 위반 없으면 Content-Security-Policy로 전환할 것.
async headers() {
const cspDirectives = [
"default-src 'self'",
// GTM, Clarity, 토스페이먼츠 스크립트 허용. 개발 환경에서는 'unsafe-eval' 추가(Next.js HMR 필요).
`script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.clarity.ms https://js.tosspayments.com${isDev ? " 'unsafe-eval'" : ''}`,
"style-src 'self' 'unsafe-inline'",
// 카카오·구글 프로필 이미지, 자사 API/CMS 이미지 도메인 허용
"img-src 'self' data: blob: https://img1.kakaocdn.net https://lh3.googleusercontent.com https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.zeroone.it.kr https://test-blog.zeroone.it.kr",
// API, GA, Clarity, 토스, 카카오/구글 OAuth 연결 허용. 개발에서는 HMR WebSocket 추가.
`connect-src 'self' https://api.zeroone.it.kr https://test-api.zeroone.it.kr https://www.google-analytics.com https://www.clarity.ms https://api.tosspayments.com https://kauth.kakao.com https://accounts.google.com${isDev ? ' ws://localhost:*' : ''}`,
// 토스 결제창 iframe 허용
'frame-src https://pay.toss.im https://cert.tosspayments.com',
"font-src 'self' data:",
].join('; ');

return [
{
source: '/(.*)',
headers: [
// Report-Only 모드: 위반을 차단하지 않고 콘솔에 경고만 출력. 검증 완료 후 CSP로 전환 예정.
{ key: 'Content-Security-Policy-Report-Only', value: cspDirectives },
// [클릭재킹 방지] 동일 출처의 iframe만 허용
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
// [MIME 스니핑 방지] 브라우저가 Content-Type을 임의로 변경하는 행위 차단
{ key: 'X-Content-Type-Options', value: 'nosniff' },
// [정보 유출 방지] 크로스 오리진 요청 시 origin만 전송
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
// [HTTPS 강제] 1년간 HTTPS만 허용 (프로덕션에서 유효)
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
},
// [브라우저 API 제한] 불필요한 카메라·마이크·위치정보 API 비활성화
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
];
},
// output: 'standalone',
/* config options here */
// 외부 이미지 도메인 허용 설정 추가
Expand Down
13 changes: 12 additions & 1 deletion src/app/api/auth/clear-session/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

// new URL(path, base)는 path가 절대 URL이면 base를 무시하므로 반드시 사전 검증 필요.
function isSafeRedirectPath(path: string) {
if (!path) return false;
if (!path.startsWith('/')) return false; // 절대 URL(https://evil.com) 차단
if (path.startsWith('//')) return false; // 프로토콜-상대 URL(//evil.com) 차단
if (path.startsWith('\\')) return false; // 역슬래시로 시작하는 경로 차단 (예: \evil.com)

return true;
}

export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const redirectTo = searchParams.get('redirect') || '/login';
const rawRedirect = searchParams.get('redirect') || '/login';
const redirectTo = isSafeRedirectPath(rawRedirect) ? rawRedirect : '/login';

// 쿠키 삭제
const cookieStore = await cookies();
Expand Down
139 changes: 82 additions & 57 deletions src/components/ui/image-upload-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import Image from 'next/image';
import { useState, DragEvent, ChangeEvent, useRef } from 'react';
import Button from '@/components/ui/button';

// 클라이언트에서 선제 차단하여 불필요한 413 에러 및 대용량 업로드 요청을 방지.
const DEFAULT_MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

const inputStyles = {
base: 'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500',
dragging: 'border-border-brand bg-fill-brand-subtle-hover',
Expand All @@ -13,12 +16,31 @@ const inputStyles = {
export default function ImageUploadInput({
image,
onChangeImage,
maxSizeBytes = DEFAULT_MAX_SIZE_BYTES,
}: {
image?: string;
onChangeImage: (file: File | undefined) => void;
maxSizeBytes?: number;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [sizeError, setSizeError] = useState<string | null>(null);

const validateAndChange = (file: File) => {
if (!file.type.startsWith('image/')) {
setSizeError(null);

return;
}
if (file.size > maxSizeBytes) {
const maxMb = (maxSizeBytes / 1024 / 1024).toFixed(0);
setSizeError(`이미지 파일 크기는 ${maxMb}MB 이하만 업로드할 수 있어요.`);

return;
}
setSizeError(null);
onChangeImage(file);
};

const handleOpenFileDialog = () => {
fileInputRef.current?.click();
Expand Down Expand Up @@ -49,77 +71,80 @@ export default function ImageUploadInput({
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
onChangeImage(file);
}
if (file) validateAndChange(file);
};

const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('image/')) {
onChangeImage(file);
}
if (file) validateAndChange(file);
e.target.value = ''; // 같은 파일 재업로드 허용을 위해 입력값 초기화
};

const handleRemove = () => {
setSizeError(null);
if (fileInputRef.current) fileInputRef.current.value = '';
onChangeImage(undefined);
};

return (
<div
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={`${inputStyles.base} ${isDragging ? inputStyles.dragging : inputStyles.notDragging}`}
>
{!image ? (
<div className="flex flex-col items-center justify-center gap-300">
<div className="flex flex-col items-center justify-center gap-150">
<div className="flex flex-col gap-1">
<div
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
className={`${inputStyles.base} ${isDragging ? inputStyles.dragging : inputStyles.notDragging}`}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/components/ui/image-upload-input.tsx | head -150

Repository: code-zero-to-one/study-platform-client

Length of output: 5532


🏁 Script executed:

grep -r "clsx\|tailwind-merge\|class-variance-authority" --include="package.json" --include="*.ts" --include="*.tsx" | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 1650


🏁 Script executed:

cat -n src/components/ui/button/index.tsx | head -50

Repository: code-zero-to-one/study-platform-client

Length of output: 2111


🏁 Script executed:

cat -n src/components/ui/button/index.tsx | sed -n '50,70p'

Repository: code-zero-to-one/study-platform-client

Length of output: 739


🏁 Script executed:

cat -n src/components/ui/\(shadcn\)/lib/utils.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 292


동적 클래스 조합을 CVA와 cn() 유틸로 통일해 주세요.

현재 템플릿 문자열 조합은 프로젝트 스타일 가이드와 불일치합니다. 다른 UI 컴포넌트(Button, Badge, Chip 등)와 같이 CVA를 사용하고, 프로젝트의 cn() 유틸리티를 활용해주세요.

제안 패치
+import { cva } from 'class-variance-authority';
+import { cn } from '@/components/ui/(shadcn)/lib/utils';
 import Image from 'next/image';
 import { useState, DragEvent, ChangeEvent, useRef } from 'react';
 import Button from '@/components/ui/button';

-const inputStyles = {
-  base: 'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500',
-  dragging: 'border-border-brand bg-fill-brand-subtle-hover',
-  notDragging: 'border-gray-300 border-dashed',
-};
+const inputStyles = cva(
+  'rounded-100 flex w-full flex-col items-center justify-center border-2 p-500',
+  {
+    variants: {
+      dragging: {
+        true: 'border-border-brand bg-fill-brand-subtle-hover',
+        false: 'border-gray-300 border-dashed',
+      },
+    },
+    defaultVariants: {
+      dragging: false,
+    },
+  },
+);
...
- className={`${inputStyles.base} ${isDragging ? inputStyles.dragging : inputStyles.notDragging}`}
+ className={cn(inputStyles({ dragging: isDragging }))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/image-upload-input.tsx` at line 96, Replace the
template-string class composition with the project's cn() util and CVA-based
style tokens: stop using `${inputStyles.base} ${isDragging ?
inputStyles.dragging : inputStyles.notDragging}` and instead compose classes
with cn(inputStyles.base, isDragging ? inputStyles.dragging :
inputStyles.notDragging). If inputStyles is not already a cva(...) descriptor,
convert its definition to use cva(...) (exported as inputStyles) with variants
for dragging vs notDragging, then call cn(inputStyles(), { /* variant keys if
used */ }) or cn(inputStyles.base, ...) as appropriate so the component (using
isDragging) follows the project's CVA + cn pattern.

>
{!image ? (
<div className="flex flex-col items-center justify-center gap-300">
<div className="flex flex-col items-center justify-center gap-150">
<Image
src="/icons/camera.svg"
width={32}
height={32}
alt="파일 업로드"
/>
Comment on lines +101 to +106
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists
fd "image-upload-input.tsx" src/

Repository: code-zero-to-one/study-platform-client

Length of output: 122


🏁 Script executed:

# Check next.config configuration
cat -n next.config.ts

Repository: code-zero-to-one/study-platform-client

Length of output: 5783


🏁 Script executed:

# Search for SVG imports to see current patterns
rg "\.svg['\"]" src/ -A 2 -B 2 | head -40

Repository: code-zero-to-one/study-platform-client

Length of output: 2824


🏁 Script executed:

# Check if icons/camera.svg exists
fd "camera.svg"

Repository: code-zero-to-one/study-platform-client

Length of output: 105


🏁 Script executed:

# Read the image-upload-input.tsx file to verify the current code
cat -n src/components/ui/image-upload-input.tsx | head -120 | tail -30

Repository: code-zero-to-one/study-platform-client

Length of output: 1328


🏁 Script executed:

# Check the complete context around lines 101-106
sed -n '95,110p' src/components/ui/image-upload-input.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 709


🏁 Script executed:

# Check imports at the top of the file to understand current pattern
head -20 src/components/ui/image-upload-input.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 683


SVG를 React 컴포넌트로 import해 주세요.

@svgr/webpack으로 설정된 SVG React 컴포넌트 import 방식을 사용하세요. 다른 파일들(예: home-dashboard.tsx)처럼 SVG를 컴포넌트로 가져와 사용하는 것이 프로젝트 가이드에 맞습니다.

제안 패치
+import CameraIcon from 'public/icons/camera.svg';
...
-<Image
-  src="/icons/camera.svg"
-  width={32}
-  height={32}
-  alt="파일 업로드"
-/>

+<CameraIcon
+  width={32}
+  height={32}
+  aria-label="파일 업로드"
+/>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Image
src="/icons/camera.svg"
width={32}
height={32}
alt="파일 업로드"
/>
<CameraIcon
width={32}
height={32}
aria-label="파일 업로드"
/>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/image-upload-input.tsx` around lines 101 - 106, Replace the
raw Image tag using src="/icons/camera.svg" with an SVG React component imported
via the project's svgr setup: import the SVG (e.g. camera.svg) as a React
component and render that component in place of the <Image ...
src="/icons/camera.svg" .../> element (adjust props like width/height/alt or
className as needed); update the import at the top of the file accordingly and
remove the static Image usage so it matches other files (e.g.
home-dashboard.tsx) that use SVGs as React components.

<span className="font-designer-18m text-text-default">
드래그하여 파일 업로드
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
onClick={handleOpenFileDialog}
color="primary"
size="small"
type="button"
>
파일 업로드
</Button>
</div>
) : (
<div className="relative">
<Image
src="/icons/camera.svg"
width={32}
height={32}
alt="파일 업로드"
src={image}
alt="preview"
width={240}
height={180}
className="rounded-lg object-cover"
/>
<span className="font-designer-18m text-text-default">
드래그하여 파일 업로드
</span>
<button
type="button"
onClick={handleRemove}
aria-label="이미지 삭제"
className="bg-background-dimmer border-border-inverse text-text-inverse absolute top-0 right-0 flex h-[36px] w-[36px] translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border"
>
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
onClick={handleOpenFileDialog}
color="primary"
size="small"
type="button"
>
파일 업로드
</Button>
</div>
) : (
<div className="relative">
<Image
src={image}
alt="preview"
width={240}
height={180}
className="rounded-lg object-cover"
/>
<button
type="button"
onClick={handleRemove}
className="bg-background-dimmer border-border-inverse text-text-inverse absolute top-0 right-0 flex h-[36px] w-[36px] translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border"
>
</button>
</div>
)}
)}
</div>
{sizeError && <p className="text-text-danger text-sm">{sizeError}</p>}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

오류 메시지에 라이브 리전을 추가해 주세요.

동적으로 바뀌는 검증 오류는 스크린리더에 즉시 전달되도록 role="alert"(또는 aria-live)를 붙이는 게 안전합니다.

제안 패치
-      {sizeError && <p className="text-text-danger text-sm">{sizeError}</p>}
+      {sizeError && (
+        <p role="alert" aria-live="polite" className="text-text-danger text-sm">
+          {sizeError}
+        </p>
+      )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{sizeError && <p className="text-text-danger text-sm">{sizeError}</p>}
{sizeError && (
<p role="alert" aria-live="polite" className="text-text-danger text-sm">
{sizeError}
</p>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/image-upload-input.tsx` at line 147, The validation message
element that renders {sizeError} should announce dynamically to assistive tech;
update the JSX that renders sizeError (the <p className="text-text-danger
text-sm">{sizeError}</p>) to include an accessibility live region such as
role="alert" (or aria-live="assertive") so screen readers are notified
immediately when sizeError appears; keep the conditional render as-is and only
add the attribute to the <p> element.

</div>
);
}
82 changes: 68 additions & 14 deletions src/features/study/group/model/group-study-form.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ import {

const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;

// [보안/데이터 무결성] 백엔드 DTO의 @Size 어노테이션과 동기화.
// 출처: GroupStudyDetailInfoRequestDto.java, GroupStudyInterviewPostRequestDto.java
export const GROUP_STUDY_TITLE_MAX_LENGTH = 100;
export const GROUP_STUDY_SUMMARY_MAX_LENGTH = 200;
export const GROUP_STUDY_DESCRIPTION_MAX_LENGTH = 1000;
export const GROUP_STUDY_LOCATION_MAX_LENGTH = 255;
export const GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH = 500;

export const STUDY_CLASSIFICATION = ['GROUP_STUDY', 'PREMIUM_STUDY'] as const;
export type StudyClassification = (typeof STUDY_CLASSIFICATION)[number];

Expand All @@ -44,7 +52,13 @@ export const GroupStudyFormSchema = z
// 진행 방식(1)
method: z.enum(STUDY_METHODS),
// 진행 방식 (위치) optional (1)
location: z.string().trim(),
location: z
.string()
.trim()
.max(
GROUP_STUDY_LOCATION_MAX_LENGTH,
`위치는 ${GROUP_STUDY_LOCATION_MAX_LENGTH}자 이하로 입력해주세요.`,
),
// 정기 모임(1)
regularMeeting: z.enum(REGULAR_MEETINGS),
// 진행 기간 (시작일) (1)
Expand All @@ -59,11 +73,32 @@ export const GroupStudyFormSchema = z
.regex(ISO_DATE_REGEX, 'YYYY-MM-DD 형식의 종료일을 입력해 주세요.'),
price: z.string().trim().optional(),
// 스터디 제목(2)
title: z.string().trim().min(1, '스터디 제목을 입력해주세요.'),
title: z
.string()
.trim()
.min(1, '스터디 제목을 입력해주세요.')
.max(
GROUP_STUDY_TITLE_MAX_LENGTH,
`제목은 ${GROUP_STUDY_TITLE_MAX_LENGTH}자 이하로 입력해주세요.`,
),
// 스터디 한 줄 소개(2)
summary: z.string().trim().min(1, '한 줄 소개를 입력해주세요.'),
summary: z
.string()
.trim()
.min(1, '한 줄 소개를 입력해주세요.')
.max(
GROUP_STUDY_SUMMARY_MAX_LENGTH,
`한 줄 소개는 ${GROUP_STUDY_SUMMARY_MAX_LENGTH}자 이하로 입력해주세요.`,
),
// 스터디 소개(2)
description: z.string().trim().min(1, '스터디 소개를 입력해주세요.'),
description: z
.string()
.trim()
.min(1, '스터디 소개를 입력해주세요.')
.max(
GROUP_STUDY_DESCRIPTION_MAX_LENGTH,
`스터디 소개는 ${GROUP_STUDY_DESCRIPTION_MAX_LENGTH}자 이하로 입력해주세요.`,
),
// 썸네일 START(2)
thumbnailExtension: z
.enum(THUMBNAIL_EXTENSION)
Expand All @@ -73,12 +108,31 @@ export const GroupStudyFormSchema = z
// 썸네일 END(2)
// 스터디원에게 보여줄 질문을 입력하세요(3)
interviewPost: z
.array(z.string())
.refine((arr) => arr.length > 0 && arr.every((v) => v.trim() !== ''), {
message: '모든 질문을 입력해야 합니다.',
})
.refine((arr) => arr.length <= 10, {
message: '질문은 최대 10개까지만 입력할 수 있습니다.',
.array(
z
.string()
.trim()
.max(
GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH,
`질문은 ${GROUP_STUDY_INTERVIEW_Q_MAX_LENGTH}자 이하로 입력해주세요.`,
),
)
.superRefine((arr, ctx) => {
if (arr.length > 10) {
ctx.addIssue({
code: "custom",
message: '질문은 최대 10개까지만 입력할 수 있습니다.',
});
}
arr.forEach((item, idx) => {
if (!item || item.trim() === '') {
ctx.addIssue({
code: "custom",
message: '질문을 입력해주세요.',
path: [idx],
});
}
});
}),
})
.superRefine((data, ctx) => {
Expand All @@ -87,7 +141,7 @@ export const GroupStudyFormSchema = z
if (data.classification === 'PREMIUM_STUDY') {
if (!data.price || priceNum < 10000) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: '참가비는 10,000원 이상이어야 합니다.',
path: ['price'],
});
Expand All @@ -96,7 +150,7 @@ export const GroupStudyFormSchema = z
// 그룹 스터디: 0원만 허용
else if (data.price && priceNum !== 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: '그룹 스터디는 무료만 가능합니다.',
path: ['price'],
});
Expand All @@ -120,7 +174,7 @@ export const GroupStudyFormSchema = z

if (data.startDate < tomorrowYmd) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: '스터디 시작일은 내일부터 설정할 수 있습니다.',
path: ['startDate'],
});
Expand All @@ -136,7 +190,7 @@ export const GroupStudyFormSchema = z
const end = parseDate(data.endDate);
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message: '종료일은 시작일과 같거나 이후여야 합니다.',
path: ['endDate'],
});
Expand Down
Loading
Loading