// lib/file-download.ts // 공용 파일 다운로드 유틸리티 (보안 및 로깅 강화) import { toast } from "sonner"; /** * 파일 타입 정보 */ export interface FileInfo { type: 'pdf' | 'document' | 'spreadsheet' | 'image' | 'archive' | 'other'; canPreview: boolean; icon: string; mimeType?: string; } /** * 파일 다운로드 옵션 */ export interface FileDownloadOptions { /** 다운로드 액션 타입 */ action?: 'download' | 'preview'; /** 에러 시 토스트 표시 여부 */ showToast?: boolean; /** 성공 시 토스트 표시 여부 */ showSuccessToast?: boolean; /** 커스텀 에러 핸들러 */ onError?: (error: string) => void; /** 커스텀 성공 핸들러 */ onSuccess?: (fileName: string, fileSize?: number) => void; /** 진행률 콜백 (큰 파일용) */ onProgress?: (progress: number) => void; /** 로깅 비활성화 */ disableLogging?: boolean; } /** * 파일 다운로드 결과 */ export interface FileDownloadResult { success: boolean; error?: string; fileSize?: number; fileInfo?: FileInfo; downloadDuration?: number; } /** * 보안 설정 */ const SECURITY_CONFIG = { // 허용된 파일 확장자 ALLOWED_EXTENSIONS: new Set([ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'dwg', 'dxf', 'zip', 'rar', '7z', 'webp' ]), // 최대 파일 크기 (100MB) MAX_FILE_SIZE: 100 * 1024 * 1024, // 허용된 도메인 (선택적) ALLOWED_DOMAINS: [ window.location.hostname, 'localhost', '127.0.0.1' ], // Rate limiting (클라이언트 사이드) MAX_DOWNLOADS_PER_MINUTE: 10, // 타임아웃 설정 FETCH_TIMEOUT: 30000, // 30초 }; /** * Rate limiting 추적 */ class RateLimiter { private downloadAttempts: number[] = []; canDownload(): boolean { const now = Date.now(); const oneMinuteAgo = now - 60000; // 1분 이전 기록 제거 this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo); if (this.downloadAttempts.length >= SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE) { return false; } this.downloadAttempts.push(now); return true; } getRemainingDownloads(): number { const now = Date.now(); const oneMinuteAgo = now - 60000; this.downloadAttempts = this.downloadAttempts.filter(time => time > oneMinuteAgo); return Math.max(0, SECURITY_CONFIG.MAX_DOWNLOADS_PER_MINUTE - this.downloadAttempts.length); } } const rateLimiter = new RateLimiter(); /** * 클라이언트 사이드 로깅 서비스 */ class ClientLogger { private static async logToServer(event: string, data: any) { try { // 로깅이 비활성화된 경우 서버 전송 안함 if (data.disableLogging) { console.log(`[CLIENT LOG] ${event}:`, data); return; } await fetch('/api/client-logs', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ event, data: { ...data, timestamp: new Date().toISOString(), userAgent: navigator.userAgent, url: window.location.href, sessionId: this.getSessionId(), }, }), }).catch(error => { // 로깅 실패해도 메인 기능에 영향 없도록 console.warn('로깅 실패:', error); }); } catch (error) { console.warn('로깅 실패:', error); } } private static getSessionId(): string { let sessionId = sessionStorage.getItem('client-session-id'); if (!sessionId) { sessionId = crypto.randomUUID(); sessionStorage.setItem('client-session-id', sessionId); } return sessionId; } static logDownloadAttempt(filePath: string, fileName: string, action: string, options?: any) { this.logToServer('download_attempt', { filePath, fileName, action, fileExtension: fileName.split('.').pop()?.toLowerCase(), ...options, }); } static logDownloadSuccess(filePath: string, fileName: string, fileSize?: number, duration?: number) { this.logToServer('download_success', { filePath, fileName, fileSize, duration, fileExtension: fileName.split('.').pop()?.toLowerCase(), }); } static logDownloadError(filePath: string, fileName: string, error: string, duration?: number) { this.logToServer('download_error', { filePath, fileName, error, duration, fileExtension: fileName.split('.').pop()?.toLowerCase(), }); } static logSecurityViolation(type: string, details: any) { this.logToServer('security_violation', { type, ...details, }); } } /** * 보안 검증 함수들 */ const SecurityValidator = { validateFileExtension(fileName: string): boolean { const extension = fileName.split('.').pop()?.toLowerCase(); return extension ? SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension) : false; }, validateFileName(fileName: string): boolean { // 위험한 문자 체크 const dangerousPatterns = [ /[<>:"'|?*]/, // 특수문자 /[\x00-\x1f]/, // 제어문자 /^\./, // 숨김 파일 /\.(exe|bat|cmd|scr|vbs|js|jar)$/i, // 실행 파일 ]; return !dangerousPatterns.some(pattern => pattern.test(fileName)); }, validateFilePath(filePath: string): boolean { // 경로 탐색 공격 방지 const dangerousPatterns = [ /\.\./, // 상위 디렉토리 접근 /\/\//, // 이중 슬래시 /\\+/, // 백슬래시 /[<>:"'|?*]/, // 특수문자 ]; return !dangerousPatterns.some(pattern => pattern.test(filePath)); }, validateUrl(url: string): boolean { try { const urlObj = new URL(url); // HTTPS 강제 (개발 환경 제외) if (window.location.protocol === 'https:' && urlObj.protocol !== 'https:') { if (!['localhost', '127.0.0.1'].includes(urlObj.hostname)) { return false; } } // 허용된 도메인 검사 (상대 URL은 허용) if (urlObj.hostname && !SECURITY_CONFIG.ALLOWED_DOMAINS.includes(urlObj.hostname)) { return false; } return true; } catch { return false; } }, validateFileSize(size: number): boolean { return size <= SECURITY_CONFIG.MAX_FILE_SIZE; }, }; /** * 파일 정보 가져오기 */ export const getFileInfo = (fileName: string): FileInfo => { const ext = fileName.toLowerCase().split('.').pop(); const fileTypes: Record = { pdf: { type: 'pdf', canPreview: true, icon: '📄', mimeType: 'application/pdf' }, doc: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/msword' }, docx: { type: 'document', canPreview: false, icon: '📝', mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, xls: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.ms-excel' }, xlsx: { type: 'spreadsheet', canPreview: false, icon: '📊', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, ppt: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.ms-powerpoint' }, pptx: { type: 'document', canPreview: false, icon: '📑', mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }, jpg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, jpeg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/jpeg' }, png: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/png' }, gif: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/gif' }, webp: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/webp' }, svg: { type: 'image', canPreview: true, icon: '🖼️', mimeType: 'image/svg+xml' }, zip: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/zip' }, rar: { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-rar-compressed' }, '7z': { type: 'archive', canPreview: false, icon: '📦', mimeType: 'application/x-7z-compressed' }, txt: { type: 'document', canPreview: true, icon: '📝', mimeType: 'text/plain' }, csv: { type: 'spreadsheet', canPreview: true, icon: '📊', mimeType: 'text/csv' }, }; return fileTypes[ext || ''] || { type: 'other', canPreview: false, icon: '📎', mimeType: 'application/octet-stream' }; }; /** * 파일 크기를 읽기 쉬운 형태로 변환 */ export const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; /** * 타임아웃이 적용된 fetch */ const fetchWithTimeout = async (url: string, options: RequestInit = {}): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), SECURITY_CONFIG.FETCH_TIMEOUT); try { const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(timeoutId); return response; } catch (error) { clearTimeout(timeoutId); throw error; } }; /** * 파일 메타데이터 확인 */ export const checkFileMetadata = async (url: string): Promise<{ exists: boolean; size?: number; contentType?: string; lastModified?: Date; error?: string; }> => { try { // URL 보안 검증 if (!SecurityValidator.validateUrl(url)) { return { exists: false, error: "허용되지 않은 URL입니다" }; } const response = await fetchWithTimeout(url, { method: 'HEAD', headers: { 'Cache-Control': 'no-cache' } }); if (!response.ok) { let error = "파일 접근 실패"; switch (response.status) { case 404: error = "파일을 찾을 수 없습니다"; break; case 403: error = "파일 접근 권한이 없습니다"; break; case 429: error = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요"; break; case 500: error = "서버 오류가 발생했습니다"; break; default: error = `파일 접근 실패 (${response.status})`; } return { exists: false, error }; } const contentLength = response.headers.get('Content-Length'); const contentType = response.headers.get('Content-Type'); const lastModified = response.headers.get('Last-Modified'); const size = contentLength ? parseInt(contentLength, 10) : undefined; // 파일 크기 검증 if (size && !SecurityValidator.validateFileSize(size)) { return { exists: false, error: `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})` }; } return { exists: true, size, contentType: contentType || undefined, lastModified: lastModified ? new Date(lastModified) : undefined, }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다"; return { exists: false, error: errorMessage }; } }; /** * 메인 파일 다운로드 함수 */ export const downloadFile = async ( filePath: string, fileName: string, options: FileDownloadOptions = {} ): Promise => { const startTime = Date.now(); const { action = 'download', showToast = true, onError, onSuccess, disableLogging = false } = options; // 로깅 if (!disableLogging) { ClientLogger.logDownloadAttempt(filePath, fileName, action, { userInitiated: true, disableLogging }); } try { // Rate limiting 체크 if (!rateLimiter.canDownload()) { const error = `다운로드 제한 초과. ${rateLimiter.getRemainingDownloads()}회 남음`; if (showToast) toast.error(error); if (onError) onError(error); if (!disableLogging) { ClientLogger.logSecurityViolation('rate_limit_exceeded', { filePath, fileName }); } return { success: false, error }; } // 보안 검증 if (!SecurityValidator.validateFileName(fileName)) { const error = "안전하지 않은 파일명입니다"; if (showToast) toast.error(error); if (onError) onError(error); if (!disableLogging) { ClientLogger.logSecurityViolation('invalid_filename', { filePath, fileName }); } return { success: false, error }; } if (!SecurityValidator.validateFilePath(filePath)) { const error = "안전하지 않은 파일 경로입니다"; if (showToast) toast.error(error); if (onError) onError(error); if (!disableLogging) { ClientLogger.logSecurityViolation('invalid_filepath', { filePath, fileName }); } return { success: false, error }; } if (!SecurityValidator.validateFileExtension(fileName)) { const error = "허용되지 않은 파일 형식입니다"; if (showToast) toast.error(error); if (onError) onError(error); if (!disableLogging) { ClientLogger.logSecurityViolation('invalid_extension', { filePath, fileName }); } return { success: false, error }; } // URL 구성 const baseUrl = filePath.startsWith('http') ? filePath : `${window.location.origin}${filePath}`; const url = new URL(baseUrl); if (action === 'download') { url.searchParams.set('download', 'true'); } const fullUrl = url.toString(); // URL 보안 검증 if (!SecurityValidator.validateUrl(fullUrl)) { const error = "허용되지 않은 URL입니다"; if (showToast) toast.error(error); if (onError) onError(error); if (!disableLogging) { ClientLogger.logSecurityViolation('invalid_url', { fullUrl, fileName }); } return { success: false, error }; } // 파일 정보 확인 const metadata = await checkFileMetadata(fullUrl); if (!metadata.exists) { const error = metadata.error || "파일을 찾을 수 없습니다"; if (showToast) toast.error(error); if (onError) onError(error); const duration = Date.now() - startTime; if (!disableLogging) { ClientLogger.logDownloadError(filePath, fileName, error, duration); } return { success: false, error, downloadDuration: duration }; } const fileInfo = getFileInfo(fileName); // 미리보기 처리 if (action === 'preview' && fileInfo.canPreview) { const previewUrl = filePath.startsWith('http') ? filePath : `${window.location.origin}${filePath}`; // 안전한 새 창 열기 const newWindow = window.open('', '_blank', 'noopener,noreferrer'); if (newWindow) { newWindow.location.href = previewUrl; if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`); if (onSuccess) onSuccess(fileName, metadata.size); const duration = Date.now() - startTime; if (!disableLogging) { ClientLogger.logDownloadSuccess(filePath, fileName, metadata.size, duration); } return { success: true, fileSize: metadata.size, fileInfo, downloadDuration: duration }; } else { throw new Error("팝업이 차단되었습니다. 팝업 차단을 해제해주세요."); } } // 안전한 다운로드 console.log(`📥 보안 검증된 다운로드: ${fullUrl}`); const response = await fetchWithTimeout(fullUrl); if (!response.ok) { throw new Error(`다운로드 실패: ${response.status}`); } // Content-Type 검증 const contentType = response.headers.get('Content-Type'); if (contentType && fileInfo.mimeType && !contentType.includes(fileInfo.mimeType.split(';')[0])) { console.warn('⚠️ MIME 타입 불일치:', { expected: fileInfo.mimeType, actual: contentType }); } // Blob으로 변환 const blob = await response.blob(); // 최종 파일 크기 검증 if (!SecurityValidator.validateFileSize(blob.size)) { const error = `파일이 너무 큽니다 (최대 ${formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE)})`; if (showToast) toast.error(error); if (onError) onError(error); const duration = Date.now() - startTime; if (!disableLogging) { ClientLogger.logDownloadError(filePath, fileName, error, duration); } return { success: false, error, downloadDuration: duration }; } // 브라우저 호환성을 고려한 안전한 다운로드 const downloadUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = fileName; link.style.display = 'none'; link.setAttribute('data-download-source', 'secure-download-utility'); document.body.appendChild(link); link.click(); document.body.removeChild(link); // 메모리 정리 setTimeout(() => URL.revokeObjectURL(downloadUrl), 100); // 성공 처리 const duration = Date.now() - startTime; if (showToast) { const sizeText = blob.size ? ` (${formatFileSize(blob.size)})` : ''; toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`); } if (onSuccess) onSuccess(fileName, blob.size); if (!disableLogging) { ClientLogger.logDownloadSuccess(filePath, fileName, blob.size, duration); } return { success: true, fileSize: blob.size, fileInfo, downloadDuration: duration }; } catch (error) { const duration = Date.now() - startTime; const errorMessage = error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다"; console.error("❌ 다운로드 오류:", error); if (showToast) toast.error(errorMessage); if (onError) onError(errorMessage); if (!disableLogging) { ClientLogger.logDownloadError(filePath, fileName, errorMessage, duration); } return { success: false, error: errorMessage, downloadDuration: duration }; } }; /** * 간편 다운로드 함수 */ export const quickDownload = (filePath: string, fileName: string) => { return downloadFile(filePath, fileName, { action: 'download' }); }; /** * 간편 미리보기 함수 */ export const quickPreview = (filePath: string, fileName: string) => { const fileInfo = getFileInfo(fileName); if (!fileInfo.canPreview) { toast.warning("이 파일 형식은 미리보기를 지원하지 않습니다. 다운로드를 진행합니다."); return downloadFile(filePath, fileName, { action: 'download' }); } return downloadFile(filePath, fileName, { action: 'preview' }); }; /** * 파일 다운로드 또는 미리보기 (자동 판단) */ export const smartFileAction = (filePath: string, fileName: string) => { const fileInfo = getFileInfo(fileName); const action = fileInfo.canPreview ? 'preview' : 'download'; return downloadFile(filePath, fileName, { action }); }; /** * 보안 정보 조회 */ export const getSecurityInfo = () => { return { allowedExtensions: Array.from(SECURITY_CONFIG.ALLOWED_EXTENSIONS), maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE, maxFileSizeFormatted: formatFileSize(SECURITY_CONFIG.MAX_FILE_SIZE), remainingDownloads: rateLimiter.getRemainingDownloads(), allowedDomains: SECURITY_CONFIG.ALLOWED_DOMAINS, }; };