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()]; } }