- cheditor5, smarteditor2 에디터 이미지 업로드 지원 - get_editor_upload_url 훅을 통한 R2 스토리지 연동 - 업로드 성공 시 로컬 파일 자동 삭제 - Presigned URL 생성으로 보안 접근 제공 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
906 lines
24 KiB
PHP
906 lines
24 KiB
PHP
<?php
|
|
/**
|
|
* R2 Storage Hooks for Gnuboard5
|
|
*
|
|
* 이 파일을 그누보드 extend/ 디렉토리에 배치하면 자동으로 로드됩니다.
|
|
*
|
|
* 설치 방법:
|
|
* 1. extend/r2-storage/ 폴더를 그누보드 extend/ 디렉토리에 복사
|
|
* 2. extend/r2-storage/ 에서 composer install 실행
|
|
* 3. extend/r2-storage/r2_config.php 에 R2 인증 정보 입력
|
|
* 4. 이 파일(r2_hooks.php)을 extend/ 디렉토리에 복사
|
|
*
|
|
* @package Gnuboard\R2Storage
|
|
*/
|
|
|
|
if (!defined('_GNUBOARD_')) exit;
|
|
|
|
// Composer autoload
|
|
$r2AutoloadPath = __DIR__ . '/r2-storage/vendor/autoload.php';
|
|
if (!file_exists($r2AutoloadPath)) {
|
|
// Composer 설치 안됨 - 경고 로그
|
|
if (defined('G5_DATA_PATH')) {
|
|
error_log('[R2 Storage] Composer autoload not found. Please run: cd extend/r2-storage && composer install');
|
|
}
|
|
return;
|
|
}
|
|
|
|
require_once $r2AutoloadPath;
|
|
|
|
// R2 설정 로드
|
|
$r2ConfigPath = __DIR__ . '/r2-storage/r2_config.php';
|
|
if (file_exists($r2ConfigPath)) {
|
|
require_once $r2ConfigPath;
|
|
}
|
|
|
|
use Gnuboard\R2Storage\R2FileHandler;
|
|
|
|
// ============================================
|
|
// 전역 R2 핸들러 인스턴스
|
|
// ============================================
|
|
$GLOBALS['r2_handler'] = new R2FileHandler();
|
|
|
|
/**
|
|
* R2 핸들러 가져오기
|
|
*
|
|
* @return R2FileHandler
|
|
*/
|
|
function get_r2_handler(): R2FileHandler
|
|
{
|
|
return $GLOBALS['r2_handler'];
|
|
}
|
|
|
|
/**
|
|
* R2 활성화 여부
|
|
*
|
|
* @return bool
|
|
*/
|
|
function is_r2_enabled(): bool
|
|
{
|
|
return get_r2_handler()->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 = '<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 연결 테스트
|
|
*
|
|
* @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()];
|
|
}
|
|
}
|