feat: Gnuboard5 Cloudflare R2 Storage Module

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

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

View File

@@ -0,0 +1,488 @@
<?php
/**
* R2 File Handler for Gnuboard5
*
* 그누보드5와 R2 스토리지를 연결하는 핸들러 클래스
*
* @package Gnuboard\R2Storage
*/
namespace Gnuboard\R2Storage;
class R2FileHandler
{
/** @var R2StorageAdapter */
private $adapter;
/** @var bool */
private $enabled;
/** @var bool */
private $usePresignedUrl;
/** @var int */
private $presignedExpiry;
/** @var bool */
private $fallbackToLocal;
/** @var array */
private $pathTemplates;
/** @var string|null */
private $lastError;
/**
* R2FileHandler 생성자
*
* @param array|null $config 설정 (null이면 상수에서 로드)
*/
public function __construct(?array $config = null)
{
$this->loadConfig($config);
if ($this->enabled) {
$this->initAdapter();
}
}
/**
* 설정 로드
*
* @param array|null $config
*/
private function loadConfig(?array $config): void
{
if ($config !== null) {
// 배열로 전달된 설정 사용
$this->enabled = $config['enabled'] ?? true;
$this->usePresignedUrl = $config['use_presigned_url'] ?? true;
$this->presignedExpiry = $config['presigned_expiry'] ?? 3600;
$this->fallbackToLocal = $config['fallback_to_local'] ?? true;
$this->pathTemplates = $config['path_templates'] ?? [];
} else {
// 상수에서 로드 (그누보드 환경)
$this->enabled = defined('R2_ENABLED') ? R2_ENABLED : false;
$this->usePresignedUrl = defined('R2_USE_PRESIGNED_URL') ? R2_USE_PRESIGNED_URL : true;
$this->presignedExpiry = defined('R2_PRESIGNED_EXPIRY') ? R2_PRESIGNED_EXPIRY : 3600;
$this->fallbackToLocal = defined('R2_FALLBACK_TO_LOCAL') ? R2_FALLBACK_TO_LOCAL : true;
$this->pathTemplates = [
'board' => defined('R2_PATH_BOARD') ? R2_PATH_BOARD : 'users/{user_id}/board/{bo_table}',
'editor' => defined('R2_PATH_EDITOR') ? R2_PATH_EDITOR : 'users/{user_id}/editor/{date}',
'profile' => defined('R2_PATH_PROFILE') ? R2_PATH_PROFILE : 'users/{user_id}/profile',
'public' => defined('R2_PATH_PUBLIC') ? R2_PATH_PUBLIC : 'public/board/{bo_table}',
];
}
}
/**
* R2 어댑터 초기화
*/
private function initAdapter(): void
{
try {
$this->adapter = new R2StorageAdapter([
'account_id' => defined('R2_ACCOUNT_ID') ? R2_ACCOUNT_ID : '',
'access_key_id' => defined('R2_ACCESS_KEY_ID') ? R2_ACCESS_KEY_ID : '',
'secret_access_key' => defined('R2_SECRET_ACCESS_KEY') ? R2_SECRET_ACCESS_KEY : '',
'bucket' => defined('R2_BUCKET_NAME') ? R2_BUCKET_NAME : '',
'public_url' => defined('R2_PUBLIC_URL') ? R2_PUBLIC_URL : '',
'multipart_threshold' => defined('R2_MULTIPART_THRESHOLD') ? R2_MULTIPART_THRESHOLD : (100 * 1024 * 1024),
]);
} catch (\Exception $e) {
$this->lastError = $e->getMessage();
$this->enabled = false;
}
}
/**
* R2 활성화 여부
*
* @return bool
*/
public function isEnabled(): bool
{
return $this->enabled && $this->adapter !== null;
}
/**
* 파일 업로드 처리 (그누보드 $_FILES 배열 처리)
*
* @param array $file $_FILES 배열 요소
* @param string $boTable 게시판 테이블명
* @param int $memberId 회원 ID (0이면 비회원)
* @param string $type 업로드 타입 (board|editor|profile)
* @return array 결과 배열
*/
public function handleUpload(array $file, string $boTable, int $memberId = 0, string $type = 'board'): array
{
// 업로드 에러 체크
if ($file['error'] !== UPLOAD_ERR_OK) {
return $this->handleUploadError($file['error']);
}
// R2 비활성화 시 로컬 저장 정보 반환
if (!$this->isEnabled()) {
if ($this->fallbackToLocal) {
return $this->fallbackLocalInfo($file, $boTable, $memberId, $type);
}
return ['success' => false, 'error' => 'R2 storage is not enabled'];
}
// 안전한 파일명 생성
$safeFilename = $this->generateSafeFilename($file['name']);
// R2 키 생성
$r2Key = $this->generateR2Key($memberId, $boTable, $safeFilename, $type);
// 업로드 실행
$result = $this->adapter->upload($file['tmp_name'], $r2Key, [
'metadata' => [
'original_name' => $file['name'],
'member_id' => (string) $memberId,
'bo_table' => $boTable,
'upload_time' => date('Y-m-d H:i:s'),
],
]);
if ($result['success']) {
return [
'success' => true,
'storage_type' => 'r2',
'r2_key' => $r2Key,
'filename' => $safeFilename,
'original_name' => $file['name'],
'filesize' => $file['size'],
'content_type' => $file['type'],
'url' => $this->getDownloadUrl($r2Key),
'etag' => $result['etag'] ?? '',
];
}
// R2 업로드 실패 시 로컬 폴백
if ($this->fallbackToLocal) {
$this->lastError = $result['error'] ?? 'Unknown upload error';
return $this->fallbackLocalInfo($file, $boTable, $memberId, $type);
}
return ['success' => false, 'error' => $result['error'] ?? 'Upload failed'];
}
/**
* 업로드 에러 처리
*
* @param int $errorCode
* @return array
*/
private function handleUploadError(int $errorCode): array
{
$messages = [
UPLOAD_ERR_INI_SIZE => 'The uploaded file exceeds the upload_max_filesize directive in php.ini',
UPLOAD_ERR_FORM_SIZE => 'The uploaded file exceeds the MAX_FILE_SIZE directive in the HTML form',
UPLOAD_ERR_PARTIAL => 'The uploaded file was only partially uploaded',
UPLOAD_ERR_NO_FILE => 'No file was uploaded',
UPLOAD_ERR_NO_TMP_DIR => 'Missing a temporary folder',
UPLOAD_ERR_CANT_WRITE => 'Failed to write file to disk',
UPLOAD_ERR_EXTENSION => 'A PHP extension stopped the file upload',
];
return [
'success' => false,
'error' => $messages[$errorCode] ?? 'Unknown upload error',
];
}
/**
* 로컬 폴백 정보 생성
*
* @param array $file
* @param string $boTable
* @param int $memberId
* @param string $type
* @return array
*/
private function fallbackLocalInfo(array $file, string $boTable, int $memberId, string $type): array
{
$safeFilename = $this->generateSafeFilename($file['name']);
return [
'success' => true,
'storage_type' => 'local',
'r2_key' => null,
'filename' => $safeFilename,
'original_name' => $file['name'],
'filesize' => $file['size'],
'content_type' => $file['type'],
'fallback' => true,
'fallback_reason' => $this->lastError ?? 'R2 not available',
];
}
/**
* 안전한 파일명 생성 (그누보드 스타일)
*
* @param string $originalName 원본 파일명
* @return string
*/
public function generateSafeFilename(string $originalName): string
{
// 확장자 추출
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
// 위험한 확장자 처리
$dangerousExt = ['php', 'phtml', 'php3', 'php4', 'php5', 'phps', 'phar', 'inc', 'jsp', 'jspx', 'cgi', 'pl', 'py', 'asp', 'aspx'];
if (in_array($ext, $dangerousExt)) {
$ext .= '-x';
}
// 해시 기반 파일명 생성
$hash = md5(sha1($_SERVER['REMOTE_ADDR'] ?? 'localhost') . microtime() . mt_rand());
$random = substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8);
return $hash . '_' . $random . '.' . $ext;
}
/**
* R2 객체 키 생성
*
* @param int $memberId 회원 ID
* @param string $boTable 게시판 테이블명
* @param string $filename 파일명
* @param string $type 업로드 타입
* @return string
*/
public function generateR2Key(int $memberId, string $boTable, string $filename, string $type = 'board'): string
{
// 비회원은 public 경로 사용
if ($memberId <= 0) {
$template = $this->pathTemplates['public'] ?? 'public/board/{bo_table}';
} else {
$template = $this->pathTemplates[$type] ?? $this->pathTemplates['board'] ?? 'users/{user_id}/board/{bo_table}';
}
// 플레이스홀더 치환
$path = str_replace(
['{user_id}', '{bo_table}', '{date}'],
[$memberId, $boTable, date('Y-m-d')],
$template
);
return trim($path, '/') . '/' . $filename;
}
/**
* 다운로드 URL 생성
*
* @param string $r2Key R2 객체 키
* @return string
*/
public function getDownloadUrl(string $r2Key): string
{
if (!$this->isEnabled()) {
return '';
}
if ($this->usePresignedUrl) {
return $this->adapter->getPresignedUrl($r2Key, $this->presignedExpiry) ?? '';
}
return $this->adapter->getPublicUrl($r2Key);
}
/**
* 파일 삭제
*
* @param string $r2Key R2 객체 키
* @return bool
*/
public function handleDelete(string $r2Key): bool
{
if (!$this->isEnabled() || empty($r2Key)) {
return false;
}
return $this->adapter->delete($r2Key);
}
/**
* 여러 파일 삭제
*
* @param array $r2Keys R2 객체 키 배열
* @return array
*/
public function handleDeleteMultiple(array $r2Keys): array
{
if (!$this->isEnabled() || empty($r2Keys)) {
return ['deleted' => [], 'errors' => []];
}
return $this->adapter->deleteMultiple($r2Keys);
}
/**
* 파일 존재 여부 확인
*
* @param string $r2Key R2 객체 키
* @return bool
*/
public function exists(string $r2Key): bool
{
if (!$this->isEnabled() || empty($r2Key)) {
return false;
}
return $this->adapter->exists($r2Key);
}
/**
* 파일 메타데이터 조회
*
* @param string $r2Key R2 객체 키
* @return array|null
*/
public function getMetadata(string $r2Key): ?array
{
if (!$this->isEnabled() || empty($r2Key)) {
return null;
}
return $this->adapter->getMetadata($r2Key);
}
/**
* 유저 파일 목록 조회
*
* @param int $memberId 회원 ID
* @param string|null $type 파일 타입 (board|editor|profile)
* @param int $maxKeys 최대 개수
* @return array
*/
public function listUserFiles(int $memberId, ?string $type = null, int $maxKeys = 100): array
{
if (!$this->isEnabled() || $memberId <= 0) {
return [];
}
$prefix = "users/{$memberId}/";
if ($type) {
$prefix .= "{$type}/";
}
$result = $this->adapter->listObjects($prefix, $maxKeys);
return $result['objects'] ?? [];
}
/**
* 그누보드 get_file() 함수 확장
*
* 기존 파일 정보에 R2 URL 추가
*
* @param array $fileInfo 기존 파일 정보
* @param string|null $r2Key R2 객체 키
* @return array
*/
public function extendFileInfo(array $fileInfo, ?string $r2Key): array
{
if (!$this->isEnabled() || empty($r2Key)) {
return $fileInfo;
}
// R2 다운로드 URL로 교체
$downloadUrl = $this->getDownloadUrl($r2Key);
if ($downloadUrl) {
$fileInfo['href'] = $downloadUrl;
$fileInfo['r2_key'] = $r2Key;
$fileInfo['storage_type'] = 'r2';
}
return $fileInfo;
}
/**
* 로컬 파일을 R2로 마이그레이션
*
* @param string $localPath 로컬 파일 경로
* @param string $r2Key R2 객체 키
* @param bool $deleteLocal 로컬 파일 삭제 여부
* @return array
*/
public function migrateLocalFile(string $localPath, string $r2Key, bool $deleteLocal = false): array
{
if (!$this->isEnabled()) {
return ['success' => false, 'error' => 'R2 not enabled'];
}
if (!file_exists($localPath)) {
return ['success' => false, 'error' => 'Local file not found'];
}
$result = $this->adapter->upload($localPath, $r2Key);
if ($result['success'] && $deleteLocal) {
@unlink($localPath);
}
return $result;
}
/**
* 마지막 에러 메시지
*
* @return string|null
*/
public function getLastError(): ?string
{
if ($this->lastError) {
return $this->lastError;
}
if ($this->adapter) {
return $this->adapter->getLastError();
}
return null;
}
/**
* R2 어댑터 직접 접근
*
* @return R2StorageAdapter|null
*/
public function getAdapter(): ?R2StorageAdapter
{
return $this->adapter;
}
/**
* 이미지 파일 여부 확인
*
* @param string $filename
* @return bool
*/
public function isImage(string $filename): bool
{
$imageExt = defined('R2_ALLOWED_IMAGE_EXT')
? explode(',', R2_ALLOWED_IMAGE_EXT)
: ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg'];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, $imageExt);
}
/**
* 허용된 파일 확장자 체크
*
* @param string $filename
* @return bool
*/
public function isAllowedExtension(string $filename): bool
{
$allowedExt = defined('R2_ALLOWED_FILE_EXT')
? explode(',', R2_ALLOWED_FILE_EXT)
: ['jpg', 'jpeg', 'gif', 'png', 'webp', 'svg', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'hwp', 'txt', 'zip', 'rar', '7z'];
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
return in_array($ext, $allowedExt);
}
}

View File

@@ -0,0 +1,526 @@
<?php
/**
* Cloudflare R2 Storage Adapter
*
* AWS SDK를 사용하여 R2 S3 호환 API와 통신하는 어댑터 클래스
*
* @package Gnuboard\R2Storage
*/
namespace Gnuboard\R2Storage;
use Aws\S3\S3Client;
use Aws\S3\MultipartUploader;
use Aws\Exception\AwsException;
use Aws\Exception\MultipartUploadException;
class R2StorageAdapter
{
/** @var S3Client */
private $client;
/** @var string */
private $bucket;
/** @var string */
private $endpoint;
/** @var int */
private $multipartThreshold;
/** @var string|null */
private $publicUrl;
/** @var string|null */
private $lastError;
/**
* R2StorageAdapter 생성자
*
* @param array $config 설정 배열
* - account_id: Cloudflare Account ID
* - access_key_id: R2 Access Key ID
* - secret_access_key: R2 Secret Access Key
* - bucket: 버킷 이름
* - public_url: (선택) Public URL
* - multipart_threshold: (선택) 멀티파트 업로드 임계값 (bytes)
*/
public function __construct(array $config)
{
$this->validateConfig($config);
$this->bucket = $config['bucket'];
$this->endpoint = "https://{$config['account_id']}.r2.cloudflarestorage.com";
$this->publicUrl = $config['public_url'] ?? null;
$this->multipartThreshold = $config['multipart_threshold'] ?? (100 * 1024 * 1024);
$this->client = new S3Client([
'region' => 'auto',
'version' => 'latest',
'endpoint' => $this->endpoint,
'use_path_style_endpoint' => true,
'credentials' => [
'key' => $config['access_key_id'],
'secret' => $config['secret_access_key'],
],
]);
}
/**
* 설정값 검증
*
* @param array $config
* @throws \InvalidArgumentException
*/
private function validateConfig(array $config): void
{
$required = ['account_id', 'access_key_id', 'secret_access_key', 'bucket'];
foreach ($required as $key) {
if (empty($config[$key])) {
throw new \InvalidArgumentException("Missing required config: {$key}");
}
}
}
/**
* 로컬 파일을 R2에 업로드
*
* @param string $localPath 로컬 파일 경로
* @param string $r2Key R2 객체 키 (경로)
* @param array $options 추가 옵션
* - content_type: MIME 타입
* - acl: 접근 제어 (private|public-read)
* - metadata: 메타데이터 배열
* @return array 결과 배열 ['success' => bool, 'key' => string, 'etag' => string, 'url' => string]
*/
public function upload(string $localPath, string $r2Key, array $options = []): array
{
if (!file_exists($localPath)) {
$this->lastError = "File not found: {$localPath}";
return ['success' => false, 'error' => $this->lastError];
}
$fileSize = filesize($localPath);
// 대용량 파일은 멀티파트 업로드 사용
if ($fileSize > $this->multipartThreshold) {
return $this->multipartUpload($localPath, $r2Key, $options);
}
return $this->singleUpload($localPath, $r2Key, $options);
}
/**
* 단일 파일 업로드 (100MB 미만)
*
* @param string $localPath
* @param string $r2Key
* @param array $options
* @return array
*/
private function singleUpload(string $localPath, string $r2Key, array $options = []): array
{
try {
$params = [
'Bucket' => $this->bucket,
'Key' => $r2Key,
'SourceFile' => $localPath,
];
// Content-Type 설정
if (!empty($options['content_type'])) {
$params['ContentType'] = $options['content_type'];
} else {
$params['ContentType'] = $this->getMimeType($localPath);
}
// 메타데이터 추가
if (!empty($options['metadata'])) {
$params['Metadata'] = $options['metadata'];
}
$result = $this->client->putObject($params);
return [
'success' => true,
'key' => $r2Key,
'etag' => trim($result['ETag'], '"'),
'url' => $this->getPublicUrl($r2Key),
];
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return ['success' => false, 'error' => $this->lastError];
}
}
/**
* 멀티파트 업로드 (대용량 파일용)
*
* @param string $localPath
* @param string $r2Key
* @param array $options
* @return array
*/
private function multipartUpload(string $localPath, string $r2Key, array $options = []): array
{
try {
$uploaderOptions = [
'bucket' => $this->bucket,
'key' => $r2Key,
'part_size' => 10 * 1024 * 1024, // 10MB per part
'concurrency' => 5,
];
if (!empty($options['content_type'])) {
$uploaderOptions['params']['ContentType'] = $options['content_type'];
} else {
$uploaderOptions['params']['ContentType'] = $this->getMimeType($localPath);
}
$uploader = new MultipartUploader($this->client, $localPath, $uploaderOptions);
$result = $uploader->upload();
return [
'success' => true,
'key' => $r2Key,
'etag' => trim($result['ETag'], '"'),
'url' => $this->getPublicUrl($r2Key),
];
} catch (MultipartUploadException $e) {
$this->lastError = $e->getMessage();
return ['success' => false, 'error' => $this->lastError];
}
}
/**
* 스트림에서 R2로 업로드
*
* @param resource|string $stream 파일 스트림 또는 내용
* @param string $r2Key R2 객체 키
* @param array $options 추가 옵션
* @return array
*/
public function uploadFromStream($stream, string $r2Key, array $options = []): array
{
try {
$params = [
'Bucket' => $this->bucket,
'Key' => $r2Key,
'Body' => $stream,
];
if (!empty($options['content_type'])) {
$params['ContentType'] = $options['content_type'];
}
if (!empty($options['metadata'])) {
$params['Metadata'] = $options['metadata'];
}
$result = $this->client->putObject($params);
return [
'success' => true,
'key' => $r2Key,
'etag' => trim($result['ETag'], '"'),
'url' => $this->getPublicUrl($r2Key),
];
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return ['success' => false, 'error' => $this->lastError];
}
}
/**
* R2 객체 삭제
*
* @param string $r2Key R2 객체 키
* @return bool
*/
public function delete(string $r2Key): bool
{
try {
$this->client->deleteObject([
'Bucket' => $this->bucket,
'Key' => $r2Key,
]);
return true;
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return false;
}
}
/**
* 여러 객체 일괄 삭제
*
* @param array $r2Keys R2 객체 키 배열
* @return array ['deleted' => [...], 'errors' => [...]]
*/
public function deleteMultiple(array $r2Keys): array
{
if (empty($r2Keys)) {
return ['deleted' => [], 'errors' => []];
}
try {
$objects = array_map(function ($key) {
return ['Key' => $key];
}, $r2Keys);
$result = $this->client->deleteObjects([
'Bucket' => $this->bucket,
'Delete' => [
'Objects' => $objects,
'Quiet' => false,
],
]);
$deleted = [];
$errors = [];
if (!empty($result['Deleted'])) {
$deleted = array_column($result['Deleted'], 'Key');
}
if (!empty($result['Errors'])) {
$errors = $result['Errors'];
}
return ['deleted' => $deleted, 'errors' => $errors];
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return ['deleted' => [], 'errors' => [['message' => $this->lastError]]];
}
}
/**
* Presigned URL 생성 (다운로드용)
*
* @param string $r2Key R2 객체 키
* @param int $expiry 만료 시간 (초) - 기본 1시간
* @param string $method HTTP 메서드 (GET|PUT)
* @return string|null Presigned URL
*/
public function getPresignedUrl(string $r2Key, int $expiry = 3600, string $method = 'GET'): ?string
{
try {
$command = $method === 'PUT'
? $this->client->getCommand('PutObject', [
'Bucket' => $this->bucket,
'Key' => $r2Key,
])
: $this->client->getCommand('GetObject', [
'Bucket' => $this->bucket,
'Key' => $r2Key,
]);
$request = $this->client->createPresignedRequest($command, "+{$expiry} seconds");
return (string) $request->getUri();
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return null;
}
}
/**
* 객체 존재 여부 확인
*
* @param string $r2Key R2 객체 키
* @return bool
*/
public function exists(string $r2Key): bool
{
try {
$this->client->headObject([
'Bucket' => $this->bucket,
'Key' => $r2Key,
]);
return true;
} catch (AwsException $e) {
return false;
}
}
/**
* 객체 메타데이터 조회
*
* @param string $r2Key R2 객체 키
* @return array|null
*/
public function getMetadata(string $r2Key): ?array
{
try {
$result = $this->client->headObject([
'Bucket' => $this->bucket,
'Key' => $r2Key,
]);
return [
'content_type' => $result['ContentType'] ?? null,
'content_length' => $result['ContentLength'] ?? null,
'last_modified' => $result['LastModified'] ?? null,
'etag' => trim($result['ETag'] ?? '', '"'),
'metadata' => $result['Metadata'] ?? [],
];
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return null;
}
}
/**
* 객체 목록 조회
*
* @param string $prefix 경로 접두사
* @param int $maxKeys 최대 개수
* @param string|null $continuationToken 페이지네이션 토큰
* @return array
*/
public function listObjects(string $prefix = '', int $maxKeys = 1000, ?string $continuationToken = null): array
{
try {
$params = [
'Bucket' => $this->bucket,
'MaxKeys' => $maxKeys,
];
if ($prefix) {
$params['Prefix'] = $prefix;
}
if ($continuationToken) {
$params['ContinuationToken'] = $continuationToken;
}
$result = $this->client->listObjectsV2($params);
$objects = [];
if (!empty($result['Contents'])) {
foreach ($result['Contents'] as $object) {
$objects[] = [
'key' => $object['Key'],
'size' => $object['Size'],
'last_modified' => $object['LastModified'],
'etag' => trim($object['ETag'], '"'),
];
}
}
return [
'objects' => $objects,
'is_truncated' => $result['IsTruncated'] ?? false,
'next_token' => $result['NextContinuationToken'] ?? null,
];
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return ['objects' => [], 'is_truncated' => false, 'next_token' => null];
}
}
/**
* 객체 복사
*
* @param string $sourceKey 원본 키
* @param string $destKey 대상 키
* @return bool
*/
public function copy(string $sourceKey, string $destKey): bool
{
try {
$this->client->copyObject([
'Bucket' => $this->bucket,
'CopySource' => "{$this->bucket}/{$sourceKey}",
'Key' => $destKey,
]);
return true;
} catch (AwsException $e) {
$this->lastError = $e->getMessage();
return false;
}
}
/**
* Public URL 반환
*
* @param string $r2Key R2 객체 키
* @return string
*/
public function getPublicUrl(string $r2Key): string
{
if ($this->publicUrl) {
return rtrim($this->publicUrl, '/') . '/' . ltrim($r2Key, '/');
}
// Public URL이 설정되지 않은 경우 endpoint URL 반환
return $this->endpoint . '/' . $this->bucket . '/' . ltrim($r2Key, '/');
}
/**
* 마지막 에러 메시지 반환
*
* @return string|null
*/
public function getLastError(): ?string
{
return $this->lastError;
}
/**
* 버킷 이름 반환
*
* @return string
*/
public function getBucket(): string
{
return $this->bucket;
}
/**
* MIME 타입 추론
*
* @param string $filePath
* @return string
*/
private function getMimeType(string $filePath): string
{
$mimeTypes = [
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'png' => 'image/png',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'pdf' => 'application/pdf',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'hwp' => 'application/x-hwp',
'txt' => 'text/plain',
'zip' => 'application/zip',
'rar' => 'application/x-rar-compressed',
'7z' => 'application/x-7z-compressed',
];
$ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
if (isset($mimeTypes[$ext])) {
return $mimeTypes[$ext];
}
// finfo 사용
if (function_exists('finfo_open') && file_exists($filePath)) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $filePath);
finfo_close($finfo);
if ($mimeType) {
return $mimeType;
}
}
return 'application/octet-stream';
}
}