feat: 에러 처리 개선 및 SVG 지원 추가

- SVG 파일 허용 (스크립트 검증 포함)
- 에러 코드 시스템 도입 (INVALID_FILE_ARRAY, EXT_NOT_ALLOWED 등)
- 모든 에러 메시지 한글화
- SVG 보안 검증 (script, onclick, javascript: 등 차단)
- getLastErrorCode(), getErrorMessage() 메서드 추가

사용자 친화적 에러 메시지:
- "허용되지 않는 파일 형식입니다."
- "파일 크기가 서버 설정 제한을 초과했습니다."
- "SVG 파일에 스크립트가 포함되어 있어 업로드할 수 없습니다."

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-10 15:18:39 +09:00
parent 1b319b60c3
commit 949213beda

View File

@@ -32,6 +32,34 @@ class R2FileHandler
/** @var string|null */
private $lastError;
/** @var string|null 마지막 에러 코드 */
private $lastErrorCode;
/** @var array 에러 메시지 (한글) */
private const ERROR_MESSAGES = [
// 파일 검증 에러
'INVALID_FILE_ARRAY' => '잘못된 파일 형식입니다.',
'FILE_NOT_FOUND' => '파일을 찾을 수 없습니다.',
'EXT_NOT_ALLOWED' => '허용되지 않는 파일 형식입니다. (허용: 이미지, 문서, 동영상, 음악, 압축파일)',
'DOUBLE_EXT_DETECTED' => '보안상 이중 확장자 파일은 업로드할 수 없습니다.',
'MIME_MISMATCH' => '파일 내용이 확장자와 일치하지 않습니다.',
'INVALID_IMAGE' => '손상되었거나 유효하지 않은 이미지 파일입니다.',
'SVG_SCRIPT_DETECTED' => 'SVG 파일에 스크립트가 포함되어 있어 업로드할 수 없습니다.',
// 업로드 에러 (PHP)
'UPLOAD_ERR_INI_SIZE' => '파일 크기가 서버 설정 제한을 초과했습니다.',
'UPLOAD_ERR_FORM_SIZE' => '파일 크기가 허용된 최대 크기를 초과했습니다.',
'UPLOAD_ERR_PARTIAL' => '파일이 일부만 업로드되었습니다. 다시 시도해주세요.',
'UPLOAD_ERR_NO_FILE' => '업로드된 파일이 없습니다.',
'UPLOAD_ERR_NO_TMP_DIR' => '서버 임시 폴더 오류입니다. 관리자에게 문의하세요.',
'UPLOAD_ERR_CANT_WRITE' => '파일 저장에 실패했습니다. 관리자에게 문의하세요.',
'UPLOAD_ERR_EXTENSION' => '서버 보안 정책에 의해 업로드가 차단되었습니다.',
'UPLOAD_ERR_UNKNOWN' => '알 수 없는 업로드 오류입니다.',
// R2 에러
'R2_NOT_ENABLED' => '파일 저장소가 비활성화되어 있습니다.',
'R2_UPLOAD_FAILED' => '파일 업로드에 실패했습니다. 잠시 후 다시 시도해주세요.',
'R2_CONNECTION_ERROR' => '저장소 연결에 실패했습니다. 관리자에게 문의하세요.',
];
/** @var array 허용된 파일 타입 (화이트리스트) */
private $allowedTypes = [
// 이미지
@@ -42,6 +70,7 @@ class R2FileHandler
'webp' => ['image/webp'],
'bmp' => ['image/bmp', 'image/x-ms-bmp'],
'ico' => ['image/x-icon', 'image/vnd.microsoft.icon'],
'svg' => ['image/svg+xml'],
// 비디오
'mp4' => ['video/mp4'],
'webm' => ['video/webm'],
@@ -164,7 +193,7 @@ class R2FileHandler
{
// 필수 키 체크
if (!isset($file['error']) || !isset($file['tmp_name']) || !isset($file['name']) || !isset($file['size'])) {
return ['success' => false, 'error' => 'Invalid file array'];
return $this->errorResponse('INVALID_FILE_ARRAY');
}
// 업로드 에러 체크
@@ -172,9 +201,10 @@ class R2FileHandler
return $this->handleUploadError($file['error']);
}
// MIME 타입 검증 (Content-Type 스푸핑 방지)
if (!$this->validateMimeType($file['tmp_name'], $file['name'])) {
return ['success' => false, 'error' => 'Invalid file type or suspicious content detected'];
// 파일 타입 검증 (화이트리스트)
$validation = $this->validateFileType($file['tmp_name'], $file['name']);
if ($validation !== true) {
return $this->errorResponse($validation);
}
// R2 비활성화 시 로컬 저장 정보 반환
@@ -182,7 +212,7 @@ class R2FileHandler
if ($this->fallbackToLocal) {
return $this->fallbackLocalInfo($file, $boTable, $memberId, $type);
}
return ['success' => false, 'error' => 'R2 storage is not enabled'];
return $this->errorResponse('R2_NOT_ENABLED');
}
// 안전한 파일명 생성
@@ -221,7 +251,27 @@ class R2FileHandler
return $this->fallbackLocalInfo($file, $boTable, $memberId, $type);
}
return ['success' => false, 'error' => $result['error'] ?? 'Upload failed'];
return $this->errorResponse('R2_UPLOAD_FAILED', $result['error'] ?? null);
}
/**
* 에러 응답 생성
*
* @param string $code 에러 코드
* @param string|null $detail 추가 상세 정보
* @return array
*/
private function errorResponse(string $code, ?string $detail = null): array
{
$this->lastErrorCode = $code;
$this->lastError = self::ERROR_MESSAGES[$code] ?? '알 수 없는 오류가 발생했습니다.';
return [
'success' => false,
'error' => $this->lastError,
'error_code' => $code,
'error_detail' => $detail,
];
}
/**
@@ -232,20 +282,18 @@ class R2FileHandler
*/
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',
$codeMap = [
UPLOAD_ERR_INI_SIZE => 'UPLOAD_ERR_INI_SIZE',
UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE',
UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL',
UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR',
UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE',
UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION',
];
return [
'success' => false,
'error' => $messages[$errorCode] ?? 'Unknown upload error',
];
$code = $codeMap[$errorCode] ?? 'UPLOAD_ERR_UNKNOWN';
return $this->errorResponse($code);
}
/**
@@ -495,6 +543,27 @@ class R2FileHandler
return null;
}
/**
* 마지막 에러 코드
*
* @return string|null
*/
public function getLastErrorCode(): ?string
{
return $this->lastErrorCode;
}
/**
* 에러 코드로 메시지 조회
*
* @param string $code 에러 코드
* @return string
*/
public static function getErrorMessage(string $code): string
{
return self::ERROR_MESSAGES[$code] ?? '알 수 없는 오류가 발생했습니다.';
}
/**
* R2 어댑터 직접 접근
*
@@ -510,12 +579,12 @@ class R2FileHandler
*
* @param string $filePath 임시 파일 경로
* @param string $originalName 원본 파일명
* @return bool
* @return true|string true면 성공, 문자열이면 에러 코드
*/
private function validateMimeType(string $filePath, string $originalName): bool
private function validateFileType(string $filePath, string $originalName)
{
if (!file_exists($filePath)) {
return false;
return 'FILE_NOT_FOUND';
}
// 확장자 추출
@@ -526,39 +595,45 @@ class R2FileHandler
if (strpos($basename, '.') !== false) {
$innerExt = strtolower(pathinfo($basename, PATHINFO_EXTENSION));
if (!isset($this->allowedTypes[$innerExt])) {
// 내부 확장자가 허용 목록에 없으면 의심스러운 파일
return false;
return 'DOUBLE_EXT_DETECTED';
}
}
// 허용된 확장자인지 확인
if (!isset($this->allowedTypes[$ext])) {
return false;
return 'EXT_NOT_ALLOWED';
}
$allowedMimes = $this->allowedTypes[$ext];
// finfo로 실제 MIME 타입 확인
if (!function_exists('finfo_open')) {
return true; // finfo 없으면 확장자만으로 허용
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $filePath);
finfo_close($finfo);
// 실제 MIME과 허용된 MIME 비교
if (!in_array($realMime, $allowedMimes)) {
// application/octet-stream은 일부 파일에서 발생 (hwp 등)
if (!($realMime === 'application/octet-stream' && in_array('application/octet-stream', $allowedMimes))) {
return 'MIME_MISMATCH';
}
}
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $filePath);
finfo_close($finfo);
// 실제 MIME과 허용된 MIME 비교
if (!in_array($realMime, $allowedMimes)) {
// application/octet-stream은 일부 파일에서 발생 (hwp 등)
if ($realMime === 'application/octet-stream' && in_array('application/octet-stream', $allowedMimes)) {
return true;
// SVG는 스크립트 검사
if ($ext === 'svg') {
if (!$this->validateSvg($filePath)) {
return 'SVG_SCRIPT_DETECTED';
}
return false;
return true;
}
// 이미지는 추가 검증 (getimagesize)
if ($this->isImageExtension($ext)) {
return $this->validateImage($filePath);
if (!$this->validateImage($filePath)) {
return 'INVALID_IMAGE';
}
}
return true;
@@ -597,6 +672,39 @@ class R2FileHandler
return in_array($imageInfo[2], $validTypes);
}
/**
* SVG 파일 스크립트 검증
*
* @param string $filePath
* @return bool true면 안전, false면 스크립트 발견
*/
private function validateSvg(string $filePath): bool
{
$content = file_get_contents($filePath);
if ($content === false) {
return false;
}
// 위험한 패턴 검사
$dangerousPatterns = [
'/<script\b/i', // <script> 태그
'/\bon\w+\s*=/i', // onclick, onload 등 이벤트 핸들러
'/javascript\s*:/i', // javascript: URL
'/data\s*:\s*text\/html/i', // data:text/html
'/<foreignObject/i', // foreignObject (HTML 삽입 가능)
'/xlink:href\s*=\s*["\']?\s*javascript/i', // xlink:href="javascript:..."
'/href\s*=\s*["\']?\s*javascript/i', // href="javascript:..."
];
foreach ($dangerousPatterns as $pattern) {
if (preg_match($pattern, $content)) {
return false;
}
}
return true;
}
/**
* 이미지 파일 여부 확인
*