Files
HeuJung 7b56d5f23a feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정
인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
2026-07-30 07:57:06 +09:00

188 lines
6.1 KiB
PHP

<?php
namespace App\Support;
use Illuminate\Support\Facades\Log;
/**
* 업로드 이미지 축소 (환경설정 > 업로드)
*
* `upload.image_max_width` / `image_max_height` / `image_quality` 는 저장·검증만 되고
* 실제로 이미지를 줄이는 코드가 없었습니다. 관리자는 값을 바꾸면 적용된다고 인지하지만
* 원본이 그대로 저장돼, 설정 화면과 실제 동작이 어긋나 있었습니다.
*
* 단위 규약: config/attachment.image_* 는 픽셀, `image_quality` 는 1~100 입니다.
* MB↔KB 처럼 변환이 필요한 값이 아니므로 `SettingsServiceProvider` 가 그대로 옮깁니다.
*
* 한계값이 없으면(설정 미지정) 아무 것도 하지 않습니다 — 기존 동작을 유지해,
* 이 기능이 도입됐다는 이유만으로 기존 사이트의 이미지가 갑자기 줄어들지 않도록 합니다.
*/
class ImageResizer
{
/**
* 축소를 지원하는 MIME 타입 → GD 로더/세이버 매핑
*/
private const SUPPORTED = [
'image/jpeg' => 'jpeg',
'image/jpg' => 'jpeg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* 기본 품질 (설정 미지정 시)
*/
private const DEFAULT_QUALITY = 85;
/**
* 이미지가 설정 상한을 넘으면 비율을 유지한 채 파일을 제자리에서 축소합니다.
*
* 상한 이내이거나 지원하지 않는 형식이면 파일을 건드리지 않습니다 — 상한 이내인데도
* 다시 인코딩하면 아무 이득 없이 화질만 떨어집니다.
*
* @param string $path 이미지 파일 절대 경로 (제자리 수정)
* @param string|null $mimeType 파일 MIME 타입
* @return bool 실제로 축소했으면 true
*/
public function resizeInPlace(string $path, ?string $mimeType): bool
{
$format = self::SUPPORTED[strtolower((string) $mimeType)] ?? null;
if ($format === null || ! extension_loaded('gd') || ! is_file($path)) {
return false;
}
$maxWidth = $this->limit('image_max_width');
$maxHeight = $this->limit('image_max_height');
if ($maxWidth === null && $maxHeight === null) {
return false;
}
$size = @getimagesize($path);
if ($size === false) {
return false;
}
[$width, $height] = [(int) $size[0], (int) $size[1]];
if ($width <= 0 || $height <= 0) {
return false;
}
$scale = min(
$maxWidth !== null ? $maxWidth / $width : 1.0,
$maxHeight !== null ? $maxHeight / $height : 1.0,
1.0
);
if ($scale >= 1.0) {
return false;
}
// 반올림으로 0 이 되는 극단 비율에서도 최소 1px 은 남긴다
$targetWidth = max(1, (int) round($width * $scale));
$targetHeight = max(1, (int) round($height * $scale));
try {
return $this->write($path, $format, $targetWidth, $targetHeight);
} catch (\Throwable $e) {
// 축소 실패가 업로드 자체를 막지는 않는다 — 원본이 그대로 저장된다
Log::warning('업로드 이미지 축소 실패 (원본을 그대로 저장합니다)', [
'path' => $path,
'mime_type' => $mimeType,
'error' => $e->getMessage(),
]);
return false;
}
}
/**
* 설정된 픽셀 한계값을 반환합니다.
*
* @param string $key 설정 키 (image_max_width / image_max_height)
* @return int|null 한계값 (미설정이면 null)
*/
private function limit(string $key): ?int
{
$value = config("attachment.{$key}");
if ($value === null || $value === '') {
return null;
}
$value = (int) $value;
return $value > 0 ? $value : null;
}
/**
* 축소한 이미지를 원본 경로에 다시 씁니다.
*
* @param string $path 대상 경로
* @param string $format GD 포맷 (jpeg/png/gif/webp)
* @param int $targetWidth 결과 가로
* @param int $targetHeight 결과 세로
* @return bool 성공 여부
*/
private function write(string $path, string $format, int $targetWidth, int $targetHeight): bool
{
$source = match ($format) {
'jpeg' => @imagecreatefromjpeg($path),
'png' => @imagecreatefrompng($path),
'gif' => @imagecreatefromgif($path),
'webp' => @imagecreatefromwebp($path),
};
if (! $source) {
return false;
}
$target = imagecreatetruecolor($targetWidth, $targetHeight);
// PNG·GIF·WebP 의 투명도를 보존한다 (미처리 시 검은 배경으로 채워진다)
if (in_array($format, ['png', 'gif', 'webp'], true)) {
imagealphablending($target, false);
imagesavealpha($target, true);
imagefill($target, 0, 0, imagecolorallocatealpha($target, 0, 0, 0, 127));
}
imagecopyresampled(
$target, $source,
0, 0, 0, 0,
$targetWidth, $targetHeight,
imagesx($source), imagesy($source)
);
$quality = $this->quality();
$written = match ($format) {
'jpeg' => imagejpeg($target, $path, $quality),
// PNG 압축 레벨은 0~9 이고 값이 클수록 더 압축된다 — 품질(1~100)과 방향이 반대다
'png' => imagepng($target, $path, (int) round((100 - $quality) / 100 * 9)),
'gif' => imagegif($target, $path),
'webp' => imagewebp($target, $path, $quality),
};
imagedestroy($source);
imagedestroy($target);
return (bool) $written;
}
/**
* 설정된 이미지 품질을 반환합니다.
*
* @return int 1~100 범위의 품질
*/
private function quality(): int
{
$quality = (int) config('attachment.image_quality', self::DEFAULT_QUALITY);
return max(1, min(100, $quality > 0 ? $quality : self::DEFAULT_QUALITY));
}
}