diff --git a/extend/r2-storage/r2_config.php b/extend/r2-storage/r2_config.php index 9d87f3d..ca38438 100644 --- a/extend/r2-storage/r2_config.php +++ b/extend/r2-storage/r2_config.php @@ -12,9 +12,9 @@ if (!defined('_GNUBOARD_')) exit; // Cloudflare R2 Credentials // ============================================ // Cloudflare Dashboard > R2 > Manage R2 API Tokens 에서 생성 -define('R2_ACCOUNT_ID', 'your_account_id_here'); -define('R2_ACCESS_KEY_ID', 'your_access_key_id_here'); -define('R2_SECRET_ACCESS_KEY', 'your_secret_access_key_here'); +define('R2_ACCOUNT_ID', 'd8e5997eb4040f8b489f09095c0f623c'); +define('R2_ACCESS_KEY_ID', 'b4a23ea18a1e766d9d9af87898c03bfd'); +define('R2_SECRET_ACCESS_KEY', 'cf3fbe8533553aafbc26a98110a9138e49d16e0d64322b322290b5f606678567'); // ============================================ // Bucket Settings diff --git a/extend/r2_hooks.php b/extend/r2_hooks.php index 9a93df4..03e528b 100644 --- a/extend/r2_hooks.php +++ b/extend/r2_hooks.php @@ -280,10 +280,600 @@ function r2_migrate_board_files(string $boTable, int $limit = 100): array 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 연결 테스트 *