summaryrefslogtreecommitdiff
path: root/lib/file-download.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-18 07:52:02 +0000
commit48a2255bfc45ffcfb0b39ffefdd57cbacf8b36df (patch)
tree0c88b7c126138233875e8d372a4e999e49c38a62 /lib/file-download.ts
parent2ef02e27dbe639876fa3b90c30307dda183545ec (diff)
(대표님) 파일관리변경, 클라IP추적, 실시간알림, 미들웨어변경, 알림API
Diffstat (limited to 'lib/file-download.ts')
-rw-r--r--lib/file-download.ts471
1 files changed, 420 insertions, 51 deletions
diff --git a/lib/file-download.ts b/lib/file-download.ts
index 1e8536b5..5e0350a0 100644
--- a/lib/file-download.ts
+++ b/lib/file-download.ts
@@ -1,5 +1,5 @@
// lib/file-download.ts
-// 공용 파일 다운로드 유틸리티
+// 공용 파일 다운로드 유틸리티 (보안 및 로깅 강화)
import { toast } from "sonner";
@@ -14,6 +14,240 @@ export interface FileInfo {
}
/**
+ * 파일 다운로드 옵션
+ */
+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 => {
@@ -57,32 +291,24 @@ export const formatFileSize = (bytes: number): string => {
};
/**
- * 파일 다운로드 옵션
+ * 타임아웃이 적용된 fetch
*/
-export interface FileDownloadOptions {
- /** 다운로드 액션 타입 */
- action?: 'download' | 'preview';
- /** 에러 시 토스트 표시 여부 */
- showToast?: boolean;
- /** 성공 시 토스트 표시 여부 */
- showSuccessToast?: boolean;
- /** 커스텀 에러 핸들러 */
- onError?: (error: string) => void;
- /** 커스텀 성공 핸들러 */
- onSuccess?: (fileName: string, fileSize?: number) => void;
- /** 진행률 콜백 (큰 파일용) */
- onProgress?: (progress: number) => void;
-}
-
-/**
- * 파일 다운로드 결과
- */
-export interface FileDownloadResult {
- success: boolean;
- error?: string;
- fileSize?: number;
- fileInfo?: FileInfo;
-}
+const fetchWithTimeout = async (url: string, options: RequestInit = {}): Promise<Response> => {
+ 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;
+ }
+};
/**
* 파일 메타데이터 확인
@@ -95,7 +321,12 @@ export const checkFileMetadata = async (url: string): Promise<{
error?: string;
}> => {
try {
- const response = await fetch(url, {
+ // URL 보안 검증
+ if (!SecurityValidator.validateUrl(url)) {
+ return { exists: false, error: "허용되지 않은 URL입니다" };
+ }
+
+ const response = await fetchWithTimeout(url, {
method: 'HEAD',
headers: { 'Cache-Control': 'no-cache' }
});
@@ -110,6 +341,9 @@ export const checkFileMetadata = async (url: string): Promise<{
case 403:
error = "파일 접근 권한이 없습니다";
break;
+ case 429:
+ error = "요청이 너무 많습니다. 잠시 후 다시 시도해주세요";
+ break;
case 500:
error = "서버 오류가 발생했습니다";
break;
@@ -123,18 +357,26 @@ export const checkFileMetadata = async (url: string): Promise<{
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: contentLength ? parseInt(contentLength, 10) : undefined,
+ size,
contentType: contentType || undefined,
lastModified: lastModified ? new Date(lastModified) : undefined,
};
} catch (error) {
- return {
- exists: false,
- error: error instanceof Error ? error.message : "네트워크 오류가 발생했습니다"
- };
+ const errorMessage = error instanceof Error ? error.message : "네트워크 오류가 발생했습니다";
+ return { exists: false, error: errorMessage };
}
};
@@ -146,85 +388,199 @@ export const downloadFile = async (
fileName: string,
options: FileDownloadOptions = {}
): Promise<FileDownloadResult> => {
- const { action = 'download', showToast = true, onError, onSuccess } = options;
+ 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 {
- // ✅ URL에 다운로드 강제 파라미터 추가
+ // 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'); // 🔑 핵심!
+ 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);
- return { success: false, error };
+ const duration = Date.now() - startTime;
+ if (!disableLogging) {
+ ClientLogger.logDownloadError(filePath, fileName, error, duration);
+ }
+ return { success: false, error, downloadDuration: duration };
}
const fileInfo = getFileInfo(fileName);
- // 미리보기 처리 (download=true 없이)
+ // 미리보기 처리
if (action === 'preview' && fileInfo.canPreview) {
const previewUrl = filePath.startsWith('http')
? filePath
: `${window.location.origin}${filePath}`;
- window.open(previewUrl, '_blank', 'noopener,noreferrer');
- if (showToast) toast.success(`${fileInfo.icon} 파일을 새 탭에서 열었습니다`);
- if (onSuccess) onSuccess(fileName, metadata.size);
- return { success: true, fileSize: metadata.size, fileInfo };
+ // 안전한 새 창 열기
+ 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("팝업이 차단되었습니다. 팝업 차단을 해제해주세요.");
+ }
}
- // ✅ 안전한 다운로드 방식 (fetch + Blob)
- console.log(`📥 안전한 다운로드: ${fullUrl}`);
+ // 안전한 다운로드
+ console.log(`📥 보안 검증된 다운로드: ${fullUrl}`);
- const response = await fetch(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.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 = metadata.size ? ` (${formatFileSize(metadata.size)})` : '';
+ const sizeText = blob.size ? ` (${formatFileSize(blob.size)})` : '';
toast.success(`${fileInfo.icon} 파일 다운로드 완료: ${fileName}${sizeText}`);
}
- if (onSuccess) onSuccess(fileName, metadata.size);
+ if (onSuccess) onSuccess(fileName, blob.size);
+ if (!disableLogging) {
+ ClientLogger.logDownloadSuccess(filePath, fileName, blob.size, duration);
+ }
- return { success: true, fileSize: metadata.size, fileInfo };
+ 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);
- return { success: false, error: errorMessage };
+ if (!disableLogging) {
+ ClientLogger.logDownloadError(filePath, fileName, errorMessage, duration);
+ }
+
+ return {
+ success: false,
+ error: errorMessage,
+ downloadDuration: duration
+ };
}
};
@@ -257,4 +613,17 @@ export const smartFileAction = (filePath: string, fileName: string) => {
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,
+ };
}; \ No newline at end of file