feat: 에디터 이미지 업로드 R2 훅 추가

- cheditor5, smarteditor2 에디터 이미지 업로드 지원
- get_editor_upload_url 훅을 통한 R2 스토리지 연동
- 업로드 성공 시 로컬 파일 자동 삭제
- Presigned URL 생성으로 보안 접근 제공

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-10 17:08:46 +09:00
parent 971a66773a
commit 6f92cacfea
2 changed files with 593 additions and 3 deletions

View File

@@ -12,9 +12,9 @@ if (!defined('_GNUBOARD_')) exit;
// Cloudflare R2 Credentials // Cloudflare R2 Credentials
// ============================================ // ============================================
// Cloudflare Dashboard > R2 > Manage R2 API Tokens 에서 생성 // Cloudflare Dashboard > R2 > Manage R2 API Tokens 에서 생성
define('R2_ACCOUNT_ID', 'your_account_id_here'); define('R2_ACCOUNT_ID', 'd8e5997eb4040f8b489f09095c0f623c');
define('R2_ACCESS_KEY_ID', 'your_access_key_id_here'); define('R2_ACCESS_KEY_ID', 'b4a23ea18a1e766d9d9af87898c03bfd');
define('R2_SECRET_ACCESS_KEY', 'your_secret_access_key_here'); define('R2_SECRET_ACCESS_KEY', 'cf3fbe8533553aafbc26a98110a9138e49d16e0d64322b322290b5f606678567');
// ============================================ // ============================================
// Bucket Settings // Bucket Settings

View File

@@ -280,10 +280,600 @@ function r2_migrate_board_files(string $boTable, int $limit = 100): array
return $results; 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 = '<a href="' . htmlspecialchars($presignedUrl, ENT_QUOTES) . '" target="_blank" class="view_image">';
$img .= '<img src="' . htmlspecialchars($presignedUrl, ENT_QUOTES) . '" alt="' . $alt . '"' . $attr . '>';
$img .= '</a>';
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 연결 테스트 * R2 연결 테스트
* *