isEnabled(); } // ============================================ // 파일 업로드 헬퍼 함수 // ============================================ /** * R2로 파일 업로드 * * 사용 예: * $result = r2_upload_file($_FILES['bf_file'][0], $bo_table, $member['mb_no']); * * @param array $file $_FILES 배열 요소 * @param string $boTable 게시판 테이블명 * @param int $memberId 회원 ID * @param string $type 업로드 타입 (board|editor|profile) * @return array */ function r2_upload_file(array $file, string $boTable, int $memberId = 0, string $type = 'board'): array { return get_r2_handler()->handleUpload($file, $boTable, $memberId, $type); } /** * R2 파일 삭제 * * @param string $r2Key R2 객체 키 * @return bool */ function r2_delete_file(string $r2Key): bool { return get_r2_handler()->handleDelete($r2Key); } /** * R2 다운로드 URL 생성 * * @param string $r2Key R2 객체 키 * @return string */ function r2_get_download_url(string $r2Key): string { return get_r2_handler()->getDownloadUrl($r2Key); } /** * R2 파일 존재 여부 * * @param string $r2Key R2 객체 키 * @return bool */ function r2_file_exists(string $r2Key): bool { return get_r2_handler()->exists($r2Key); } // ============================================ // 그누보드 함수 확장 // ============================================ /** * get_file() 함수 확장 - R2 URL 적용 * * 기존 get_file() 함수 호출 후 이 함수로 R2 URL 적용 * * 사용 예: * $file = get_file($bo_table, $wr_id); * $file = r2_extend_files($file); * * @param array $files get_file() 결과 * @return array */ function r2_extend_files(array $files): array { if (!is_r2_enabled()) { return $files; } $handler = get_r2_handler(); foreach ($files as $key => $file) { // r2_key가 있는 경우에만 처리 if (!empty($file['r2_key'])) { $files[$key] = $handler->extendFileInfo($file, $file['r2_key']); } } return $files; } // ============================================ // 그누보드 액션 훅 (테마에서 사용) // ============================================ /** * 게시판 파일 업로드 전 훅 * * write_update.php 에서 호출 * * @param array $file * @param string $boTable * @param int $memberId * @return array|null R2 업로드 결과 또는 null (로컬 업로드 진행) */ function r2_before_upload(array $file, string $boTable, int $memberId): ?array { if (!is_r2_enabled()) { return null; } $result = r2_upload_file($file, $boTable, $memberId, 'board'); if ($result['success'] && $result['storage_type'] === 'r2') { return $result; } // 로컬 폴백 return null; } /** * 게시판 파일 삭제 전 훅 * * @param string $r2Key * @param string $localPath * @return bool */ function r2_before_delete(?string $r2Key, string $localPath): bool { // R2 파일 삭제 if (!empty($r2Key) && is_r2_enabled()) { r2_delete_file($r2Key); } // 로컬 파일 삭제는 그누보드 기본 로직에서 처리 return true; } // ============================================ // 마이그레이션 도구 // ============================================ /** * 로컬 파일을 R2로 마이그레이션 * * CLI에서 실행: * php -r "define('_GNUBOARD_', true); include 'extend/r2_hooks.php'; r2_migrate_board_files('free', 100);" * * @param string $boTable 게시판 테이블명 * @param int $limit 처리할 파일 수 * @return array 마이그레이션 결과 */ function r2_migrate_board_files(string $boTable, int $limit = 100): array { global $g5; if (!is_r2_enabled()) { return ['success' => false, 'error' => 'R2 not enabled']; } $handler = get_r2_handler(); $results = ['migrated' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []]; // 테이블명 검증 (영문, 숫자, 언더스코어만 허용) if (!preg_match('/^[a-zA-Z0-9_]+$/', $boTable)) { return ['success' => false, 'error' => 'Invalid table name']; } $limit = (int) $limit; // 아직 마이그레이션되지 않은 파일 조회 $sql = "SELECT bf_no, wr_id, bf_file, bf_source FROM {$g5['board_file_table']} WHERE bo_table = '" . sql_real_escape_string($boTable) . "' AND (bf_storage_type IS NULL OR bf_storage_type = 'local') AND bf_r2_key IS NULL LIMIT {$limit}"; $result = sql_query($sql); while ($row = sql_fetch_array($result)) { $localPath = G5_DATA_PATH . '/file/' . $boTable . '/' . $row['bf_file']; if (!file_exists($localPath)) { $results['skipped']++; continue; } // 회원 ID 조회 (게시글에서) $wrId = (int) $row['wr_id']; $write = sql_fetch("SELECT mb_id FROM {$g5['write_prefix']}{$boTable} WHERE wr_id = '{$wrId}'"); $memberId = 0; if (!empty($write['mb_id'])) { $mbId = sql_real_escape_string($write['mb_id']); $member = sql_fetch("SELECT mb_no FROM {$g5['member_table']} WHERE mb_id = '{$mbId}'"); $memberId = (int) ($member['mb_no'] ?? 0); } // R2 키 생성 $r2Key = $handler->generateR2Key($memberId, $boTable, $row['bf_file'], 'board'); // 마이그레이션 실행 $uploadResult = $handler->migrateLocalFile($localPath, $r2Key, false); if ($uploadResult['success']) { // DB 업데이트 $sql = "UPDATE {$g5['board_file_table']} SET bf_r2_key = '" . sql_real_escape_string($r2Key) . "', bf_storage_type = 'r2' WHERE bf_no = '{$row['bf_no']}'"; sql_query($sql); $results['migrated']++; } else { $results['failed']++; $results['errors'][] = "File {$row['bf_file']}: " . ($uploadResult['error'] ?? 'Unknown error'); } } return $results; } // ============================================ // 그누보드 훅 등록 // ============================================ // write_update.php 파일 업로드 후 R2로 이동 add_replace('write_update_upload_file', 'r2_hook_upload_file', 10, 5); /** * 로컬에 저장된 파일을 R2로 이동 * * @param string $dest_file 로컬 파일 경로 * @param array $board 게시판 정보 * @param int $wr_id 글 ID * @param string $w 작성 모드 * @return string */ function r2_hook_upload_file($dest_file, $board = [], $wr_id = 0, $w = '') { if (!is_r2_enabled()) { return $dest_file; } if (!file_exists($dest_file)) { return $dest_file; } global $member; $handler = get_r2_handler(); $bo_table = isset($board['bo_table']) ? $board['bo_table'] : ''; $member_id = isset($member['mb_no']) ? (int)$member['mb_no'] : 0; $filename = basename($dest_file); // R2 키 생성 $r2Key = $handler->generateR2Key($member_id, $bo_table, $filename, 'board'); // R2로 업로드 $result = $handler->migrateLocalFile($dest_file, $r2Key, true); // true = 로컬 파일 삭제 if ($result['success']) { // R2 키를 전역 변수에 저장 (나중에 DB 저장용) if (!isset($GLOBALS['r2_uploaded_keys'])) { $GLOBALS['r2_uploaded_keys'] = []; } $GLOBALS['r2_uploaded_keys'][$filename] = $r2Key; error_log("[R2] Uploaded: {$filename} -> {$r2Key}"); } else { error_log("[R2] Upload failed: {$filename} - " . ($result['error'] ?? 'Unknown')); } return $dest_file; } // 파일 삭제 훅 add_replace('delete_file_path', 'r2_hook_delete_file', 10, 2); // 이미지 뷰어 훅 - 파일 존재 확인 add_replace('exists_view_image', 'r2_hook_exists_view_image', 10, 3); // 이미지 URL 훅 - R2 URL로 변환 add_replace('get_file_board_url', 'r2_hook_get_file_url', 10, 2); // 이미지 크기 훅 - R2 파일 지원 add_replace('get_view_imagesize', 'r2_hook_get_imagesize', 10, 3); // 다운로드 파일 존재 확인 훅 add_replace('download_file_exist_check', 'r2_hook_download_exist', 10, 2); // 다운로드 파일 헤더 이벤트 (R2 리다이렉트) add_event('download_file_header', 'r2_hook_download_redirect', 10, 2); /** * 다운로드 파일 존재 확인 (R2 지원) * * @param bool $file_exist_check 로컬 파일 존재 여부 * @param array $file 파일 정보 * @return bool */ function r2_hook_download_exist($file_exist_check, $file) { // 로컬에 파일이 있으면 기본 처리 if ($file_exist_check) { return $file_exist_check; } if (!is_r2_enabled()) { return $file_exist_check; } $filename = $file['bf_file'] ?? ''; $bo_table = $file['bo_table'] ?? ''; if (empty($filename) || empty($bo_table)) { return $file_exist_check; } // R2에서 파일 검색 $r2Key = r2_find_file_key($bo_table, $filename); if (!empty($r2Key)) { // R2 키를 전역 변수에 저장 (리다이렉트에서 사용) $GLOBALS['r2_download_key'] = $r2Key; $GLOBALS['r2_download_file'] = $file; return true; } return $file_exist_check; } /** * R2 파일 다운로드 리다이렉트 * * @param array $file 파일 정보 * @param bool $file_exist_check 파일 존재 여부 */ function r2_hook_download_redirect($file, $file_exist_check) { if (!is_r2_enabled()) { return; } // R2 키가 저장되어 있으면 R2 presigned URL로 리다이렉트 if (!empty($GLOBALS['r2_download_key'])) { $handler = get_r2_handler(); $r2Key = $GLOBALS['r2_download_key']; // Presigned URL 생성 $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { // R2 presigned URL로 리다이렉트 header('Location: ' . $presignedUrl); exit; } } } /** * 파일 삭제 시 R2에서도 삭제 */ function r2_hook_delete_file($file_path, $row = []) { if (!is_r2_enabled()) { return $file_path; } // bf_r2_key 필드가 있으면 R2에서 삭제 if (!empty($row['bf_r2_key'])) { r2_delete_file($row['bf_r2_key']); error_log("[R2] Deleted: " . $row['bf_r2_key']); } return $file_path; } /** * 이미지 파일 존재 여부 확인 (R2 지원) */ function r2_hook_exists_view_image($file_exists, $filepath, $editor_file) { // 로컬에 파일이 있으면 그대로 사용 if ($file_exists) { return $file_exists; } if (!is_r2_enabled()) { return $file_exists; } // R2에서 파일 확인 $filename = basename($filepath); $bo_table = ''; // 경로에서 게시판 테이블명 추출 if (preg_match('/\/file\/([^\/]+)\//', $filepath, $matches)) { $bo_table = $matches[1]; } if (empty($bo_table)) { return $file_exists; } // R2 키 패턴으로 검색 (users/*/board/{bo_table}/{filename}) $handler = get_r2_handler(); $adapter = $handler->getAdapter(); if (!$adapter) { return $file_exists; } // R2에서 파일 검색 $searchPattern = "board/{$bo_table}/{$filename}"; $objects = $adapter->listObjects('', 100); foreach ($objects['objects'] as $obj) { if (strpos($obj['key'], $searchPattern) !== false) { // R2에 파일 존재 - 전역 변수에 R2 키 저장 $GLOBALS['r2_current_file_key'] = $obj['key']; return true; } } return $file_exists; } /** * 게시판 파일 URL을 R2 URL로 변환 */ function r2_hook_get_file_url($url, $bo_table) { if (!is_r2_enabled()) { return $url; } // R2 키가 저장되어 있으면 presigned URL 반환 if (!empty($GLOBALS['r2_current_file_key'])) { $handler = get_r2_handler(); $r2Key = $GLOBALS['r2_current_file_key']; // Presigned URL 생성 $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { // R2 키는 유지 (다른 훅에서 사용) return $presignedUrl; } } return $url; } /** * R2 파일의 이미지 크기 가져오기 */ function r2_hook_get_imagesize($size, $filepath, $editor_file) { // 로컬에서 이미지 크기를 가져왔으면 그대로 사용 if (!empty($size)) { return $size; } if (!is_r2_enabled()) { return $size; } // R2 키가 있으면 R2에서 이미지 가져와서 크기 확인 if (!empty($GLOBALS['r2_current_file_key'])) { $handler = get_r2_handler(); $r2Key = $GLOBALS['r2_current_file_key']; // Presigned URL로 이미지 크기 확인 $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { // URL에서 이미지 크기 가져오기 $imageSize = @getimagesize($presignedUrl); if ($imageSize) { return $imageSize; } // 이미지 크기를 가져올 수 없으면 기본값 반환 (1x1 테스트 이미지용) return [1, 1, IMAGETYPE_PNG, 'width="1" height="1"']; } } return $size; } // 게시글 본문 이미지 URL 변환 훅 add_replace('get_view_thumbnail', 'r2_hook_convert_content_images', 10, 1); // 첨부파일 이미지 썸네일 태그 생성 훅 add_replace('get_file_thumbnail_tags', 'r2_hook_file_thumbnail', 10, 2); // 목록 썸네일 정보 훅 (메인페이지, 게시판 목록) add_replace('get_list_thumbnail_info', 'r2_hook_list_thumbnail', 10, 2); // 에디터 이미지 업로드 훅 (cheditor5, smarteditor2) add_replace('get_editor_upload_url', 'r2_hook_editor_upload', 10, 3); /** * 목록 썸네일 정보 (R2 지원) * 메인페이지, 최근게시물, 게시판 목록 등에서 사용 * * @param array $thumbnail_info 기존 썸네일 정보 (빈 배열) * @param array $params 파라미터 (bo_table, wr_id, filename, filepath 등) * @return array */ function r2_hook_list_thumbnail($thumbnail_info, $params) { if (!is_r2_enabled()) { return $thumbnail_info; } if (!empty($thumbnail_info)) { return $thumbnail_info; } $bo_table = $params['bo_table'] ?? ''; $filename = $params['filename'] ?? ''; $filepath = $params['filepath'] ?? ''; if (empty($bo_table) || empty($filename)) { return $thumbnail_info; } // 로컬 파일 존재 확인 $localPath = $filepath . '/' . $filename; if (file_exists($localPath)) { // 로컬 파일이 있으면 기본 처리 return $thumbnail_info; } // R2에서 파일 검색 $r2Key = r2_find_file_key($bo_table, $filename); if (empty($r2Key)) { return $thumbnail_info; } // Presigned URL 생성 $handler = get_r2_handler(); $presignedUrl = $handler->getDownloadUrl($r2Key); if (empty($presignedUrl)) { return $thumbnail_info; } // 썸네일 정보 반환 (R2 presigned URL 사용) return [ 'src' => $presignedUrl, 'ori' => $presignedUrl, 'alt' => '' ]; } /** * 첨부파일 이미지 썸네일 태그 생성 (R2 지원) * * @param string $contents 기존 태그 (빈 문자열) * @param array $file 파일 정보 * @return string */ function r2_hook_file_thumbnail($contents, $file) { if (!is_r2_enabled()) { return $contents; } if (empty($file) || !is_array($file)) { return $contents; } $filename = $file['file'] ?? ''; if (empty($filename)) { return $contents; } global $board; $bo_table = $board['bo_table'] ?? ''; if (empty($bo_table)) { return $contents; } // 로컬 파일 존재 확인 $localPath = G5_DATA_PATH . '/file/' . $bo_table . '/' . $filename; if (file_exists($localPath)) { // 로컬 파일이 있으면 기본 처리 return $contents; } // R2에서 파일 검색 $handler = get_r2_handler(); $adapter = $handler->getAdapter(); if (!$adapter) { return $contents; } // R2 객체 검색 $r2Key = r2_find_file_key($bo_table, $filename); if (empty($r2Key)) { return $contents; } // Presigned URL 생성 $presignedUrl = $handler->getDownloadUrl($r2Key); if (empty($presignedUrl)) { return $contents; } // 이미지 크기 처리 $width = $file['image_width'] ?? 0; $height = $file['image_height'] ?? 0; // 게시판 이미지 폭에 맞게 조정 if ($board && $width > $board['bo_image_width'] && $board['bo_image_width']) { $rate = $board['bo_image_width'] / $width; $width = $board['bo_image_width']; $height = (int)($height * $rate); } $attr = $width ? ' width="'.$width.'"' : ''; $alt = htmlspecialchars($file['content'] ?? '', ENT_QUOTES); // 이미지 태그 생성 $img = ''; $img .= '' . $alt . ''; $img .= ''; return $img; } /** * R2에서 파일 키 찾기 (캐싱 사용) * * @param string $boTable 게시판 테이블명 * @param string $filename 파일명 * @return string|null */ function r2_find_file_key($boTable, $filename) { static $r2ObjectCache = null; $handler = get_r2_handler(); $adapter = $handler->getAdapter(); if (!$adapter) { return null; } // 캐시 초기화 if ($r2ObjectCache === null) { $r2ObjectCache = []; $objects = $adapter->listObjects('', 1000); foreach ($objects['objects'] as $obj) { $objFilename = basename($obj['key']); if (!isset($r2ObjectCache[$objFilename])) { $r2ObjectCache[$objFilename] = []; } $r2ObjectCache[$objFilename][] = $obj['key']; } } // 파일명으로 검색 if (!isset($r2ObjectCache[$filename])) { return null; } // 게시판 경로와 일치하는 키 찾기 foreach ($r2ObjectCache[$filename] as $key) { if (strpos($key, "board/{$boTable}/") !== false) { return $key; } } // 게시판 경로와 일치하지 않으면 첫 번째 결과 반환 return $r2ObjectCache[$filename][0] ?? null; } /** * 게시글 본문의 로컬 이미지 URL을 R2 presigned URL로 변환 * * @param string $contents 게시글 본문 * @return string */ function r2_hook_convert_content_images($contents) { if (!is_r2_enabled()) { return $contents; } if (empty($contents)) { return $contents; } // /data/file/{bo_table}/{filename} 패턴 찾기 // URL 형식: http://domain/data/file/free/filename.jpg 또는 /data/file/free/filename.jpg $pattern = '/(https?:\/\/[^\/]+)?\/data\/file\/([^\/]+)\/([^"\'>\s]+)/i'; if (!preg_match_all($pattern, $contents, $matches, PREG_SET_ORDER)) { return $contents; } $handler = get_r2_handler(); $adapter = $handler->getAdapter(); if (!$adapter) { return $contents; } // R2 객체 목록 캐싱 (성능 최적화) static $r2ObjectCache = null; if ($r2ObjectCache === null) { $r2ObjectCache = []; $objects = $adapter->listObjects('', 1000); foreach ($objects['objects'] as $obj) { // 파일명을 키로 사용 $filename = basename($obj['key']); $r2ObjectCache[$filename] = $obj['key']; } } $replacements = []; foreach ($matches as $match) { $fullUrl = $match[0]; $boTable = $match[2]; $filename = $match[3]; // 이미 처리된 URL 건너뛰기 if (isset($replacements[$fullUrl])) { continue; } // R2에서 파일 검색 if (isset($r2ObjectCache[$filename])) { $r2Key = $r2ObjectCache[$filename]; // R2 키가 올바른 게시판인지 확인 if (strpos($r2Key, "board/{$boTable}/") !== false) { // Presigned URL 생성 $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { $replacements[$fullUrl] = $presignedUrl; } } } } // URL 교체 foreach ($replacements as $original => $replacement) { $contents = str_replace($original, $replacement, $contents); } return $contents; } // ============================================ // 디버깅 도구 // ============================================ /** * 에디터 이미지 업로드 (R2 지원) * cheditor5, smarteditor2 공통 * * @param string $url 로컬 이미지 URL * @param string $savefile 로컬 파일 경로 * @param object $fileInfo 파일 정보 (name, size, width, height 등) * @return string R2 presigned URL 또는 원본 URL */ function r2_hook_editor_upload($url, $savefile, $fileInfo) { if (!is_r2_enabled()) { return $url; } if (!file_exists($savefile)) { return $url; } global $member; $handler = get_r2_handler(); $member_id = isset($member['mb_no']) ? (int)$member['mb_no'] : 0; $filename = is_object($fileInfo) ? $fileInfo->name : basename($savefile); // R2 키 생성 (editor 타입) $r2Key = $handler->generateR2Key($member_id, '', $filename, 'editor'); // R2로 업로드 (로컬 파일 삭제) $result = $handler->migrateLocalFile($savefile, $r2Key, true); if ($result['success']) { error_log("[R2] Editor upload: {$filename} -> {$r2Key}"); // Presigned URL 반환 $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { return $presignedUrl; } } else { error_log("[R2] Editor upload failed: {$filename} - " . ($result['error'] ?? 'Unknown')); } return $url; } /** * R2 연결 테스트 * * @return array */ function r2_test_connection(): array { if (!is_r2_enabled()) { return ['success' => false, 'error' => 'R2 not enabled']; } try { $adapter = get_r2_handler()->getAdapter(); if (!$adapter) { return ['success' => false, 'error' => 'Adapter not initialized']; } // 버킷 목록 조회 테스트 $result = $adapter->listObjects('', 1); return [ 'success' => true, 'bucket' => $adapter->getBucket(), 'message' => 'Connection successful', ]; } catch (\Exception $e) { return ['success' => false, 'error' => $e->getMessage()]; } } // ============================================ // 1:1문의(QA) 첨부파일 R2 훅 // ============================================ /** * 1:1문의 파일 업로드 후 R2로 마이그레이션 */ add_event('qawrite_update', 'r2_hook_qa_upload', 10, 5); function r2_hook_qa_upload($qa_id, $write, $w, $qaconfig, $answer_id = null) { if (!is_r2_enabled()) { return; } global $g5, $member; $handler = get_r2_handler(); $member_id = isset($member['mb_no']) ? (int)$member['mb_no'] : 0; // 현재 QA 레코드 조회 $qa = sql_fetch("SELECT qa_file1, qa_file2 FROM {$g5['qa_content_table']} WHERE qa_id = '{$qa_id}'"); // 파일1, 파일2 처리 for ($i = 1; $i <= 2; $i++) { $filename = $qa["qa_file{$i}"]; if (empty($filename)) { continue; } $localPath = G5_DATA_PATH . '/qa/' . $filename; if (!file_exists($localPath)) { continue; } // R2 키 생성 $r2Key = $handler->generateR2Key($member_id, 'qa', $filename, 'qa'); // R2로 마이그레이션 $result = $handler->migrateLocalFile($localPath, $r2Key, true); if ($result['success']) { // DB에 R2 키 저장 (bf_r2_key 필드가 없으므로 파일명에 prefix 추가) $newFilename = 'r2::' . $r2Key; sql_query("UPDATE {$g5['qa_content_table']} SET qa_file{$i} = '" . sql_escape_string($newFilename) . "' WHERE qa_id = '{$qa_id}'"); error_log("[R2] QA file migrated: {$filename} -> {$r2Key}"); } else { error_log("[R2] QA file migration failed: {$filename} - " . ($result['error'] ?? 'Unknown')); } } } /** * 1:1문의 파일 다운로드 존재 확인 */ add_replace('qa_download_file_exist_check', 'r2_hook_qa_download_check', 10, 2); function r2_hook_qa_download_check($exists, $file) { if (!is_r2_enabled()) { return $exists; } // R2 파일인지 확인 if (strpos($file, 'r2::') === 0) { $r2Key = substr($file, 4); $handler = get_r2_handler(); try { return $handler->getAdapter()->exists($r2Key); } catch (\Exception $e) { error_log("[R2] QA download check failed: " . $e->getMessage()); return false; } } return $exists; } /** * 1:1문의 파일 다운로드 리다이렉트 */ add_event('qa_download_file_header', 'r2_hook_qa_download_redirect', 10, 2); function r2_hook_qa_download_redirect($file, $filename) { if (!is_r2_enabled()) { return; } // R2 파일인지 확인 if (strpos($file, 'r2::') === 0) { $r2Key = substr($file, 4); $handler = get_r2_handler(); try { $presignedUrl = $handler->getDownloadUrl($r2Key, $filename); if ($presignedUrl) { header("Location: {$presignedUrl}"); exit; } } catch (\Exception $e) { error_log("[R2] QA download redirect failed: " . $e->getMessage()); } } } // ============================================ // 회원 아이콘/이미지 R2 훅 // ============================================ /** * 회원 가입/수정 후 아이콘/이미지 R2 마이그레이션 */ add_event('register_form_update_after', 'r2_hook_member_image_upload', 10, 2); function r2_hook_member_image_upload($mb_id, $w) { if (!is_r2_enabled()) { return; } $handler = get_r2_handler(); $mb_dir = substr($mb_id, 0, 2); $icon_name = get_mb_icon_name($mb_id) . '.gif'; // 회원 아이콘 처리 $iconPath = G5_DATA_PATH . '/member/' . $mb_dir . '/' . $icon_name; if (file_exists($iconPath)) { $r2Key = "members/{$mb_id}/icon/{$icon_name}"; $result = $handler->migrateLocalFile($iconPath, $r2Key, false); // 로컬 파일 유지 if ($result['success']) { error_log("[R2] Member icon uploaded: {$mb_id} -> {$r2Key}"); } } // 회원 프로필 이미지 처리 $imgPath = G5_DATA_PATH . '/member_image/' . $mb_dir . '/' . $icon_name; if (file_exists($imgPath)) { $r2Key = "members/{$mb_id}/profile/{$icon_name}"; $result = $handler->migrateLocalFile($imgPath, $r2Key, false); // 로컬 파일 유지 if ($result['success']) { error_log("[R2] Member profile image uploaded: {$mb_id} -> {$r2Key}"); } } } /** * 관리자 회원 수정 후 아이콘/이미지 R2 마이그레이션 */ add_event('admin_member_form_update_after', 'r2_hook_admin_member_image_upload', 10, 2); function r2_hook_admin_member_image_upload($mb_id, $w) { // 동일한 처리 로직 사용 r2_hook_member_image_upload($mb_id, $w); } // ============================================ // 에디터 이미지 URL 변환 (content 내 이미지) // ============================================ /** * 에디터 콘텐츠 URL을 R2 URL로 변환 */ add_replace('get_editor_content_url', 'r2_hook_editor_content_url', 10, 1); // ============================================ // 쇼핑몰 상품 이미지 R2 훅 // ============================================ /** * 상품 이미지 존재 확인 (R2 지원) */ add_replace('is_exists_item_file', 'r2_hook_shop_image_exists', 10, 3); function r2_hook_shop_image_exists($exists, $it, $i) { if (!is_r2_enabled()) { return $exists; } // 로컬에 있으면 그대로 반환 if ($exists) { return $exists; } // R2에서 확인 $it_id = $it['it_id'] ?? ''; if (empty($it_id)) { return $exists; } $handler = get_r2_handler(); $r2Key = "shop/item/{$it_id}/{$i}"; try { return $handler->getAdapter()->exists($r2Key); } catch (\Exception $e) { return $exists; } } /** * 상품 이미지 정보 (R2 URL 반환) */ add_replace('get_image_by_item', 'r2_hook_shop_image_info', 10, 4); function r2_hook_shop_image_info($infos, $it, $i, $size) { if (!is_r2_enabled()) { return $infos; } $it_id = $it['it_id'] ?? ''; if (empty($it_id)) { return $infos; } $handler = get_r2_handler(); $r2Key = "shop/item/{$it_id}/{$i}"; try { if ($handler->getAdapter()->exists($r2Key)) { $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { $infos['src'] = $presignedUrl; $infos['r2'] = true; } } } catch (\Exception $e) { error_log("[R2] Shop image info failed: " . $e->getMessage()); } return $infos; } /** * 상품 이미지 태그 (R2 URL 지원) */ add_replace('get_it_image_tag', 'r2_hook_shop_image_tag', 10, 9); function r2_hook_shop_image_tag($img, $thumb, $it_id, $width, $height, $anchor, $img_id, $img_alt, $is_crop) { if (!is_r2_enabled()) { return $img; } // 이미 R2 URL이면 그대로 반환 if (strpos($img, 'r2.cloudflarestorage.com') !== false) { return $img; } // 로컬 이미지 경로에서 R2 URL로 변환 시도 if (preg_match('/src=["\']([^"\']+)["\']/', $img, $matches)) { $src = $matches[1]; // shop/item 경로인 경우 if (preg_match('/\/item\/([^\/]+)\/([^\/\."\']+)/', $src, $itemMatches)) { $itemId = $itemMatches[1]; $imgNum = $itemMatches[2]; $handler = get_r2_handler(); $r2Key = "shop/item/{$itemId}/{$imgNum}"; try { if ($handler->getAdapter()->exists($r2Key)) { $presignedUrl = $handler->getDownloadUrl($r2Key); if ($presignedUrl) { $img = str_replace($src, $presignedUrl, $img); } } } catch (\Exception $e) { error_log("[R2] Shop image tag failed: " . $e->getMessage()); } } } return $img; } // ============================================ // 내용관리/FAQ 이미지 R2 훅 (goto_url 이벤트 활용) // ============================================ /** * 내용관리 이미지 업로드 후 R2 마이그레이션 */ add_event('admin_content_created', 'r2_hook_content_image_migrate', 20, 1); add_event('admin_content_updated', 'r2_hook_content_image_migrate', 20, 1); function r2_hook_content_image_migrate($co_id) { if (!is_r2_enabled()) { return; } // 업로드가 완료된 후 (goto_url 전) 파일 마이그레이션 예약 // register_shutdown_function을 사용하여 스크립트 종료 직전에 마이그레이션 수행 register_shutdown_function('r2_migrate_content_images', $co_id); } function r2_migrate_content_images($co_id) { if (!is_r2_enabled()) { return; } $handler = get_r2_handler(); // 헤더 이미지 $himg_path = G5_DATA_PATH . "/content/{$co_id}_h"; if (file_exists($himg_path)) { $r2Key = "content/{$co_id}_h"; $result = $handler->migrateLocalFile($himg_path, $r2Key, false); if ($result['success']) { error_log("[R2] Content header image migrated: {$co_id}"); } } // 타이틀 이미지 $timg_path = G5_DATA_PATH . "/content/{$co_id}_t"; if (file_exists($timg_path)) { $r2Key = "content/{$co_id}_t"; $result = $handler->migrateLocalFile($timg_path, $r2Key, false); if ($result['success']) { error_log("[R2] Content title image migrated: {$co_id}"); } } } /** * FAQ 마스터 이미지 업로드 후 R2 마이그레이션 */ add_event('admin_faq_master_created', 'r2_hook_faq_image_migrate', 20, 1); add_event('admin_faq_master_updated', 'r2_hook_faq_image_migrate', 20, 1); function r2_hook_faq_image_migrate($fm_id) { if (!is_r2_enabled()) { return; } register_shutdown_function('r2_migrate_faq_images', $fm_id); } function r2_migrate_faq_images($fm_id) { if (!is_r2_enabled()) { return; } $handler = get_r2_handler(); // 헤더 이미지 $himg_path = G5_DATA_PATH . "/faq/{$fm_id}_h"; if (file_exists($himg_path)) { $r2Key = "faq/{$fm_id}_h"; $result = $handler->migrateLocalFile($himg_path, $r2Key, false); if ($result['success']) { error_log("[R2] FAQ header image migrated: {$fm_id}"); } } // 타이틀 이미지 $timg_path = G5_DATA_PATH . "/faq/{$fm_id}_t"; if (file_exists($timg_path)) { $r2Key = "faq/{$fm_id}_t"; $result = $handler->migrateLocalFile($timg_path, $r2Key, false); if ($result['success']) { error_log("[R2] FAQ title image migrated: {$fm_id}"); } } } /** * 관리자 회원 수정 후 아이콘/이미지 R2 마이그레이션 (이벤트명 수정) */ add_event('admin_member_form_update', 'r2_hook_admin_member_upload', 10, 2); function r2_hook_admin_member_upload($w, $mb_id) { r2_hook_member_image_upload($mb_id, $w); } function r2_hook_editor_content_url($url) { if (!is_r2_enabled()) { return $url; } // 이미 R2 URL이면 그대로 반환 if (strpos($url, 'r2.cloudflarestorage.com') !== false) { return $url; } // 로컬 에디터 경로인 경우 R2 URL로 변환 시도 if (preg_match('/\/data\/editor\/(\d+)\/(.+)$/', $url, $matches)) { $date = $matches[1]; $filename = $matches[2]; // R2 키 패턴으로 검색 $handler = get_r2_handler(); // users/*/editor/날짜/파일명 패턴으로 검색 try { $searchPattern = "editor/" . substr($date, 0, 4) . "-" . substr($date, 4, 2); $objects = $handler->getAdapter()->listObjects($searchPattern, 100); foreach ($objects as $obj) { if (strpos($obj['Key'], $filename) !== false) { $presignedUrl = $handler->getDownloadUrl($obj['Key']); if ($presignedUrl) { return $presignedUrl; } } } } catch (\Exception $e) { error_log("[R2] Editor content URL conversion failed: " . $e->getMessage()); } } return $url; }