Files
Gnuboard7/app/Services/TemplateLayoutAttachmentService.php
T
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

190 lines
8.1 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Models\TemplateLayoutAttachment;
use App\Support\ImageResizer;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* 템플릿 레이아웃 첨부 파일 서비스
*
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다.
* 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고,
* 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다.
* (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙)
*/
class TemplateLayoutAttachmentService
{
/** 스토리지 카테고리 — {category}/{path} 경로 패턴의 prefix */
private const STORAGE_CATEGORY = 'template-layout-attachments';
/**
* @param TemplateLayoutAttachmentRepositoryInterface $repository 첨부 리포지토리
* @param TemplateRepositoryInterface $templateRepository 템플릿 리포지토리
* @param StorageInterface $storage 코어 스토리지 드라이버
*/
public function __construct(
private TemplateLayoutAttachmentRepositoryInterface $repository,
private TemplateRepositoryInterface $templateRepository,
private StorageInterface $storage,
) {}
/**
* 첨부 파일을 업로드하고 행을 생성합니다.
*
* @param string $templateIdentifier 템플릿 식별자 (vendor-name 형식)
* @param UploadedFile $file 업로드 파일
* @param string|null $layoutName 사용 출처 레이아웃 이름
* @return array{success: bool, attachment: TemplateLayoutAttachment|null, url: string|null, error: string|null}
*/
public function upload(string $templateIdentifier, UploadedFile $file, ?string $layoutName = null): array
{
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $template) {
return ['success' => false, 'attachment' => null, 'url' => null, 'error' => 'template_not_found'];
}
// 저장 경로 — 템플릿 식별자/날짜별 디렉토리 + UUID 파일명 (충돌 회피)
$storedFilename = Str::uuid().'.'.$file->getClientOriginalExtension();
$relativePath = "{$templateIdentifier}/".date('Y/m/d')."/{$storedFilename}";
$disk = config('attachment.disk', 'attachments');
// 환경설정 > 업로드의 최대 가로/세로·품질 적용 (코어 설정이 모든 업로드 경로에 동일 적용).
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기 기록이 모두 축소본을 본다.
app(ImageResizer::class)->resizeInPlace($file->getRealPath(), $file->getMimeType());
$stored = $this->storage
->withDisk($disk)
->put(self::STORAGE_CATEGORY, $relativePath, file_get_contents($file->getRealPath()));
if (! $stored) {
return ['success' => false, 'attachment' => null, 'url' => null, 'error' => 'storage_failed'];
}
$attachment = $this->repository->create([
'template_id' => $template->id,
'layout_name' => $layoutName,
'disk' => $disk,
'path' => $relativePath,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'created_by' => Auth::id(),
]);
Log::info('레이아웃 첨부 파일 업로드 완료', [
'attachment_id' => $attachment->id,
'template_id' => $template->id,
'path' => $relativePath,
]);
return [
'success' => true,
'attachment' => $attachment,
'url' => $this->resolveUrl($attachment),
'error' => null,
];
}
/**
* 템플릿(+선택적 레이아웃)별 첨부 파일 목록을 조회합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @param string|null $layoutName 레이아웃 이름 (null 이면 템플릿 전체)
* @return array{success: bool, attachments: Collection<int, TemplateLayoutAttachment>|null, error: string|null}
*/
public function list(string $templateIdentifier, ?string $layoutName = null): array
{
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
if (! $template) {
return ['success' => false, 'attachments' => null, 'error' => 'template_not_found'];
}
return [
'success' => true,
'attachments' => $this->repository->listForTemplate($template->id, $layoutName),
'error' => null,
];
}
/**
* 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제.
*
* DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다
* (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장).
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
*/
public function delete(TemplateLayoutAttachment $attachment): bool
{
// 1. 스토리지 파일 실삭제 (명시적 — CASCADE 미의존)
$this->storage
->withDisk($attachment->disk)
->delete(self::STORAGE_CATEGORY, $attachment->path);
// 2. DB 행 삭제
return $this->repository->delete($attachment);
}
/**
* 첨부 파일의 공개 접근 URL을 생성합니다.
*
* 첨부 파일은 비공개 `attachments` 디스크에 저장되어 직접 공개 URL 이 없다
* (`StorageInterface::url()` 은 public 디스크 전용). 발행된 배경 이미지는 일반
* 사이트 방문자에게도 로드되어야 하므로, 인증 불필요한 공개 서빙 라우트
* (`PublicTemplateController::serveFile`)의 URL 을 돌려준다. 라우트는 첨부 id 로
* 키되며 서빙 시 첨부가 해당 템플릿 소속인지 검증한다.
*
* @param TemplateLayoutAttachment $attachment 첨부 파일
* @return string 공개 서빙 URL
*/
public function resolveUrl(TemplateLayoutAttachment $attachment): string
{
$template = $attachment->template;
$identifier = $template?->identifier ?? (string) $attachment->template_id;
return route('api.public.templates.layout-attachment-file', [
'identifier' => $identifier,
'attachment' => $attachment->id,
]);
}
/**
* 서빙 가능한 첨부 파일의 절대 경로를 돌려줍니다.
*
* 첨부가 주어진 템플릿 소속인지 검증하고, 스토리지에 파일이 실제로 존재하면
* 컨트롤러가 스트림할 수 있는 절대 파일시스템 경로를 반환한다. 소속 불일치 /
* 파일 부재 시 null (컨트롤러가 404).
*
* @param string $templateIdentifier 요청 경로의 템플릿 식별자
* @param TemplateLayoutAttachment $attachment 서빙 대상 첨부
* @return string|null 절대 파일 경로 또는 null
*/
public function getServableFilePath(string $templateIdentifier, TemplateLayoutAttachment $attachment): ?string
{
$template = $this->templateRepository->findByIdentifier($templateIdentifier);
// 경로 식별자와 첨부의 소속 템플릿이 일치해야 한다 (교차 템플릿 접근 차단).
if (! $template || $attachment->template_id !== $template->id) {
return null;
}
$driver = $this->storage->withDisk($attachment->disk);
if (! $driver->exists(self::STORAGE_CATEGORY, $attachment->path)) {
return null;
}
// 절대 경로 — {category base}/{relative path}. fileResponse 가 절대 경로를 요구.
return rtrim($driver->getBasePath(self::STORAGE_CATEGORY), '/\\').'/'.$attachment->path;
}
}