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

### 5. Self-check
[`docs/wiki/06-Checkpoints.md`](../../../docs/wiki/06-Checkpoints.md) 의 7개 항목을 PR 본문에 박은 채로 사용자가 직접 확인하도록 둔다. Agent는 박스를 자기가 채우지 않는다.
Expand Down
42 changes: 33 additions & 9 deletions app/sessions/[id]/_components/PhotoReplaceButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,38 @@

import { useRef } from "react";


import {
ALLOWED_ACCEPT_ATTR,
checkUploadFile,
} from "@/lib/upload-limits";

type Props = {
sessionId: number;
/**
* useActionState 가 만들어준 formAction. 부모(PhotoSection) 의 alert/pending 상태와
* 공유하기 위해 props 로 받음.
* useActionState 가 만들어준 formAction. 부모(PhotoSection) 의 alert/pending
* 상태와 공유하기 위해 props 로 받는다.
*/
formAction: (formData: FormData) => void;
/**
* client preflight 가 막은 에러를 부모 ErrorAlert 로 올린다.
*/
onClientError: (message: string | null) => void;
};

/**
* 사진 우측 [교체] 버튼. 파일 선택 즉시 form auto-submit.
* upload action 은 PhotoSection 의 useActionState 가 관리 — 결과/pending 이 한 곳에.
*
* - 파일 크기/형식은 client preflight 로 미리 잡아서 server action body limit
* (Next 기본 1MB) 에 의한 generic 413 을 막는다.
* - 통과한 경우에만 form.requestSubmit() 으로 server action 호출.
*/
export function PhotoReplaceButton({ sessionId, formAction }: Props) {
export function PhotoReplaceButton({
sessionId,
formAction,
onClientError,
}: Props) {
const formRef = useRef<HTMLFormElement>(null);

return (
<form ref={formRef} action={formAction} className="inline-flex">
<input type="hidden" name="sessionId" value={sessionId} />
Expand All @@ -27,16 +42,25 @@ export function PhotoReplaceButton({ sessionId, formAction }: Props) {
<input
type="file"
name="photo"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
accept={ALLOWED_ACCEPT_ATTR}
className="hidden"
onChange={(e) => {
if (e.currentTarget.files?.length) {
formRef.current?.requestSubmit();
const f = e.currentTarget.files?.[0] ?? null;
if (!f) {
onClientError(null);
return;
}
const err = checkUploadFile(f);
if (err) {
onClientError(err);
e.currentTarget.value = "";
return;
}
onClientError(null);
formRef.current?.requestSubmit();
}}
/>
</label>
</form>
);
}

56 changes: 51 additions & 5 deletions app/sessions/[id]/_components/PhotoSection.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"use client";

import Image from "next/image";
import { useActionState } from "react";
import { useActionState, useState } from "react";

import { ErrorAlert } from "@/components/form/ErrorAlert";
import { SubmitButton } from "@/components/form/SubmitButton";
import {
ALLOWED_ACCEPT_ATTR,
MAX_UPLOAD_MB,
checkUploadFile,
} from "@/lib/upload-limits";
import { encodeUploadPath } from "@/lib/upload-url";

import { uploadSessionPhoto, removeSessionPhoto } from "../photo-actions";
Expand All @@ -22,6 +27,10 @@ type Props = {
* - 사진 있음: next/image 16:9. canEdit 면 [교체]/[삭제] + 에러 alert.
* - 사진 없음 + canEdit: 업로드 폼 + 에러 alert.
* - 사진 없음 + 비편집자: '사진 없음' placeholder.
*
* 큰 파일은 client preflight 에서 즉시 잡아준다. server action 의 body limit
* 까지 도달했다가 generic 413 으로 죽는 케이스 방지. server-side 도 동일한
* `checkUploadFile` 을 한 번 더 호출 (defense in depth).
*/
export function PhotoSection({
sessionId,
Expand All @@ -31,11 +40,42 @@ export function PhotoSection({
}: Props) {
const [uploadState, uploadAction] = useActionState(uploadSessionPhoto, null);
const [removeState, removeAction] = useActionState(removeSessionPhoto, null);
const [clientError, setClientError] = useState<string | null>(null);

const uploadError =
uploadState && !uploadState.ok ? uploadState.error : null;
const removeError =
removeState && !removeState.ok ? removeState.error : null;
const error = uploadError ?? removeError;
const error = clientError ?? uploadError ?? removeError;

function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.currentTarget.files?.[0] ?? null;
if (!f) {
setClientError(null);
return;
}
const err = checkUploadFile(f);
if (err) {
setClientError(err);
e.currentTarget.value = "";
} else {
setClientError(null);
}
}

function handleUploadSubmit(e: React.FormEvent<HTMLFormElement>) {
const input = e.currentTarget.elements.namedItem(
"photo",
) as HTMLInputElement | null;
const f = input?.files?.[0] ?? null;
const err = checkUploadFile(f);
if (err) {
e.preventDefault();
setClientError(err);
} else {
setClientError(null);
}
}

if (groupPhotoPath) {
const src = `/api/uploads/${encodeUploadPath(groupPhotoPath)}`;
Expand All @@ -55,7 +95,11 @@ export function PhotoSection({
<div className="mt-2 flex flex-col gap-2">
<ErrorAlert message={error} />
<div className="flex gap-3 text-xs">
<PhotoReplaceButton sessionId={sessionId} formAction={uploadAction} />
<PhotoReplaceButton
sessionId={sessionId}
formAction={uploadAction}
onClientError={setClientError}
/>
<form action={removeAction}>
<input type="hidden" name="sessionId" value={sessionId} />
<SubmitButton
Expand All @@ -80,14 +124,16 @@ export function PhotoSection({
<ErrorAlert message={error} />
<form
action={uploadAction}
onSubmit={handleUploadSubmit}
className="flex flex-col gap-2 sm:flex-row sm:items-center"
>
<input type="hidden" name="sessionId" value={sessionId} />
<input
type="file"
name="photo"
accept="image/jpeg,image/png,image/webp,image/heic,image/heif"
accept={ALLOWED_ACCEPT_ATTR}
required
onChange={handleFileChange}
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"
/>
<SubmitButton
Expand All @@ -97,7 +143,7 @@ export function PhotoSection({
/>
</form>
<p className="text-xs text-muted-foreground">
jpg / png / webp / heic, 최대 15MB.
jpg / png / webp / heic, 최대 {MAX_UPLOAD_MB}MB.
</p>
</section>
);
Expand Down
29 changes: 7 additions & 22 deletions app/sessions/[id]/photo-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,8 @@ import { z } from "zod";

import { db } from "@/lib/db";
import { requireHostOrAdmin } from "@/lib/guard";
import {
ALLOWED_MIME_TYPES,
MAX_UPLOAD_BYTES,
deleteUploadedFile,
saveUploadedFile,
} from "@/lib/uploads";
import { checkUploadFile } from "@/lib/upload-limits";
import { deleteUploadedFile, saveUploadedFile } from "@/lib/uploads";

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

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

const file = formData.get("photo");
if (!(file instanceof File)) {
return { ok: false, error: "사진 파일을 선택하세요." };
}
if (file.size <= 0) {
return { ok: false, error: "빈 파일입니다." };
}
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / 1024 / 1024).toFixed(1);
return { ok: false, error: `파일이 너무 큽니다 (${mb} MB > 15 MB).` };
}
if (!ALLOWED_MIME_TYPES.has((file.type ?? "").toLowerCase())) {
return {
ok: false,
error: "지원하지 않는 파일 형식입니다. (jpg / png / webp / heic)",
};
const preflight = checkUploadFile(file instanceof File ? file : null);
if (preflight) {
return { ok: false, error: preflight };
}
const photo = file as File;

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

let relPath: string;
try {
const saved = await saveUploadedFile(file, "sessions");
const saved = await saveUploadedFile(photo, "sessions");
relPath = saved.relPath;
} catch (err) {
// saveUploadedFile 안의 추가 검증(이미 위에서 잡지만 안전망)
Expand Down
1 change: 1 addition & 0 deletions docs/wiki/06-Checkpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
- 빠른 더블/연속 클릭으로 row 중복 생성
- 권한 없는 사용자로 동일 페이지 진입
- 인증 만료 / 쿠키 삭제 후 새로고침
- 파일 업로드면 **framework limit 경계** 도 같이: Next Server Actions 기본 1MB 를 넘는 파일이 들어왔을 때 우리 코드의 에러 메시지가 보이는가 (PR #44 사고 — 큰 사진이 generic 413 으로 죽는 동안 inline 에러는 안 떴음)
- Agent 가 "코드 측 검증 통과" 라고 보고하는 것 ≠ 사용자가 동작 확인. **빌드 그린은 시작점**.

---
Expand Down
41 changes: 41 additions & 0 deletions lib/upload-limits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 업로드 제한 / 검증 — pure (no node deps). client/server 양쪽에서 import 가능.
//
// 같은 사실(15MB, 허용 MIME)을:
// - server: photo-actions / lib/uploads 에서 최종 검증
// - client: PhotoSection / PhotoReplaceButton 에서 사전 검증
// 양쪽이 같은 값을 보게 하려고 여기 한 곳에 둔다.

export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB
export const MAX_UPLOAD_MB = MAX_UPLOAD_BYTES / 1024 / 1024;

export const ALLOWED_MIME_TYPES = new Set<string>([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);

export const ALLOWED_ACCEPT_ATTR =
"image/jpeg,image/png,image/webp,image/heic,image/heif";

/**
* 업로드 전 사전 검증. 통과면 null, 실패면 사용자에게 보여줄 한국어 에러.
*
* 주의:
* - 이 함수는 client/server 둘 다에서 호출된다.
* - server action 본문 안에서 한 번 더 호출해 "client preflight 우회" 를 막는다.
*/
export function checkUploadFile(file: File | null | undefined): string | null {
if (!file) return "사진 파일을 선택하세요.";
if (file.size <= 0) return "빈 파일입니다.";
if (file.size > MAX_UPLOAD_BYTES) {
const mb = (file.size / 1024 / 1024).toFixed(1);
return `파일이 너무 큽니다 (${mb} MB > ${MAX_UPLOAD_MB} MB).`;
}
const mime = (file.type ?? "").toLowerCase();
if (mime && !ALLOWED_MIME_TYPES.has(mime)) {
return "지원하지 않는 파일 형식입니다. (jpg / png / webp / heic)";
}
return null;
}
18 changes: 9 additions & 9 deletions lib/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ import { mkdir, unlink, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import path from "node:path";

export const ALLOWED_MIME_TYPES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/heif",
]);
import {
ALLOWED_MIME_TYPES,
MAX_UPLOAD_BYTES,
MAX_UPLOAD_MB,
} from "@/lib/upload-limits";

// 호환을 위해 server 쪽 import 경로도 유지 (기존 코드: from "@/lib/uploads").
export { ALLOWED_MIME_TYPES, MAX_UPLOAD_BYTES } from "@/lib/upload-limits";

export const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; // 15 MB

const MIME_TO_EXT: Record<string, string> = {
"image/jpeg": "jpg",
Expand Down Expand Up @@ -95,7 +95,7 @@ export async function saveUploadedFile(
}
if (file.size > MAX_UPLOAD_BYTES) {
throw new Error(
`파일이 너무 큽니다 (${(file.size / 1024 / 1024).toFixed(1)} MB > 15 MB).`,
`파일이 너무 큽니다 (${(file.size / 1024 / 1024).toFixed(1)} MB > ${MAX_UPLOAD_MB} MB).`,
);
}
const mime = (file.type || "").toLowerCase();
Expand Down
9 changes: 9 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ const nextConfig: NextConfig = {
},
// PrismaClient 가 Next 의 bundler 에 의해 traced 되지 않도록 external 처리.
serverExternalPackages: ["@prisma/client", ".prisma/client"],
// 사진 업로드 server action 은 multipart/form-data 로 최대 15MB 원본 + multipart
// overhead 까지 도달할 수 있다. Next 의 기본 1MB 제한이면 큰 JPG 가 우리 코드
// 검증(`lib/upload-limits.ts`)에 도달하지 못하고 generic 413 으로 죽는다.
// 진짜 상한은 lib 의 MAX_UPLOAD_BYTES(15MB) 이고, 이 값은 그보다 한 단계 위.
experimental: {
serverActions: {
bodySizeLimit: "20mb",
},
},
};

export default nextConfig;
Loading