From dd86ccb782e93f7296432bdab7e5f96f05299834 Mon Sep 17 00:00:00 2001 From: kappa Date: Sat, 10 Jan 2026 14:36:38 +0900 Subject: [PATCH] feat: Gnuboard5 Cloudflare R2 Storage Module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R2StorageAdapter: S3 호환 클라이언트 래퍼 - R2FileHandler: 그누보드 통합 핸들러 - Presigned URL 지원 - 유저별 경로 분리 (users/{member_id}/...) - 대용량 파일 멀티파트 업로드 지원 - 로컬 스토리지 폴백 - DB 마이그레이션 스크립트 포함 Co-Authored-By: Claude Opus 4.5 --- README.md | 172 ++++++ extend/r2-storage/composer.json | 15 + .../migrations/001_add_r2_columns.sql | 38 ++ extend/r2-storage/migrations/002_rollback.sql | 17 + extend/r2-storage/r2_config.php | 66 +++ extend/r2-storage/src/R2FileHandler.php | 488 ++++++++++++++++ extend/r2-storage/src/R2StorageAdapter.php | 526 ++++++++++++++++++ extend/r2_hooks.php | 306 ++++++++++ 8 files changed, 1628 insertions(+) create mode 100644 README.md create mode 100644 extend/r2-storage/composer.json create mode 100644 extend/r2-storage/migrations/001_add_r2_columns.sql create mode 100644 extend/r2-storage/migrations/002_rollback.sql create mode 100644 extend/r2-storage/r2_config.php create mode 100644 extend/r2-storage/src/R2FileHandler.php create mode 100644 extend/r2-storage/src/R2StorageAdapter.php create mode 100644 extend/r2_hooks.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..0bdfda7 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# Gnuboard5 Cloudflare R2 Storage Module + +그누보드5를 위한 Cloudflare R2 파일 스토리지 모듈입니다. + +## 주요 기능 + +- Cloudflare R2 S3 호환 API를 통한 파일 업로드/다운로드 +- 유저별 경로 분리 (`users/{member_id}/...`) +- Presigned URL을 통한 보안 다운로드 +- 대용량 파일 멀티파트 업로드 지원 (100MB+) +- 로컬 스토리지 폴백 지원 +- 기존 파일 R2 마이그레이션 도구 + +## 디렉토리 구조 + +``` +gnuboard-r2-storage/ +├── extend/ +│ ├── r2_hooks.php # 그누보드 자동 로드 훅 +│ └── r2-storage/ +│ ├── composer.json +│ ├── r2_config.php # R2 설정 파일 +│ ├── src/ +│ │ ├── R2StorageAdapter.php +│ │ └── R2FileHandler.php +│ └── migrations/ +│ ├── 001_add_r2_columns.sql +│ └── 002_rollback.sql +└── README.md +``` + +## 설치 방법 + +### 1. Cloudflare R2 설정 + +1. [Cloudflare Dashboard](https://dash.cloudflare.com) 접속 +2. R2 Object Storage > Create bucket +3. 버킷 이름: `gnuboard-files` (또는 원하는 이름) +4. Manage R2 API Tokens > Create API token + - Permissions: Object Read & Write + - Specify bucket: 생성한 버킷 선택 +5. Access Key ID와 Secret Access Key 저장 + +### 2. 모듈 설치 + +```bash +# 그누보드 extend 디렉토리에 복사 +cp -r gnuboard-r2-storage/extend/* /path/to/gnuboard/extend/ + +# Composer 의존성 설치 +cd /path/to/gnuboard/extend/r2-storage +composer install +``` + +### 3. 설정 파일 수정 + +`extend/r2-storage/r2_config.php` 파일을 열고 R2 인증 정보 입력: + +```php +define('R2_ACCOUNT_ID', 'your_account_id'); +define('R2_ACCESS_KEY_ID', 'your_access_key'); +define('R2_SECRET_ACCESS_KEY', 'your_secret_key'); +define('R2_BUCKET_NAME', 'gnuboard-files'); +``` + +### 4. 데이터베이스 마이그레이션 + +```bash +# MySQL에서 실행 +mysql -u username -p database_name < extend/r2-storage/migrations/001_add_r2_columns.sql +``` + +## 사용 방법 + +### 기본 업로드 + +```php +// 파일 업로드 +$result = r2_upload_file($_FILES['bf_file'][0], $bo_table, $member['mb_no']); + +if ($result['success']) { + $r2Key = $result['r2_key']; + $downloadUrl = $result['url']; +} +``` + +### 다운로드 URL 생성 + +```php +$downloadUrl = r2_get_download_url($r2Key); +``` + +### 파일 삭제 + +```php +r2_delete_file($r2Key); +``` + +### 연결 테스트 + +```php +$test = r2_test_connection(); +var_dump($test); +``` + +## 그누보드 통합 + +### write_update.php 수정 예시 + +```php +// 기존 파일 업로드 코드 전에 추가 +if (is_r2_enabled()) { + $r2Result = r2_before_upload($file, $bo_table, $member['mb_no']); + if ($r2Result && $r2Result['storage_type'] === 'r2') { + // R2 업로드 성공 - DB에 r2_key 저장 + $bf_r2_key = $r2Result['r2_key']; + $bf_storage_type = 'r2'; + // 로컬 move_uploaded_file 스킵 + } +} +``` + +### get_file() 결과 확장 + +```php +$files = get_file($bo_table, $wr_id); +$files = r2_extend_files($files); +``` + +## 마이그레이션 도구 + +기존 로컬 파일을 R2로 마이그레이션: + +```php +// 특정 게시판의 파일 100개 마이그레이션 +$result = r2_migrate_board_files('free', 100); +print_r($result); +// ['migrated' => 95, 'failed' => 2, 'skipped' => 3, 'errors' => [...]] +``` + +## 저장 경로 구조 + +``` +bucket/ +├── users/{member_id}/ +│ ├── board/{bo_table}/{filename} # 게시판 첨부파일 +│ ├── editor/{date}/{filename} # 에디터 이미지 +│ └── profile/{filename} # 프로필 이미지 +└── public/ + └── board/{bo_table}/{filename} # 비회원 업로드 +``` + +## 설정 옵션 + +| 상수 | 기본값 | 설명 | +|------|--------|------| +| `R2_ENABLED` | `true` | R2 스토리지 활성화 | +| `R2_USE_PRESIGNED_URL` | `true` | Presigned URL 사용 | +| `R2_PRESIGNED_EXPIRY` | `3600` | URL 만료 시간 (초) | +| `R2_MULTIPART_THRESHOLD` | `100MB` | 멀티파트 업로드 임계값 | +| `R2_FALLBACK_TO_LOCAL` | `true` | R2 실패 시 로컬 저장 | + +## 요구사항 + +- PHP 7.4+ +- Composer +- AWS SDK for PHP 3.x +- 그누보드 5.x + +## 라이선스 + +MIT License diff --git a/extend/r2-storage/composer.json b/extend/r2-storage/composer.json new file mode 100644 index 0000000..dc7d3e3 --- /dev/null +++ b/extend/r2-storage/composer.json @@ -0,0 +1,15 @@ +{ + "name": "gnuboard/r2-storage", + "description": "Cloudflare R2 Storage Module for Gnuboard5", + "type": "library", + "license": "MIT", + "require": { + "php": ">=7.4", + "aws/aws-sdk-php": "^3.0" + }, + "autoload": { + "psr-4": { + "Gnuboard\\R2Storage\\": "src/" + } + } +} diff --git a/extend/r2-storage/migrations/001_add_r2_columns.sql b/extend/r2-storage/migrations/001_add_r2_columns.sql new file mode 100644 index 0000000..b94fceb --- /dev/null +++ b/extend/r2-storage/migrations/001_add_r2_columns.sql @@ -0,0 +1,38 @@ +-- ============================================ +-- Gnuboard5 R2 Storage Migration +-- Version: 1.0.0 +-- ============================================ + +-- 게시판 첨부파일 테이블에 R2 관련 컬럼 추가 +ALTER TABLE `g5_board_file` +ADD COLUMN `bf_r2_key` VARCHAR(500) DEFAULT NULL COMMENT 'R2 object key (storage path)' AFTER `bf_type`, +ADD COLUMN `bf_storage_type` ENUM('local', 'r2') DEFAULT 'local' COMMENT 'Storage type' AFTER `bf_r2_key`; + +-- 인덱스 추가 (R2 키 조회 최적화) +CREATE INDEX `idx_bf_r2_key` ON `g5_board_file` (`bf_r2_key`(255)); +CREATE INDEX `idx_bf_storage_type` ON `g5_board_file` (`bf_storage_type`); + +-- 1:1 문의 첨부파일 테이블 (있는 경우) +-- ALTER TABLE `g5_qa_content` +-- ADD COLUMN `qa_r2_key1` VARCHAR(500) DEFAULT NULL COMMENT 'R2 object key for file 1', +-- ADD COLUMN `qa_r2_key2` VARCHAR(500) DEFAULT NULL COMMENT 'R2 object key for file 2', +-- ADD COLUMN `qa_storage_type` ENUM('local', 'r2') DEFAULT 'local'; + +-- 에디터 첨부파일 추적 테이블 (신규 생성 - 선택사항) +CREATE TABLE IF NOT EXISTS `g5_editor_files` ( + `ef_no` INT(11) NOT NULL AUTO_INCREMENT, + `mb_id` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'Member ID', + `ef_file` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'File name', + `ef_source` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Original file name', + `ef_filesize` INT(11) NOT NULL DEFAULT 0 COMMENT 'File size in bytes', + `ef_width` INT(11) NOT NULL DEFAULT 0 COMMENT 'Image width', + `ef_height` INT(11) NOT NULL DEFAULT 0 COMMENT 'Image height', + `ef_type` TINYINT(4) NOT NULL DEFAULT 0 COMMENT 'File type (0=file, 1=image)', + `ef_r2_key` VARCHAR(500) DEFAULT NULL COMMENT 'R2 object key', + `ef_storage_type` ENUM('local', 'r2') DEFAULT 'local' COMMENT 'Storage type', + `ef_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Upload datetime', + PRIMARY KEY (`ef_no`), + KEY `idx_mb_id` (`mb_id`), + KEY `idx_ef_r2_key` (`ef_r2_key`(255)), + KEY `idx_ef_storage_type` (`ef_storage_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Editor uploaded files tracking'; diff --git a/extend/r2-storage/migrations/002_rollback.sql b/extend/r2-storage/migrations/002_rollback.sql new file mode 100644 index 0000000..61d3840 --- /dev/null +++ b/extend/r2-storage/migrations/002_rollback.sql @@ -0,0 +1,17 @@ +-- ============================================ +-- Gnuboard5 R2 Storage Rollback +-- WARNING: 이 스크립트는 R2 관련 컬럼을 삭제합니다. +-- 실행 전 반드시 백업하세요! +-- ============================================ + +-- 인덱스 삭제 +DROP INDEX `idx_bf_r2_key` ON `g5_board_file`; +DROP INDEX `idx_bf_storage_type` ON `g5_board_file`; + +-- 컬럼 삭제 +ALTER TABLE `g5_board_file` +DROP COLUMN `bf_r2_key`, +DROP COLUMN `bf_storage_type`; + +-- 에디터 파일 테이블 삭제 (선택사항) +-- DROP TABLE IF EXISTS `g5_editor_files`; diff --git a/extend/r2-storage/r2_config.php b/extend/r2-storage/r2_config.php new file mode 100644 index 0000000..9d87f3d --- /dev/null +++ b/extend/r2-storage/r2_config.php @@ -0,0 +1,66 @@ + r2_config.php + */ + +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'); + +// ============================================ +// Bucket Settings +// ============================================ +define('R2_BUCKET_NAME', 'gnuboard-files'); +define('R2_ENDPOINT', 'https://' . R2_ACCOUNT_ID . '.r2.cloudflarestorage.com'); + +// Public URL (CDN 또는 Public Bucket URL) +// 예: https://pub-xxxxx.r2.dev 또는 Custom Domain +define('R2_PUBLIC_URL', ''); + +// ============================================ +// Storage Options +// ============================================ +// R2 스토리지 활성화 여부 +define('R2_ENABLED', true); + +// Presigned URL 사용 여부 (권장: true) +define('R2_USE_PRESIGNED_URL', true); + +// Presigned URL 만료 시간 (초) - 기본 1시간 +define('R2_PRESIGNED_EXPIRY', 3600); + +// 대용량 파일 멀티파트 업로드 임계값 (bytes) - 기본 100MB +define('R2_MULTIPART_THRESHOLD', 100 * 1024 * 1024); + +// R2 연결 실패 시 로컬 저장 폴백 +define('R2_FALLBACK_TO_LOCAL', true); + +// ============================================ +// Path Prefix Settings +// ============================================ +// 유저별 경로 사용 여부 +define('R2_USE_USER_PREFIX', true); + +// 기본 경로 구조 +// {user_id} - 회원 ID +// {bo_table} - 게시판 테이블명 +// {date} - 날짜 (Y-m-d) +define('R2_PATH_BOARD', 'users/{user_id}/board/{bo_table}'); +define('R2_PATH_EDITOR', 'users/{user_id}/editor/{date}'); +define('R2_PATH_PROFILE', 'users/{user_id}/profile'); +define('R2_PATH_PUBLIC', 'public/board/{bo_table}'); + +// ============================================ +// Allowed File Types +// ============================================ +define('R2_ALLOWED_IMAGE_EXT', 'jpg,jpeg,gif,png,webp,svg'); +define('R2_ALLOWED_FILE_EXT', 'jpg,jpeg,gif,png,webp,svg,pdf,doc,docx,xls,xlsx,ppt,pptx,hwp,txt,zip,rar,7z'); diff --git a/extend/r2-storage/src/R2FileHandler.php b/extend/r2-storage/src/R2FileHandler.php new file mode 100644 index 0000000..e0b6cc0 --- /dev/null +++ b/extend/r2-storage/src/R2FileHandler.php @@ -0,0 +1,488 @@ +loadConfig($config); + + if ($this->enabled) { + $this->initAdapter(); + } + } + + /** + * 설정 로드 + * + * @param array|null $config + */ + private function loadConfig(?array $config): void + { + if ($config !== null) { + // 배열로 전달된 설정 사용 + $this->enabled = $config['enabled'] ?? true; + $this->usePresignedUrl = $config['use_presigned_url'] ?? true; + $this->presignedExpiry = $config['presigned_expiry'] ?? 3600; + $this->fallbackToLocal = $config['fallback_to_local'] ?? true; + $this->pathTemplates = $config['path_templates'] ?? []; + } else { + // 상수에서 로드 (그누보드 환경) + $this->enabled = defined('R2_ENABLED') ? R2_ENABLED : false; + $this->usePresignedUrl = defined('R2_USE_PRESIGNED_URL') ? R2_USE_PRESIGNED_URL : true; + $this->presignedExpiry = defined('R2_PRESIGNED_EXPIRY') ? R2_PRESIGNED_EXPIRY : 3600; + $this->fallbackToLocal = defined('R2_FALLBACK_TO_LOCAL') ? R2_FALLBACK_TO_LOCAL : true; + + $this->pathTemplates = [ + 'board' => defined('R2_PATH_BOARD') ? R2_PATH_BOARD : 'users/{user_id}/board/{bo_table}', + 'editor' => defined('R2_PATH_EDITOR') ? R2_PATH_EDITOR : 'users/{user_id}/editor/{date}', + 'profile' => defined('R2_PATH_PROFILE') ? R2_PATH_PROFILE : 'users/{user_id}/profile', + 'public' => defined('R2_PATH_PUBLIC') ? R2_PATH_PUBLIC : 'public/board/{bo_table}', + ]; + } + } + + /** + * R2 어댑터 초기화 + */ + private function initAdapter(): void + { + try { + $this->adapter = new R2StorageAdapter([ + 'account_id' => defined('R2_ACCOUNT_ID') ? R2_ACCOUNT_ID : '', + 'access_key_id' => defined('R2_ACCESS_KEY_ID') ? R2_ACCESS_KEY_ID : '', + 'secret_access_key' => defined('R2_SECRET_ACCESS_KEY') ? R2_SECRET_ACCESS_KEY : '', + 'bucket' => defined('R2_BUCKET_NAME') ? R2_BUCKET_NAME : '', + 'public_url' => defined('R2_PUBLIC_URL') ? R2_PUBLIC_URL : '', + 'multipart_threshold' => defined('R2_MULTIPART_THRESHOLD') ? R2_MULTIPART_THRESHOLD : (100 * 1024 * 1024), + ]); + } catch (\Exception $e) { + $this->lastError = $e->getMessage(); + $this->enabled = false; + } + } + + /** + * R2 활성화 여부 + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->enabled && $this->adapter !== null; + } + + /** + * 파일 업로드 처리 (그누보드 $_FILES 배열 처리) + * + * @param array $file $_FILES 배열 요소 + * @param string $boTable 게시판 테이블명 + * @param int $memberId 회원 ID (0이면 비회원) + * @param string $type 업로드 타입 (board|editor|profile) + * @return array 결과 배열 + */ + public function handleUpload(array $file, string $boTable, int $memberId = 0, string $type = 'board'): array + { + // 업로드 에러 체크 + if ($file['error'] !== UPLOAD_ERR_OK) { + return $this->handleUploadError($file['error']); + } + + // R2 비활성화 시 로컬 저장 정보 반환 + if (!$this->isEnabled()) { + if ($this->fallbackToLocal) { + return $this->fallbackLocalInfo($file, $boTable, $memberId, $type); + } + return ['success' => false, 'error' => 'R2 storage is not enabled']; + } + + // 안전한 파일명 생성 + $safeFilename = $this->generateSafeFilename($file['name']); + + // R2 키 생성 + $r2Key = $this->generateR2Key($memberId, $boTable, $safeFilename, $type); + + // 업로드 실행 + $result = $this->adapter->upload($file['tmp_name'], $r2Key, [ + 'metadata' => [ + 'original_name' => $file['name'], + 'member_id' => (string) $memberId, + 'bo_table' => $boTable, + 'upload_time' => date('Y-m-d H:i:s'), + ], + ]); + + if ($result['success']) { + return [ + 'success' => true, + 'storage_type' => 'r2', + 'r2_key' => $r2Key, + 'filename' => $safeFilename, + 'original_name' => $file['name'], + 'filesize' => $file['size'], + 'content_type' => $file['type'], + 'url' => $this->getDownloadUrl($r2Key), + 'etag' => $result['etag'] ?? '', + ]; + } + + // R2 업로드 실패 시 로컬 폴백 + if ($this->fallbackToLocal) { + $this->lastError = $result['error'] ?? 'Unknown upload error'; + return $this->fallbackLocalInfo($file, $boTable, $memberId, $type); + } + + return ['success' => false, 'error' => $result['error'] ?? 'Upload failed']; + } + + /** + * 업로드 에러 처리 + * + * @param int $errorCode + * @return array + */ + private function handleUploadError(int $errorCode): array + { + $messages = [ + UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini', + UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive in the HTML form', + UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded', + UPLOAD_ERR_NO_FILE => 'No file was uploaded', + UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder', + UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk', + UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload', + ]; + + return [ + 'success' => false, + 'error' => $messages[$errorCode] ?? 'Unknown upload error', + ]; + } + + /** + * 로컬 폴백 정보 생성 + * + * @param array $file + * @param string $boTable + * @param int $memberId + * @param string $type + * @return array + */ + private function fallbackLocalInfo(array $file, string $boTable, int $memberId, string $type): array + { + $safeFilename = $this->generateSafeFilename($file['name']); + + return [ + 'success' => true, + 'storage_type' => 'local', + 'r2_key' => null, + 'filename' => $safeFilename, + 'original_name' => $file['name'], + 'filesize' => $file['size'], + 'content_type' => $file['type'], + 'fallback' => true, + 'fallback_reason' => $this->lastError ?? 'R2 not available', + ]; + } + + /** + * 안전한 파일명 생성 (그누보드 스타일) + * + * @param string $originalName 원본 파일명 + * @return string + */ + public function generateSafeFilename(string $originalName): string + { + // 확장자 추출 + $ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION)); + + // 위험한 확장자 처리 + $dangerousExt = ['php', 'phtml', 'php3', 'php4', 'php5', 'phps', 'phar', 'inc', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'asp', 'aspx']; + if (in_array($ext, $dangerousExt)) { + $ext .= '-x'; + } + + // 해시 기반 파일명 생성 + $hash = md5(sha1($_SERVER['REMOTE_ADDR'] ?? 'localhost') . microtime() . mt_rand()); + $random = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8); + + return $hash . '_' . $random . '.' . $ext; + } + + /** + * R2 객체 키 생성 + * + * @param int $memberId 회원 ID + * @param string $boTable 게시판 테이블명 + * @param string $filename 파일명 + * @param string $type 업로드 타입 + * @return string + */ + public function generateR2Key(int $memberId, string $boTable, string $filename, string $type = 'board'): string + { + // 비회원은 public 경로 사용 + if ($memberId <= 0) { + $template = $this->pathTemplates['public'] ?? 'public/board/{bo_table}'; + } else { + $template = $this->pathTemplates[$type] ?? $this->pathTemplates['board'] ?? 'users/{user_id}/board/{bo_table}'; + } + + // 플레이스홀더 치환 + $path = str_replace( + ['{user_id}', '{bo_table}', '{date}'], + [$memberId, $boTable, date('Y-m-d')], + $template + ); + + return trim($path, '/') . '/' . $filename; + } + + /** + * 다운로드 URL 생성 + * + * @param string $r2Key R2 객체 키 + * @return string + */ + public function getDownloadUrl(string $r2Key): string + { + if (!$this->isEnabled()) { + return ''; + } + + if ($this->usePresignedUrl) { + return $this->adapter->getPresignedUrl($r2Key, $this->presignedExpiry) ?? ''; + } + + return $this->adapter->getPublicUrl($r2Key); + } + + /** + * 파일 삭제 + * + * @param string $r2Key R2 객체 키 + * @return bool + */ + public function handleDelete(string $r2Key): bool + { + if (!$this->isEnabled() || empty($r2Key)) { + return false; + } + + return $this->adapter->delete($r2Key); + } + + /** + * 여러 파일 삭제 + * + * @param array $r2Keys R2 객체 키 배열 + * @return array + */ + public function handleDeleteMultiple(array $r2Keys): array + { + if (!$this->isEnabled() || empty($r2Keys)) { + return ['deleted' => [], 'errors' => []]; + } + + return $this->adapter->deleteMultiple($r2Keys); + } + + /** + * 파일 존재 여부 확인 + * + * @param string $r2Key R2 객체 키 + * @return bool + */ + public function exists(string $r2Key): bool + { + if (!$this->isEnabled() || empty($r2Key)) { + return false; + } + + return $this->adapter->exists($r2Key); + } + + /** + * 파일 메타데이터 조회 + * + * @param string $r2Key R2 객체 키 + * @return array|null + */ + public function getMetadata(string $r2Key): ?array + { + if (!$this->isEnabled() || empty($r2Key)) { + return null; + } + + return $this->adapter->getMetadata($r2Key); + } + + /** + * 유저 파일 목록 조회 + * + * @param int $memberId 회원 ID + * @param string|null $type 파일 타입 (board|editor|profile) + * @param int $maxKeys 최대 개수 + * @return array + */ + public function listUserFiles(int $memberId, ?string $type = null, int $maxKeys = 100): array + { + if (!$this->isEnabled() || $memberId <= 0) { + return []; + } + + $prefix = "users/{$memberId}/"; + if ($type) { + $prefix .= "{$type}/"; + } + + $result = $this->adapter->listObjects($prefix, $maxKeys); + return $result['objects'] ?? []; + } + + /** + * 그누보드 get_file() 함수 확장 + * + * 기존 파일 정보에 R2 URL 추가 + * + * @param array $fileInfo 기존 파일 정보 + * @param string|null $r2Key R2 객체 키 + * @return array + */ + public function extendFileInfo(array $fileInfo, ?string $r2Key): array + { + if (!$this->isEnabled() || empty($r2Key)) { + return $fileInfo; + } + + // R2 다운로드 URL로 교체 + $downloadUrl = $this->getDownloadUrl($r2Key); + if ($downloadUrl) { + $fileInfo['href'] = $downloadUrl; + $fileInfo['r2_key'] = $r2Key; + $fileInfo['storage_type'] = 'r2'; + } + + return $fileInfo; + } + + /** + * 로컬 파일을 R2로 마이그레이션 + * + * @param string $localPath 로컬 파일 경로 + * @param string $r2Key R2 객체 키 + * @param bool $deleteLocal 로컬 파일 삭제 여부 + * @return array + */ + public function migrateLocalFile(string $localPath, string $r2Key, bool $deleteLocal = false): array + { + if (!$this->isEnabled()) { + return ['success' => false, 'error' => 'R2 not enabled']; + } + + if (!file_exists($localPath)) { + return ['success' => false, 'error' => 'Local file not found']; + } + + $result = $this->adapter->upload($localPath, $r2Key); + + if ($result['success'] && $deleteLocal) { + @unlink($localPath); + } + + return $result; + } + + /** + * 마지막 에러 메시지 + * + * @return string|null + */ + public function getLastError(): ?string + { + if ($this->lastError) { + return $this->lastError; + } + + if ($this->adapter) { + return $this->adapter->getLastError(); + } + + return null; + } + + /** + * R2 어댑터 직접 접근 + * + * @return R2StorageAdapter|null + */ + public function getAdapter(): ?R2StorageAdapter + { + return $this->adapter; + } + + /** + * 이미지 파일 여부 확인 + * + * @param string $filename + * @return bool + */ + public function isImage(string $filename): bool + { + $imageExt = defined('R2_ALLOWED_IMAGE_EXT') + ? explode(',', R2_ALLOWED_IMAGE_EXT) + : ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg']; + + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + return in_array($ext, $imageExt); + } + + /** + * 허용된 파일 확장자 체크 + * + * @param string $filename + * @return bool + */ + public function isAllowedExtension(string $filename): bool + { + $allowedExt = defined('R2_ALLOWED_FILE_EXT') + ? explode(',', R2_ALLOWED_FILE_EXT) + : ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'txt', 'zip', 'rar', '7z']; + + $ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); + return in_array($ext, $allowedExt); + } +} diff --git a/extend/r2-storage/src/R2StorageAdapter.php b/extend/r2-storage/src/R2StorageAdapter.php new file mode 100644 index 0000000..2b84850 --- /dev/null +++ b/extend/r2-storage/src/R2StorageAdapter.php @@ -0,0 +1,526 @@ +validateConfig($config); + + $this->bucket = $config['bucket']; + $this->endpoint = "https://{$config['account_id']}.r2.cloudflarestorage.com"; + $this->publicUrl = $config['public_url'] ?? null; + $this->multipartThreshold = $config['multipart_threshold'] ?? (100 * 1024 * 1024); + + $this->client = new S3Client([ + 'region' => 'auto', + 'version' => 'latest', + 'endpoint' => $this->endpoint, + 'use_path_style_endpoint' => true, + 'credentials' => [ + 'key' => $config['access_key_id'], + 'secret' => $config['secret_access_key'], + ], + ]); + } + + /** + * 설정값 검증 + * + * @param array $config + * @throws \InvalidArgumentException + */ + private function validateConfig(array $config): void + { + $required = ['account_id', 'access_key_id', 'secret_access_key', 'bucket']; + foreach ($required as $key) { + if (empty($config[$key])) { + throw new \InvalidArgumentException("Missing required config: {$key}"); + } + } + } + + /** + * 로컬 파일을 R2에 업로드 + * + * @param string $localPath 로컬 파일 경로 + * @param string $r2Key R2 객체 키 (경로) + * @param array $options 추가 옵션 + * - content_type: MIME 타입 + * - acl: 접근 제어 (private|public-read) + * - metadata: 메타데이터 배열 + * @return array 결과 배열 ['success' => bool, 'key' => string, 'etag' => string, 'url' => string] + */ + public function upload(string $localPath, string $r2Key, array $options = []): array + { + if (!file_exists($localPath)) { + $this->lastError = "File not found: {$localPath}"; + return ['success' => false, 'error' => $this->lastError]; + } + + $fileSize = filesize($localPath); + + // 대용량 파일은 멀티파트 업로드 사용 + if ($fileSize > $this->multipartThreshold) { + return $this->multipartUpload($localPath, $r2Key, $options); + } + + return $this->singleUpload($localPath, $r2Key, $options); + } + + /** + * 단일 파일 업로드 (100MB 미만) + * + * @param string $localPath + * @param string $r2Key + * @param array $options + * @return array + */ + private function singleUpload(string $localPath, string $r2Key, array $options = []): array + { + try { + $params = [ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + 'SourceFile' => $localPath, + ]; + + // Content-Type 설정 + if (!empty($options['content_type'])) { + $params['ContentType'] = $options['content_type']; + } else { + $params['ContentType'] = $this->getMimeType($localPath); + } + + // 메타데이터 추가 + if (!empty($options['metadata'])) { + $params['Metadata'] = $options['metadata']; + } + + $result = $this->client->putObject($params); + + return [ + 'success' => true, + 'key' => $r2Key, + 'etag' => trim($result['ETag'], '"'), + 'url' => $this->getPublicUrl($r2Key), + ]; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return ['success' => false, 'error' => $this->lastError]; + } + } + + /** + * 멀티파트 업로드 (대용량 파일용) + * + * @param string $localPath + * @param string $r2Key + * @param array $options + * @return array + */ + private function multipartUpload(string $localPath, string $r2Key, array $options = []): array + { + try { + $uploaderOptions = [ + 'bucket' => $this->bucket, + 'key' => $r2Key, + 'part_size' => 10 * 1024 * 1024, // 10MB per part + 'concurrency' => 5, + ]; + + if (!empty($options['content_type'])) { + $uploaderOptions['params']['ContentType'] = $options['content_type']; + } else { + $uploaderOptions['params']['ContentType'] = $this->getMimeType($localPath); + } + + $uploader = new MultipartUploader($this->client, $localPath, $uploaderOptions); + $result = $uploader->upload(); + + return [ + 'success' => true, + 'key' => $r2Key, + 'etag' => trim($result['ETag'], '"'), + 'url' => $this->getPublicUrl($r2Key), + ]; + } catch (MultipartUploadException $e) { + $this->lastError = $e->getMessage(); + return ['success' => false, 'error' => $this->lastError]; + } + } + + /** + * 스트림에서 R2로 업로드 + * + * @param resource|string $stream 파일 스트림 또는 내용 + * @param string $r2Key R2 객체 키 + * @param array $options 추가 옵션 + * @return array + */ + public function uploadFromStream($stream, string $r2Key, array $options = []): array + { + try { + $params = [ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + 'Body' => $stream, + ]; + + if (!empty($options['content_type'])) { + $params['ContentType'] = $options['content_type']; + } + + if (!empty($options['metadata'])) { + $params['Metadata'] = $options['metadata']; + } + + $result = $this->client->putObject($params); + + return [ + 'success' => true, + 'key' => $r2Key, + 'etag' => trim($result['ETag'], '"'), + 'url' => $this->getPublicUrl($r2Key), + ]; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return ['success' => false, 'error' => $this->lastError]; + } + } + + /** + * R2 객체 삭제 + * + * @param string $r2Key R2 객체 키 + * @return bool + */ + public function delete(string $r2Key): bool + { + try { + $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + ]); + return true; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return false; + } + } + + /** + * 여러 객체 일괄 삭제 + * + * @param array $r2Keys R2 객체 키 배열 + * @return array ['deleted' => [...], 'errors' => [...]] + */ + public function deleteMultiple(array $r2Keys): array + { + if (empty($r2Keys)) { + return ['deleted' => [], 'errors' => []]; + } + + try { + $objects = array_map(function ($key) { + return ['Key' => $key]; + }, $r2Keys); + + $result = $this->client->deleteObjects([ + 'Bucket' => $this->bucket, + 'Delete' => [ + 'Objects' => $objects, + 'Quiet' => false, + ], + ]); + + $deleted = []; + $errors = []; + + if (!empty($result['Deleted'])) { + $deleted = array_column($result['Deleted'], 'Key'); + } + + if (!empty($result['Errors'])) { + $errors = $result['Errors']; + } + + return ['deleted' => $deleted, 'errors' => $errors]; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return ['deleted' => [], 'errors' => [['message' => $this->lastError]]]; + } + } + + /** + * Presigned URL 생성 (다운로드용) + * + * @param string $r2Key R2 객체 키 + * @param int $expiry 만료 시간 (초) - 기본 1시간 + * @param string $method HTTP 메서드 (GET|PUT) + * @return string|null Presigned URL + */ + public function getPresignedUrl(string $r2Key, int $expiry = 3600, string $method = 'GET'): ?string + { + try { + $command = $method === 'PUT' + ? $this->client->getCommand('PutObject', [ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + ]) + : $this->client->getCommand('GetObject', [ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + ]); + + $request = $this->client->createPresignedRequest($command, "+{$expiry} seconds"); + return (string) $request->getUri(); + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return null; + } + } + + /** + * 객체 존재 여부 확인 + * + * @param string $r2Key R2 객체 키 + * @return bool + */ + public function exists(string $r2Key): bool + { + try { + $this->client->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + ]); + return true; + } catch (AwsException $e) { + return false; + } + } + + /** + * 객체 메타데이터 조회 + * + * @param string $r2Key R2 객체 키 + * @return array|null + */ + public function getMetadata(string $r2Key): ?array + { + try { + $result = $this->client->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $r2Key, + ]); + + return [ + 'content_type' => $result['ContentType'] ?? null, + 'content_length' => $result['ContentLength'] ?? null, + 'last_modified' => $result['LastModified'] ?? null, + 'etag' => trim($result['ETag'] ?? '', '"'), + 'metadata' => $result['Metadata'] ?? [], + ]; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return null; + } + } + + /** + * 객체 목록 조회 + * + * @param string $prefix 경로 접두사 + * @param int $maxKeys 최대 개수 + * @param string|null $continuationToken 페이지네이션 토큰 + * @return array + */ + public function listObjects(string $prefix = '', int $maxKeys = 1000, ?string $continuationToken = null): array + { + try { + $params = [ + 'Bucket' => $this->bucket, + 'MaxKeys' => $maxKeys, + ]; + + if ($prefix) { + $params['Prefix'] = $prefix; + } + + if ($continuationToken) { + $params['ContinuationToken'] = $continuationToken; + } + + $result = $this->client->listObjectsV2($params); + + $objects = []; + if (!empty($result['Contents'])) { + foreach ($result['Contents'] as $object) { + $objects[] = [ + 'key' => $object['Key'], + 'size' => $object['Size'], + 'last_modified' => $object['LastModified'], + 'etag' => trim($object['ETag'], '"'), + ]; + } + } + + return [ + 'objects' => $objects, + 'is_truncated' => $result['IsTruncated'] ?? false, + 'next_token' => $result['NextContinuationToken'] ?? null, + ]; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return ['objects' => [], 'is_truncated' => false, 'next_token' => null]; + } + } + + /** + * 객체 복사 + * + * @param string $sourceKey 원본 키 + * @param string $destKey 대상 키 + * @return bool + */ + public function copy(string $sourceKey, string $destKey): bool + { + try { + $this->client->copyObject([ + 'Bucket' => $this->bucket, + 'CopySource' => "{$this->bucket}/{$sourceKey}", + 'Key' => $destKey, + ]); + return true; + } catch (AwsException $e) { + $this->lastError = $e->getMessage(); + return false; + } + } + + /** + * Public URL 반환 + * + * @param string $r2Key R2 객체 키 + * @return string + */ + public function getPublicUrl(string $r2Key): string + { + if ($this->publicUrl) { + return rtrim($this->publicUrl, '/') . '/' . ltrim($r2Key, '/'); + } + + // Public URL이 설정되지 않은 경우 endpoint URL 반환 + return $this->endpoint . '/' . $this->bucket . '/' . ltrim($r2Key, '/'); + } + + /** + * 마지막 에러 메시지 반환 + * + * @return string|null + */ + public function getLastError(): ?string + { + return $this->lastError; + } + + /** + * 버킷 이름 반환 + * + * @return string + */ + public function getBucket(): string + { + return $this->bucket; + } + + /** + * MIME 타입 추론 + * + * @param string $filePath + * @return string + */ + private function getMimeType(string $filePath): string + { + $mimeTypes = [ + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'png' => 'image/png', + 'webp' => 'image/webp', + 'svg' => 'image/svg+xml', + 'pdf' => 'application/pdf', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'hwp' => 'application/x-hwp', + 'txt' => 'text/plain', + 'zip' => 'application/zip', + 'rar' => 'application/x-rar-compressed', + '7z' => 'application/x-7z-compressed', + ]; + + $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + + if (isset($mimeTypes[$ext])) { + return $mimeTypes[$ext]; + } + + // finfo 사용 + if (function_exists('finfo_open') && file_exists($filePath)) { + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mimeType = finfo_file($finfo, $filePath); + finfo_close($finfo); + if ($mimeType) { + return $mimeType; + } + } + + return 'application/octet-stream'; + } +} diff --git a/extend/r2_hooks.php b/extend/r2_hooks.php new file mode 100644 index 0000000..7bba56e --- /dev/null +++ b/extend/r2_hooks.php @@ -0,0 +1,306 @@ +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' => []]; + + // 아직 마이그레이션되지 않은 파일 조회 + $sql = "SELECT bf_no, wr_id, bf_file, bf_source + FROM {$g5['board_file_table']} + WHERE bo_table = '{$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 조회 (게시글에서) + $write = sql_fetch("SELECT mb_id FROM {$g5['write_prefix']}{$boTable} WHERE wr_id = '{$row['wr_id']}'"); + $memberId = 0; + if ($write['mb_id']) { + $member = sql_fetch("SELECT mb_no FROM {$g5['member_table']} WHERE mb_id = '{$write['mb_id']}'"); + $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; +} + +// ============================================ +// 디버깅 도구 +// ============================================ + +/** + * 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()]; + } +}