Skip to content
Open
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
6 changes: 5 additions & 1 deletion frontend/src/apis/image.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

presign 요청에서 MIME fallback(image/jpeg)을 쓰는 경우, 실제 PUT 업로드에도 동일한 content-type을 전달해야 서명 헤더가 일치합니다. 현재 upload 호출에서 보정된 contentType 전달이 빠져 있어 4xx(서명 불일치) 리스크가 있습니다.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { handleResponse } from './utils/apiHelpers';
interface PresignedData {
presignedUrl: string;
finalUrl: string;
success: boolean;
failureReason: string | null;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

실패이유도 이제 추가되나보군요

}

interface FeedUploadRequest {
Expand All @@ -16,11 +18,13 @@ interface FeedUploadRequest {
export async function uploadToStorage(
presignedUrl: string,
file: File,
contentType?: string,
): Promise<void> {
const resolvedContentType = contentType || file.type || 'image/jpeg';
const response = await fetch(presignedUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
headers: { 'Content-Type': resolvedContentType },
});
await handleResponse(response, `S3 업로드 실패 : ${response.status}`);
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const StyledButton = styled.button<ButtonProps>`
transition: background-color 0.2s;
width: ${({ width }) => width || 'auto'};

&:hover {
&:hover:not(:disabled) {
background-color: #333333;
${({ animated }) =>
animated &&
Expand All @@ -42,9 +42,9 @@ const StyledButton = styled.button<ButtonProps>`
}

&:disabled {
background-color: #cccccc; /* 비활성화된 느낌의 회색 */
background-color: #cccccc;
color: #666666;
cursor: not-allowed; /* 클릭할 수 없음을 나타내는 커서 */
cursor: not-allowed;
opacity: 0.7;
}
`;
Expand Down
41 changes: 34 additions & 7 deletions frontend/src/hooks/Queries/useClubImages.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

저장 로직이 existingUrls + successfulUrls append 전략이라, 화면에서 섞어서 정렬한 최종 순서(기존+신규 혼합)가 서버에 보존되지 않습니다. feedItems의 최종 순서를 기준으로 URL 배열을 재구성하고, 로컬 항목은 업로드 성공 URL을 같은 위치에 치환하는 방식으로 저장해 주세요.

Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedApi, logoApi, uploadToStorage } from '@/apis/image';
import { queryKeys } from '@/constants/queryKeys';

type ItemStatus = 'pending' | 'uploading' | 'failed';

interface FeedUploadParams {
clubId: string;
files: File[];
existingUrls: string[];
onItemStatusChange?: (index: number, status: ItemStatus) => void;
}

interface FeedUpdateParams {
Expand All @@ -22,11 +25,26 @@ export const useUploadFeed = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async ({ clubId, files, existingUrls }: FeedUploadParams) => {
mutationFn: async ({
clubId,
files,
existingUrls,
onItemStatusChange,
}: FeedUploadParams) => {
// 1. presigned URL 요청
const ALLOWED_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
'image/webp',
];
const uploadRequests = files.map((file) => ({
fileName: file.name,
contentType: file.type,
contentType: ALLOWED_TYPES.includes(file.type)
? file.type
: 'image/jpeg',
Comment on lines 43 to +47
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

서명 요청 MIME과 실제 업로드 MIME을 동일하게 맞춰주세요.

Line 45-47에서 presigned 요청은 정규화된 contentType을 쓰는데, Line 66 업로드는 그 값을 전달하지 않아 헤더가 달라질 수 있습니다. 이 경우 presigned 검증 실패(403)로 이어질 수 있습니다.

🔧 제안 수정안
       const uploadResults = await Promise.allSettled(
         files.map((file, i) => {
           if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
             return Promise.reject(
               new Error(
                 feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
               ),
             );
           }
-          return uploadToStorage(feedResArr[i].presignedUrl, file);
+          return uploadToStorage(
+            feedResArr[i].presignedUrl,
+            file,
+            uploadRequests[i].contentType,
+          );
         }),
       );

Also applies to: 66-67

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/Queries/useClubImages.ts` around lines 43 - 47, The
presign uses a normalized contentType in uploadRequests but the actual upload
doesn't send that same value, causing mismatched MIME and possible 403; update
the upload step to use the normalized contentType from uploadRequests (propagate
the contentType created in the files.map) when performing the PUT/upload (ensure
the request includes the same Content-Type header), referencing the
uploadRequests mapping and the upload logic around the current upload call
(lines near uploadRequests and the upload step at 66-67) so the presign and
actual upload match.

}));
const feedResArr = await feedApi.getUploadUrls(clubId, uploadRequests);

Expand All @@ -35,10 +53,18 @@ export const useUploadFeed = () => {
}

// 2. r2에 병렬 업로드 (개별 성공/실패 추적)
// presigned URL 생성 자체가 실패한 항목은 업로드 건너뜀
const uploadResults = await Promise.allSettled(
files.map((file, i) =>
uploadToStorage(feedResArr[i].presignedUrl, file),
),
files.map((file, i) => {
if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
return Promise.reject(
new Error(
feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
),
Comment on lines +58 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

presigned 응답 인덱스 접근에 방어 로직이 필요합니다.

Line 59와 Line 76에서 feedResArr[i]를 바로 참조하고 있어, 응답 길이가 파일 개수와 다르면 런타임 예외가 발생합니다.

🔧 제안 수정안
       const uploadResults = await Promise.allSettled(
         files.map((file, i) => {
-          if (!feedResArr[i].success || !feedResArr[i].presignedUrl) {
+          const presigned = feedResArr[i];
+          if (!presigned?.success || !presigned.presignedUrl) {
             return Promise.reject(
               new Error(
-                feedResArr[i].failureReason ?? 'presigned URL 생성 실패',
+                presigned?.failureReason ?? 'presigned URL 생성 실패',
               ),
             );
           }
-          return uploadToStorage(feedResArr[i].presignedUrl, file);
+          return uploadToStorage(presigned.presignedUrl, file);
         }),
       );

       uploadResults.forEach((result, i) => {
-        if (result.status === 'fulfilled') {
-          successfulUrls.push(feedResArr[i].finalUrl);
+        const presigned = feedResArr[i];
+        if (result.status === 'fulfilled' && presigned?.finalUrl) {
+          successfulUrls.push(presigned.finalUrl);
         } else {
           failedFiles.push(files[i].name);
           onItemStatusChange?.(i, 'failed');
         }
       });

Also applies to: 75-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/hooks/Queries/useClubImages.ts` around lines 58 - 63, The code
assumes feedResArr has the same length as files and directly indexes
feedResArr[i] in the files.map callback, which can throw if feedResArr is
shorter or missing entries; update the files.map callback in useClubImages.ts to
defensively check that feedResArr[i] exists before accessing its properties
(e.g., if (!feedResArr[i]) return Promise.reject(new Error('missing presigned
response'))), and also guard access to feedResArr[i].success, .presignedUrl and
.failureReason (use nullish or fallback messages). Alternatively iterate over
the smaller of files.length and feedResArr.length or derive promises from
feedResArr to avoid out-of-bounds indexing so both occurrences where
feedResArr[i] is used are protected.

);
}
return uploadToStorage(feedResArr[i].presignedUrl, file);
}),
);

// 3. 성공한 파일만 추출
Expand All @@ -50,6 +76,7 @@ export const useUploadFeed = () => {
successfulUrls.push(feedResArr[i].finalUrl);
} else {
failedFiles.push(files[i].name);
onItemStatusChange?.(i, 'failed');
}
});

Expand All @@ -64,8 +91,8 @@ export const useUploadFeed = () => {
// 6. 서버에 전체 배열 PUT으로 갱신
await feedApi.updateFeeds(clubId, allUrls);

// 7. 실패한 파일 정보 반환
return { clubId, failedFiles };
// 7. 실패한 파일 정보 및 성공 URL 반환
return { clubId, failedFiles, successfulUrls };
},

onSuccess: (data) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { MemoryRouter, Outlet, Route, Routes } from 'react-router-dom';
import type { Meta, StoryObj } from '@storybook/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ClubDetail } from '@/types/club';
import PhotoEditTab from './PhotoEditTab';

const SAMPLE_FEEDS = [
'https://picsum.photos/seed/a/400/500',
'https://picsum.photos/seed/b/400/500',
'https://picsum.photos/seed/c/400/500',
'https://picsum.photos/seed/d/400/500',
'https://picsum.photos/seed/e/400/500',
];

const mockClubDetail = (feeds: string[]): ClubDetail => ({
id: 'club-1',
name: '테스트 동아리',
logo: '',
tags: [],
recruitmentStatus: 'OPEN',
division: '',
category: '',
introduction: '',
description: {
introDescription: '',
activityDescription: '',
awards: [],
idealCandidate: { tags: [], content: '' },
benefits: '',
faqs: [],
},
state: '',
feeds,
presidentName: '',
presidentPhoneNumber: '',
recruitmentForm: '',
recruitmentStart: '',
recruitmentEnd: '',
recruitmentTarget: '',
socialLinks: {} as ClubDetail['socialLinks'],
});

const makeQueryClient = () =>
new QueryClient({ defaultOptions: { queries: { retry: false } } });

const Wrapper = ({ feeds = SAMPLE_FEEDS }: { feeds?: string[] }) => (
<QueryClientProvider client={makeQueryClient()}>
<div style={{ maxWidth: 600, padding: 24 }}>
<MemoryRouter initialEntries={['/admin/photo']}>
<Routes>
<Route
path='/admin/photo'
element={<Outlet context={mockClubDetail(feeds)} />}
>
<Route index element={<PhotoEditTab />} />
</Route>
</Routes>
</MemoryRouter>
</div>
</QueryClientProvider>
);

const meta = {
title: 'Admin/PhotoEditTab',
parameters: { layout: 'centered' },
} satisfies Meta;

export default meta;
type Story = StoryObj;

export const WithPhotos: Story = {
render: () => <Wrapper />,
};

export const Empty: Story = {
render: () => <Wrapper feeds={[]} />,
};

export const ManyPhotos: Story = {
render: () => (
<Wrapper
feeds={Array.from(
{ length: 9 },
(_, i) => `https://picsum.photos/seed/${i + 10}/400/500`,
)}
/>
),
};
Loading
Loading