5) { header('HTTP/1.0 403 Forbidden'); die('접근 권한이 없습니다.'); } // 회의 ID 확인 $meeting_id = isset($_GET['id']) ? intval($_GET['id']) : 0; $tenant_id = $user_id; // session.php에서 user_id를 tenant_id로 사용 if ($meeting_id <= 0) { header('HTTP/1.0 400 Bad Request'); die('잘못된 요청입니다.'); } try { $pdo = db_connect(); // MVP 단계: 모든 사용자가 모든 데이터를 볼 수 있도록 tenant_id 조건 제거 $sql = "SELECT audio_file_path, title, created_at FROM meeting_logs WHERE id = ?"; $stmt = $pdo->prepare($sql); $stmt->execute([$meeting_id]); $meeting = $stmt->fetch(PDO::FETCH_ASSOC); if (!$meeting || empty($meeting['audio_file_path'])) { header('HTTP/1.0 404 Not Found'); die('오디오 파일을 찾을 수 없습니다.'); } // 파일 경로 구성 $file_path = $_SERVER['DOCUMENT_ROOT'] . $meeting['audio_file_path']; // 경로 정규화 (슬래시 통일) $file_path = str_replace('\\', '/', $file_path); $file_path = preg_replace('#/+#', '/', $file_path); // 파일 존재 확인 if (!file_exists($file_path)) { // 디버깅 정보 error_log('다운로드 실패 - 파일 경로: ' . $file_path); error_log('다운로드 실패 - DB 경로: ' . $meeting['audio_file_path']); error_log('다운로드 실패 - DOCUMENT_ROOT: ' . $_SERVER['DOCUMENT_ROOT']); error_log('다운로드 실패 - 파일 존재 여부: ' . (file_exists($file_path) ? 'YES' : 'NO')); // 대체 경로 시도 (경로가 잘못된 경우) $alt_path = $_SERVER['DOCUMENT_ROOT'] . str_replace('//', '/', $meeting['audio_file_path']); if (file_exists($alt_path)) { $file_path = $alt_path; } else { header('HTTP/1.0 404 Not Found'); header('Content-Type: text/plain; charset=utf-8'); die('오디오 파일이 서버에 존재하지 않습니다.\n경로: ' . $file_path); } } // 파일 확장자 확인 및 MIME 타입 설정 // 파일 경로에서 직접 확장자 추출 $file_extension = ''; if (preg_match('/\.([a-z0-9]+)$/i', $file_path, $matches)) { $file_extension = strtolower($matches[1]); } // 확장자가 없으면 경로에서 추출 시도 if (empty($file_extension)) { $path_info = pathinfo($file_path); $file_extension = isset($path_info['extension']) ? strtolower($path_info['extension']) : ''; } // 확장자가 여전히 없으면 webm으로 기본 설정 if (empty($file_extension)) { $file_extension = 'webm'; } $mime_types = [ 'webm' => 'audio/webm', 'wav' => 'audio/wav', 'mp3' => 'audio/mpeg', 'ogg' => 'audio/ogg', 'm4a' => 'audio/mp4' ]; $content_type = isset($mime_types[$file_extension]) ? $mime_types[$file_extension] : 'audio/webm'; // 기본값을 audio/webm으로 설정 // 다운로드 파일명 생성 (회의록 + 제목 + 날짜 + 확장자) $title = $meeting['title'] ?: '회의녹음'; $date = date('Ymd_His', strtotime($meeting['created_at'])); // 파일명 안전하게 처리 (특수문자 제거, 공백을 언더스코어로) $safe_title = preg_replace('/[^a-zA-Z0-9가-힣_\-]/u', '_', $title); $safe_title = preg_replace('/\s+/', '_', $safe_title); $safe_title = trim($safe_title, '_'); // 제목이 비어있거나 언더스코어만 있는 경우 기본값 사용 if (empty($safe_title) || preg_match('/^_+$/', $safe_title)) { $safe_title = '회의녹음'; } // 제목에 "회의록"이 이미 포함되어 있으면 제거 (중복 방지) $safe_title = preg_replace('/^회의록[_\s]*/u', '', $safe_title); $safe_title = preg_replace('/[_\s]*회의록$/u', '', $safe_title); $safe_title = trim($safe_title, '_'); // 제목이 비어있으면 기본값 사용 if (empty($safe_title)) { $safe_title = '회의녹음'; } // 파일명 앞에 "회의록" 추가 (한 번만) $download_filename = '회의록_' . $safe_title . '_' . $date . '.' . $file_extension; // 출력 버퍼 비우기 ob_clean(); // 파일명이 확장자를 포함하는지 최종 확인 if (!preg_match('/\.' . preg_quote($file_extension, '/') . '$/i', $download_filename)) { $download_filename .= '.' . $file_extension; } // 헤더 설정 (브라우저 호환성을 위해 파일명 처리) header('Content-Type: ' . $content_type); // 파일명 처리: "회의록"을 "Meeting"으로 변환하여 브라우저 호환성 확보 $ascii_filename = str_replace('회의록', 'Meeting', $download_filename); // "Meeting"이 중복되지 않도록 처리 (제목에 이미 "Meeting"이 포함된 경우) $ascii_filename = preg_replace('/Meeting[_\s]*Meeting/i', 'Meeting', $ascii_filename); // 나머지 한글과 특수문자를 언더스코어로 변환 $ascii_filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', $ascii_filename); // 연속된 언더스코어를 하나로 통합 $ascii_filename = preg_replace('/_+/', '_', $ascii_filename); // 확장자가 확실히 포함되도록 재확인 if (!preg_match('/\.' . preg_quote($file_extension, '/') . '$/i', $ascii_filename)) { $ascii_filename .= '.' . $file_extension; } // 파일 크기 재확인 $file_size = filesize($file_path); if ($file_size === false || $file_size == 0) { header('HTTP/1.0 500 Internal Server Error'); header('Content-Type: text/plain; charset=utf-8'); die('파일을 읽을 수 없습니다. (크기: ' . $file_size . ')'); } // Content-Disposition 헤더를 더 명확하게 설정 (RFC 5987 형식) // 브라우저 호환성을 위해 두 가지 형식 모두 사용 $encoded_filename = rawurlencode($ascii_filename); header('Content-Disposition: attachment; filename="' . $ascii_filename . '"; filename*=UTF-8\'\'' . $encoded_filename); header('Content-Length: ' . $file_size); header('Content-Transfer-Encoding: binary'); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Pragma: public'); header('Expires: 0'); header('X-Content-Type-Options: nosniff'); // 디버깅용 (필요시 주석 해제) // error_log('Download: ' . $download_filename . ' (extension: ' . $file_extension . ', path: ' . $file_path . ', size: ' . $file_size . ')'); // 파일 출력 (청크 단위로 읽어서 메모리 효율성 향상) $handle = @fopen($file_path, 'rb'); if ($handle === false) { header('HTTP/1.0 500 Internal Server Error'); header('Content-Type: text/plain; charset=utf-8'); die('파일을 열 수 없습니다.'); } // 청크 단위로 출력 (8KB씩) while (!feof($handle)) { $chunk = fread($handle, 8192); if ($chunk === false) { break; } echo $chunk; flush(); } fclose($handle); exit; } catch (Exception $e) { header('HTTP/1.0 500 Internal Server Error'); die('파일 다운로드 중 오류가 발생했습니다.'); }