feat: Gnuboard5 Cloudflare R2 Storage Module

- R2StorageAdapter: S3 호환 클라이언트 래퍼
- R2FileHandler: 그누보드 통합 핸들러
- Presigned URL 지원
- 유저별 경로 분리 (users/{member_id}/...)
- 대용량 파일 멀티파트 업로드 지원
- 로컬 스토리지 폴백
- DB 마이그레이션 스크립트 포함

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-10 14:36:38 +09:00
commit dd86ccb782
8 changed files with 1628 additions and 0 deletions

172
README.md Normal file
View File

@@ -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

View File

@@ -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/"
}
}
}

View File

@@ -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';

View File

@@ -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`;

View File

@@ -0,0 +1,66 @@
<?php
/**
* Cloudflare R2 Storage Configuration
*
* 이 파일을 복사하여 실제 값으로 수정하세요.
* r2_config.php.example -> 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');

View File

@@ -0,0 +1,488 @@
<?php
/**
* R2 File Handler for Gnuboard5
*
* 그누보드5와 R2 스토리지를 연결하는 핸들러 클래스
*
* @package Gnuboard\R2Storage
*/
namespace Gnuboard\R2Storage;
class R2FileHandler
{
/** @var R2StorageAdapter */
private $adapter;
/** @var bool */
private $enabled;
/** @var bool */
private $usePresignedUrl;
/** @var int */
private $presignedExpiry;
/** @var bool */
private $fallbackToLocal;
/** @var array */
private $pathTemplates;
/** @var string|null */
private $lastError;
/**
* R2FileHandler 생성자
*
* @param array|null $config 설정 (null이면 상수에서 로드)
*/
public function __construct(?array $config = null)
{
$this->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);
}
}

View File

@@ -0,0 +1,526 @@
<?php
/**
* Cloudflare R2 Storage Adapter
*
* AWS SDK를 사용하여 R2 S3 호환 API와 통신하는 어댑터 클래스
*
* @package Gnuboard\R2Storage
*/
namespace Gnuboard\R2Storage;
use Aws\S3\S3Client;
use Aws\S3\MultipartUploader;
use Aws\Exception\AwsException;
use Aws\Exception\MultipartUploadException;
class R2StorageAdapter
{
/** @var S3Client */
private $client;
/** @var string */
private $bucket;
/** @var string */
private $endpoint;
/** @var int */
private $multipartThreshold;
/** @var string|null */
private $publicUrl;
/** @var string|null */
private $lastError;
/**
* R2StorageAdapter 생성자
*
* @param array $config 설정 배열
* - account_id: Cloudflare Account ID
* - access_key_id: R2 Access Key ID
* - secret_access_key: R2 Secret Access Key
* - bucket: 버킷 이름
* - public_url: (선택) Public URL
* - multipart_threshold: (선택) 멀티파트 업로드 임계값 (bytes)
*/
public function __construct(array $config)
{
$this->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';
}
}

306
extend/r2_hooks.php Normal file
View File

@@ -0,0 +1,306 @@
<?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' => []];
// 아직 마이그레이션되지 않은 파일 조회
$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()];
}
}