Skip to content

Commit fe1f90b

Browse files
committed
fix(uploads): bodySizeLimit + shared client preflight
1 parent 5b5e57e commit fe1f90b

8 files changed

Lines changed: 156 additions & 45 deletions

File tree

.gjc/skills/murun-feature/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ PR #28, #29 의 사고 (silent noop, 더블 클릭 row 중복) 가 이 단계
7070
- [ ] **중복 생성 방지**: DB 수준 unique constraint (or 트랜잭션) 가 2차 안전망으로 존재
7171
- [ ] 검증 로직은 **server action 안의 zod 또는 parseOptionalNumber** — client 검증만 의존 X (우회 방지)
7272
- [ ] (해당 시) 검증 실패 시 입력값이 사라지지 않는지 — 부분 보존이라도 OK
73+
- [ ] **파일 업로드를 받는 경우 (framework limit + client preflight 둘 다 필요)**
74+
- `next.config.ts``experimental.serverActions.bodySizeLimit` 이 우리 코드 상한(`MAX_UPLOAD_BYTES`) 보다 한 단계 크게 잡혀 있는가 (Next 기본 1MB 면 큰 사진이 generic 413 으로 죽음 — PR #44 의 사고)
75+
- **client preflight** (`checkUploadFile`) 가 size/MIME 를 미리 잡아 server action 까지 안 보내는가 — body limit 우회 + 빠른 피드백
76+
- server action 안에서도 같은 검증을 **한 번 더** 수행 (defense in depth — client preflight 우회 대비)
77+
- client/server 가 같은 상수(`lib/upload-limits.ts`) 를 import 하는가 — 두 곳이 어긋나면 메시지/제한 불일치
7378

7479
### 5. Self-check
7580
[`docs/wiki/06-Checkpoints.md`](../../../docs/wiki/06-Checkpoints.md) 의 7개 항목을 PR 본문에 박은 채로 사용자가 직접 확인하도록 둔다. Agent는 박스를 자기가 채우지 않는다.

app/sessions/[id]/_components/PhotoReplaceButton.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,38 @@
22

33
import { useRef } from "react";
44

5-
5+
import {
6+
ALLOWED_ACCEPT_ATTR,
7+
checkUploadFile,
8+
} from "@/lib/upload-limits";
69

710
type Props = {
811
sessionId: number;
912
/**
10-
* useActionState 가 만들어준 formAction. 부모(PhotoSection) 의 alert/pending 상태와
11-
* 공유하기 위해 props 로 받음.
13+
* useActionState 가 만들어준 formAction. 부모(PhotoSection) 의 alert/pending
14+
* 상태와 공유하기 위해 props 로 받는다.
1215
*/
1316
formAction: (formData: FormData) => void;
17+
/**
18+
* client preflight 가 막은 에러를 부모 ErrorAlert 로 올린다.
19+
*/
20+
onClientError: (message: string | null) => void;
1421
};
1522

1623
/**
1724
* 사진 우측 [교체] 버튼. 파일 선택 즉시 form auto-submit.
18-
* upload action 은 PhotoSection 의 useActionState 가 관리 — 결과/pending 이 한 곳에.
25+
*
26+
* - 파일 크기/형식은 client preflight 로 미리 잡아서 server action body limit
27+
* (Next 기본 1MB) 에 의한 generic 413 을 막는다.
28+
* - 통과한 경우에만 form.requestSubmit() 으로 server action 호출.
1929
*/
20-
export function PhotoReplaceButton({ sessionId, formAction }: Props) {
30+
export function PhotoReplaceButton({
31+
sessionId,
32+
formAction,
33+
onClientError,
34+
}: Props) {
2135
const formRef = useRef<HTMLFormElement>(null);
36+
2237
return (
2338
<form ref={formRef} action={formAction} className="inline-flex">
2439
<input type="hidden" name="sessionId" value={sessionId} />
@@ -27,16 +42,25 @@ export function PhotoReplaceButton({ sessionId, formAction }: Props) {
2742
<input
2843
type="file"
2944
name="photo"
30-
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
45+
accept={ALLOWED_ACCEPT_ATTR}
3146
className="hidden"
3247
onChange={(e) => {
33-
if (e.currentTarget.files?.length) {
34-
formRef.current?.requestSubmit();
48+
const f = e.currentTarget.files?.[0] ?? null;
49+
if (!f) {
50+
onClientError(null);
51+
return;
52+
}
53+
const err = checkUploadFile(f);
54+
if (err) {
55+
onClientError(err);
56+
e.currentTarget.value = "";
57+
return;
3558
}
59+
onClientError(null);
60+
formRef.current?.requestSubmit();
3661
}}
3762
/>
3863
</label>
3964
</form>
4065
);
4166
}
42-

app/sessions/[id]/_components/PhotoSection.tsx

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
"use client";
22

33
import Image from "next/image";
4-
import { useActionState } from "react";
4+
import { useActionState, useState } from "react";
55

66
import { ErrorAlert } from "@/components/form/ErrorAlert";
77
import { SubmitButton } from "@/components/form/SubmitButton";
8+
import {
9+
ALLOWED_ACCEPT_ATTR,
10+
MAX_UPLOAD_MB,
11+
checkUploadFile,
12+
} from "@/lib/upload-limits";
813
import { encodeUploadPath } from "@/lib/upload-url";
914

1015
import { uploadSessionPhoto, removeSessionPhoto } from "../photo-actions";
@@ -22,6 +27,10 @@ type Props = {
2227
* - 사진 있음: next/image 16:9. canEdit 면 [교체]/[삭제] + 에러 alert.
2328
* - 사진 없음 + canEdit: 업로드 폼 + 에러 alert.
2429
* - 사진 없음 + 비편집자: '사진 없음' placeholder.
30+
*
31+
* 큰 파일은 client preflight 에서 즉시 잡아준다. server action 의 body limit
32+
* 까지 도달했다가 generic 413 으로 죽는 케이스 방지. server-side 도 동일한
33+
* `checkUploadFile` 을 한 번 더 호출 (defense in depth).
2534
*/
2635
export function PhotoSection({
2736
sessionId,
@@ -31,11 +40,42 @@ export function PhotoSection({
3140
}: Props) {
3241
const [uploadState, uploadAction] = useActionState(uploadSessionPhoto, null);
3342
const [removeState, removeAction] = useActionState(removeSessionPhoto, null);
43+
const [clientError, setClientError] = useState<string | null>(null);
44+
3445
const uploadError =
3546
uploadState && !uploadState.ok ? uploadState.error : null;
3647
const removeError =
3748
removeState && !removeState.ok ? removeState.error : null;
38-
const error = uploadError ?? removeError;
49+
const error = clientError ?? uploadError ?? removeError;
50+
51+
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
52+
const f = e.currentTarget.files?.[0] ?? null;
53+
if (!f) {
54+
setClientError(null);
55+
return;
56+
}
57+
const err = checkUploadFile(f);
58+
if (err) {
59+
setClientError(err);
60+
e.currentTarget.value = "";
61+
} else {
62+
setClientError(null);
63+
}
64+
}
65+
66+
function handleUploadSubmit(e: React.FormEvent<HTMLFormElement>) {
67+
const input = e.currentTarget.elements.namedItem(
68+
"photo",
69+
) as HTMLInputElement | null;
70+
const f = input?.files?.[0] ?? null;
71+
const err = checkUploadFile(f);
72+
if (err) {
73+
e.preventDefault();
74+
setClientError(err);
75+
} else {
76+
setClientError(null);
77+
}
78+
}
3979

4080
if (groupPhotoPath) {
4181
const src = `/api/uploads/${encodeUploadPath(groupPhotoPath)}`;
@@ -55,7 +95,11 @@ export function PhotoSection({
5595
<div className="mt-2 flex flex-col gap-2">
5696
<ErrorAlert message={error} />
5797
<div className="flex gap-3 text-xs">
58-
<PhotoReplaceButton sessionId={sessionId} formAction={uploadAction} />
98+
<PhotoReplaceButton
99+
sessionId={sessionId}
100+
formAction={uploadAction}
101+
onClientError={setClientError}
102+
/>
59103
<form action={removeAction}>
60104
<input type="hidden" name="sessionId" value={sessionId} />
61105
<SubmitButton
@@ -80,14 +124,16 @@ export function PhotoSection({
80124
<ErrorAlert message={error} />
81125
<form
82126
action={uploadAction}
127+
onSubmit={handleUploadSubmit}
83128
className="flex flex-col gap-2 sm:flex-row sm:items-center"
84129
>
85130
<input type="hidden" name="sessionId" value={sessionId} />
86131
<input
87132
type="file"
88133
name="photo"
89-
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
134+
accept={ALLOWED_ACCEPT_ATTR}
90135
required
136+
onChange={handleFileChange}
91137
className="block w-full text-sm file:mr-3 file:rounded-md file:border file:border-input file:bg-background file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-accent"
92138
/>
93139
<SubmitButton
@@ -97,7 +143,7 @@ export function PhotoSection({
97143
/>
98144
</form>
99145
<p className="text-xs text-muted-foreground">
100-
jpg / png / webp / heic, 최대 15MB.
146+
jpg / png / webp / heic, 최대 {MAX_UPLOAD_MB}MB.
101147
</p>
102148
</section>
103149
);

app/sessions/[id]/photo-actions.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,8 @@ import { z } from "zod";
55

66
import { db } from "@/lib/db";
77
import { requireHostOrAdmin } from "@/lib/guard";
8-
import {
9-
ALLOWED_MIME_TYPES,
10-
MAX_UPLOAD_BYTES,
11-
deleteUploadedFile,
12-
saveUploadedFile,
13-
} from "@/lib/uploads";
8+
import { checkUploadFile } from "@/lib/upload-limits";
9+
import { deleteUploadedFile, saveUploadedFile } from "@/lib/uploads";
1410

1511
export type PhotoResult = { ok: true } | { ok: false; error: string };
1612

@@ -33,22 +29,11 @@ export async function uploadSessionPhoto(
3329
await requireHostOrAdmin(sessionId);
3430

3531
const file = formData.get("photo");
36-
if (!(file instanceof File)) {
37-
return { ok: false, error: "사진 파일을 선택하세요." };
38-
}
39-
if (file.size <= 0) {
40-
return { ok: false, error: "빈 파일입니다." };
41-
}
42-
if (file.size > MAX_UPLOAD_BYTES) {
43-
const mb = (file.size / 1024 / 1024).toFixed(1);
44-
return { ok: false, error: `파일이 너무 큽니다 (${mb} MB > 15 MB).` };
45-
}
46-
if (!ALLOWED_MIME_TYPES.has((file.type ?? "").toLowerCase())) {
47-
return {
48-
ok: false,
49-
error: "지원하지 않는 파일 형식입니다. (jpg / png / webp / heic)",
50-
};
32+
const preflight = checkUploadFile(file instanceof File ? file : null);
33+
if (preflight) {
34+
return { ok: false, error: preflight };
5135
}
36+
const photo = file as File;
5237

5338
const existing = await db.session.findUnique({
5439
where: { id: sessionId },
@@ -57,7 +42,7 @@ export async function uploadSessionPhoto(
5742

5843
let relPath: string;
5944
try {
60-
const saved = await saveUploadedFile(file, "sessions");
45+
const saved = await saveUploadedFile(photo, "sessions");
6146
relPath = saved.relPath;
6247
} catch (err) {
6348
// saveUploadedFile 안의 추가 검증(이미 위에서 잡지만 안전망)

docs/wiki/06-Checkpoints.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
- 빠른 더블/연속 클릭으로 row 중복 생성
6565
- 권한 없는 사용자로 동일 페이지 진입
6666
- 인증 만료 / 쿠키 삭제 후 새로고침
67+
- 파일 업로드면 **framework limit 경계** 도 같이: Next Server Actions 기본 1MB 를 넘는 파일이 들어왔을 때 우리 코드의 에러 메시지가 보이는가 (PR #44 사고 — 큰 사진이 generic 413 으로 죽는 동안 inline 에러는 안 떴음)
6768
- Agent 가 "코드 측 검증 통과" 라고 보고하는 것 ≠ 사용자가 동작 확인. **빌드 그린은 시작점**.
6869

6970
---

lib/upload-limits.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// 업로드 제한 / 검증 — pure (no node deps). client/server 양쪽에서 import 가능.
2+
//
3+
// 같은 사실(15MB, 허용 MIME)을:
4+
// - server: photo-actions / lib/uploads 에서 최종 검증
5+
// - client: PhotoSection / PhotoReplaceButton 에서 사전 검증
6+
// 양쪽이 같은 값을 보게 하려고 여기 한 곳에 둔다.
7+
8+
export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB
9+
export const MAX_UPLOAD_MB = MAX_UPLOAD_BYTES / 1024 / 1024;
10+
11+
export const ALLOWED_MIME_TYPES = new Set<string>([
12+
"image/jpeg",
13+
"image/png",
14+
"image/webp",
15+
"image/heic",
16+
"image/heif",
17+
]);
18+
19+
export const ALLOWED_ACCEPT_ATTR =
20+
"image/jpeg,image/png,image/webp,image/heic,image/heif";
21+
22+
/**
23+
* 업로드 전 사전 검증. 통과면 null, 실패면 사용자에게 보여줄 한국어 에러.
24+
*
25+
* 주의:
26+
* - 이 함수는 client/server 둘 다에서 호출된다.
27+
* - server action 본문 안에서 한 번 더 호출해 "client preflight 우회" 를 막는다.
28+
*/
29+
export function checkUploadFile(file: File | null | undefined): string | null {
30+
if (!file) return "사진 파일을 선택하세요.";
31+
if (file.size <= 0) return "빈 파일입니다.";
32+
if (file.size > MAX_UPLOAD_BYTES) {
33+
const mb = (file.size / 1024 / 1024).toFixed(1);
34+
return `파일이 너무 큽니다 (${mb} MB > ${MAX_UPLOAD_MB} MB).`;
35+
}
36+
const mime = (file.type ?? "").toLowerCase();
37+
if (mime && !ALLOWED_MIME_TYPES.has(mime)) {
38+
return "지원하지 않는 파일 형식입니다. (jpg / png / webp / heic)";
39+
}
40+
return null;
41+
}

lib/uploads.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
1616
import { existsSync } from "node:fs";
1717
import path from "node:path";
1818

19-
export const ALLOWED_MIME_TYPES = new Set([
20-
"image/jpeg",
21-
"image/png",
22-
"image/webp",
23-
"image/heic",
24-
"image/heif",
25-
]);
19+
import {
20+
ALLOWED_MIME_TYPES,
21+
MAX_UPLOAD_BYTES,
22+
MAX_UPLOAD_MB,
23+
} from "@/lib/upload-limits";
24+
25+
// 호환을 위해 server 쪽 import 경로도 유지 (기존 코드: from "@/lib/uploads").
26+
export { ALLOWED_MIME_TYPES, MAX_UPLOAD_BYTES } from "@/lib/upload-limits";
2627

27-
export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB
2828

2929
const MIME_TO_EXT: Record<string, string> = {
3030
"image/jpeg": "jpg",
@@ -95,7 +95,7 @@ export async function saveUploadedFile(
9595
}
9696
if (file.size > MAX_UPLOAD_BYTES) {
9797
throw new Error(
98-
`파일이 너무 큽니다 (${(file.size / 1024 / 1024).toFixed(1)} MB > 15 MB).`,
98+
`파일이 너무 큽니다 (${(file.size / 1024 / 1024).toFixed(1)} MB > ${MAX_UPLOAD_MB} MB).`,
9999
);
100100
}
101101
const mime = (file.type || "").toLowerCase();

next.config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ const nextConfig: NextConfig = {
2424
},
2525
// PrismaClient 가 Next 의 bundler 에 의해 traced 되지 않도록 external 처리.
2626
serverExternalPackages: ["@prisma/client", ".prisma/client"],
27+
// 사진 업로드 server action 은 multipart/form-data 로 최대 15MB 원본 + multipart
28+
// overhead 까지 도달할 수 있다. Next 의 기본 1MB 제한이면 큰 JPG 가 우리 코드
29+
// 검증(`lib/upload-limits.ts`)에 도달하지 못하고 generic 413 으로 죽는다.
30+
// 진짜 상한은 lib 의 MAX_UPLOAD_BYTES(15MB) 이고, 이 값은 그보다 한 단계 위.
31+
experimental: {
32+
serverActions: {
33+
bodySizeLimit: "20mb",
34+
},
35+
},
2736
};
2837

2938
export default nextConfig;

0 commit comments

Comments
 (0)