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