/** * 파일 저장소 모듈 — 업로드 시 `POST /api/v1/common/file/upload/{moduleId}`의 경로 조각이자 * 백엔드 파일 저장 설정(`FileStrgStngVo.strgStngId`)의 키다. * * 값은 백엔드 저장소 설정 표에서 받은 실제 목록이다(사용자 제공, 2026-08-20). * * 이슈: 설정에 없는 값을 보내도 업로드가 실패하지 않는다. 백엔드가 설정을 못 찾으면 기본 * 저장소로 폴백해 조용히 다른 곳에 저장한다(`EgovFileMngUtil.parseFileInf`). 그래서 여기 없는 * 문자열을 손으로 적지 않는다. */ export type FileModule = { id: string; /** 저장소 설명 — 어떤 파일이 어디로 가는지 이 파일만 보고 알 수 있게 함께 둔다. */ description: string; /** 백엔드가 허용하는 확장자. 화면의 `accept`와 사전 검증이 같은 값을 본다. */ extensions: readonly string[]; }; /** * 백엔드 `Globals.fileUpload.Extensions`의 사본 — 확장자를 못 받은 저장소의 잠정값으로만 쓴다. * * 실제 업로드를 막는 것은 이 목록이 아니라 저장소 설정의 `PRM_FILE_EXTN`이다 * (`EgovFileMngUtil.checkAllowFileExtension`). `EgovMultipartResolver`도 이 목록을 보긴 하지만 * 어긋났을 때 던지는 `SecurityException`이 주석 처리돼 있어 실제로는 로그만 남는다. * 그래서 저장소가 허용하는 확장자를 이 목록으로 다시 거르면 안 된다 — svg가 그렇게 막혔다. */ export const GLOBAL_UPLOAD_EXTENSIONS: readonly string[] = [ 'gif', 'jpg', 'jpeg', 'png', 'xls', 'xlsx', 'ppt', 'pptx', 'doc', 'docx', 'hwp', 'pdf', 'mp4', 'mp3', 'txt', 'html', 'htm', ]; const IMAGE = ['jpg', 'png', 'gif'] as const; export const FILE_MODULE = { /** 콘텐츠(금융쇼츠·4컷금융만화) 대표 이미지. */ contentThumbnail: { id: 'MODULE_CONTENT_THUMB', description: '콘텐츠 섬네일 이미지 저장소', extensions: IMAGE, }, /** 4컷금융만화의 컷 이미지. */ toonContent: { id: 'MODULE_TOON_CONTENT', description: '4컷 만화 파일 저장소', extensions: IMAGE, }, /** 금융쇼츠의 영상. */ vodContent: { id: 'MODULE_VOD_CONTENT', description: '동영상 콘텐츠 저장소', // TODO: 저장소 표는 mov·avi·mkv·webm·wmv도 허용하지만 실제 통과 여부를 확인하지 못해 // mp4로 좁혀 둔 상태다. 확인되면 표대로 되돌린다. extensions: ['mp4'], }, /** 꾸미기 아이템 썸네일. */ userItem: { id: 'MODULE_USER_ITEM', description: '사용자 아이템 저장소', extensions: ['jpg', 'png', 'gif', 'svg'], }, /** * 가맹점 상세·메뉴 이미지. * * TODO(백엔드): 저장소 설정 표에 바우처용 모듈이 없다. 없는 ID를 보내면 기본 저장소로 * 폴백해 동작은 하므로 잠정 이름을 두고 시작한다(사용자 확정) — 백엔드가 이 이름으로 * 등록하면 손댈 것이 없고, 다른 이름이면 여기 한 줄만 바꾼다. */ merchantImage: { id: 'MODULE_VOUCHER', description: '가맹점 이미지 저장소(잠정)', extensions: ['jpg', 'jpeg', 'png'], }, menuIcon: { id: 'MODULE_MENU_ICON', description: '메뉴 아이콘 저장소', extensions: ['jpg', 'png', 'gif', 'svg'], }, pocket: { id: 'MODULE_POCKET', description: '용돈기입장 사진 저장소', extensions: ['jpg', 'jpeg', 'png', 'gif'], }, studentImage: { id: 'MODULE_STUDENT_IMAGE', description: '학생 원본 이미지', extensions: IMAGE, }, userProfile: { id: 'MODULE_USER_PROFILE', description: '회원 프로필 저장소', extensions: IMAGE, }, /** 게시판(공지사항·FAQ·1:1문의) 첨부. */ board: { id: 'MODULE_BBS', description: '게시글 스토리지', // TODO: 이 저장소만의 허용 확장자를 받지 못했다. 전역 화이트리스트를 그대로 두어 화면이 // 먼저 막지 않게 한다 — 저장소 목록이 더 좁으면 백엔드가 사유를 돌려준다. extensions: GLOBAL_UPLOAD_EXTENSIONS, }, } as const satisfies Record; /** 실제로 통과하는 확장자 — 판단 기준은 저장소 설정 하나뿐이다. */ export function allowedExtensions(module: FileModule): string[] { return [...module.extensions]; } /** 확장자 목록 → `` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */ export function fileModuleAccept(module: FileModule): string { return allowedExtensions(module) .map((extension) => `.${extension}`) .join(','); } /** 파일명의 확장자가 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */ export function isAllowedFileExtension( module: FileModule, fileName: string ): boolean { const extension = fileName.split('.').pop()?.toLowerCase() ?? ''; return allowedExtensions(module).includes(extension); } export function findFileModule(id: string): FileModule | undefined { return Object.values(FILE_MODULE).find((module) => module.id === id); } /** * Server Action을 지나는 첨부파일의 상한. * * 이슈: 미들웨어(`proxy.ts`)를 지나는 요청은 본문이 10MiB에서 잘린다(측정치는 그 파일 주석 * 참조). Server Action은 페이지 URL로 POST되어 반드시 미들웨어를 지나므로 이 한도를 피할 수 * 없다. 더 큰 파일은 미들웨어에서 제외한 스트리밍 라우트(`/contents/upload`)로 보낸다. */ export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; /** * 스트리밍 업로드(`/contents/upload`)로 올릴 수 있는 상한 — 백엔드 * `multipartResolver.maxUploadSize`(100,000,000)와 같은 값이다. * * 화면이 파일을 고르는 순간 이 값으로 먼저 막는다. 넘겨 보내면 앞단이 사유 없는 413으로 * 끊어 사용자가 무엇이 잘못됐는지 알 수 없다. * * 이슈: dev API 앞단 nginx의 `client_max_body_size`가 기본값 1MiB라 실제로는 1MiB만 통과했다 * (2026-08-21 실측: 1,000KB→401 / 1,050KB→413). 인프라를 백엔드와 같은 100m로 올리기로 해 * (사용자 확정) 이 값을 기준으로 둔다. 인프라 반영 전에는 이 상한을 통과해도 413이 온다. */ export const MAX_STREAM_UPLOAD_BYTES = 100 * 1000 * 1000; /** "100MB"처럼 사람이 읽는 표기. 상한과 안내 문구가 어긋나지 않게 한 곳에서 만든다. */ export function formatMaxUploadSize(bytes: number): string { return `${Math.floor(bytes / (1000 * 1000))}MB`; }