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:
172
README.md
Normal file
172
README.md
Normal 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
|
||||
15
extend/r2-storage/composer.json
Normal file
15
extend/r2-storage/composer.json
Normal 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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
38
extend/r2-storage/migrations/001_add_r2_columns.sql
Normal file
38
extend/r2-storage/migrations/001_add_r2_columns.sql
Normal 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';
|
||||
17
extend/r2-storage/migrations/002_rollback.sql
Normal file
17
extend/r2-storage/migrations/002_rollback.sql
Normal 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`;
|
||||
66
extend/r2-storage/r2_config.php
Normal file
66
extend/r2-storage/r2_config.php
Normal 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');
|
||||
488
extend/r2-storage/src/R2FileHandler.php
Normal file
488
extend/r2-storage/src/R2FileHandler.php
Normal 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);
|
||||
}
|
||||
}
|
||||
526
extend/r2-storage/src/R2StorageAdapter.php
Normal file
526
extend/r2-storage/src/R2StorageAdapter.php
Normal 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
306
extend/r2_hooks.php
Normal 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()];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user