Files
Gnuboard7/app/Services/AttachmentService.php
T
HeuJung cfc2caba1b feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
공개 (@Tuwasduliebst 제보) 대응.

- ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰,
 filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인,
 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본
 설정 백필 upgrade step
- board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI,
 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정
- ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정
- page: 임시 첨부 GC
- 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시
 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자
 메뉴 설치·업데이트 자동 동기화(F1·F7)
- 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정
 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서,
 hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
2026-08-17 02:44:54 +09:00

553 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Models\Attachment;
use App\Models\User;
use App\Support\ImageResizer;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 첨부파일 서비스
*
* 첨부파일 업로드, 삭제, 다운로드 등의 비즈니스 로직을 처리합니다.
*/
class AttachmentService
{
/**
* AttachmentService 생성자
*
* @param AttachmentRepositoryInterface $repository 첨부파일 리포지토리
* @param StorageInterface $storage 스토리지 드라이버
* @param ImageResizer $imageResizer 업로드 이미지 축소기
*/
public function __construct(
private AttachmentRepositoryInterface $repository,
private StorageInterface $storage,
private ImageResizer $imageResizer
) {}
/**
* 단일 파일 업로드
*
* @param UploadedFile $file 업로드된 파일
* @param string|null $attachmentableType 첨부 대상 타입
* @param int|null $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
* @param AttachmentSourceType $sourceType 소스 타입
* @param string|null $sourceIdentifier 소스 식별자
* @return Attachment 생성된 첨부파일
*/
public function upload(
UploadedFile $file,
?string $attachmentableType = null,
?int $attachmentableId = null,
string $collection = 'default',
AttachmentSourceType $sourceType = AttachmentSourceType::Core,
?string $sourceIdentifier = null,
): Attachment {
// Before 훅
HookManager::doAction('core.attachment.before_upload', $file, $attachmentableType, $attachmentableId);
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
$file = HookManager::applyFilters('core.attachment.filter_upload_file', $file);
// 환경설정 > 업로드의 최대 가로/세로·품질을 실제로 적용한다.
// 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다.
$this->imageResizer->resizeInPlace($file->getRealPath(), $file->getMimeType());
// 저장 경로 생성 (날짜별 디렉토리).
// 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 —
// 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다.
$extension = strtolower($file->extension() ?: $file->getClientOriginalExtension());
$storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : '');
$datePath = date('Y/m/d');
$path = "{$datePath}/{$storedFilename}";
// 스토리지에 파일 저장
$disk = config('attachment.disk');
$this->storage->withDisk($disk)->put('', $path, file_get_contents($file->getRealPath()));
// 현재 컬렉션의 최대 order 조회
if ($attachmentableType && $attachmentableId) {
$maxOrder = $this->repository->getMaxOrder($attachmentableType, $attachmentableId, $collection);
} else {
$maxOrder = $this->repository->getMaxOrderByCollection($collection);
}
// 메타데이터 준비 (이미지인 경우 크기 정보)
$meta = [];
if (str_starts_with($file->getMimeType(), 'image/')) {
$imageSize = @getimagesize($file->getRealPath());
if ($imageSize) {
$meta['width'] = $imageSize[0];
$meta['height'] = $imageSize[1];
}
}
// DB에 저장
$attachment = $this->repository->create([
'attachmentable_type' => $attachmentableType,
'attachmentable_id' => $attachmentableId,
'source_type' => $sourceType,
'source_identifier' => $sourceIdentifier,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $storedFilename,
'disk' => $disk,
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'collection' => $collection,
'order' => $maxOrder + 1,
'meta' => ! empty($meta) ? $meta : null,
'created_by' => Auth::id(),
]);
Log::info('첨부파일 업로드 완료', [
'attachment_id' => $attachment->id,
'hash' => $attachment->hash,
'original_filename' => $attachment->original_filename,
'size' => $attachment->size,
]);
// After 훅
HookManager::doAction('core.attachment.after_upload', $attachment);
return $attachment;
}
/**
* 여러 파일 일괄 업로드
*
* @param array<UploadedFile> $files 업로드할 파일 배열
* @param string|null $attachmentableType 첨부 대상 타입
* @param int|null $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
* @param AttachmentSourceType $sourceType 소스 타입
* @param string|null $sourceIdentifier 소스 식별자
* @return Collection<int, Attachment>
*/
public function uploadBatch(
array $files,
?string $attachmentableType = null,
?int $attachmentableId = null,
string $collection = 'default',
AttachmentSourceType $sourceType = AttachmentSourceType::Core,
?string $sourceIdentifier = null,
): Collection {
$attachments = collect();
foreach ($files as $file) {
$attachment = $this->upload(
$file,
$attachmentableType,
$attachmentableId,
$collection,
$sourceType,
$sourceIdentifier,
);
$attachments->push($attachment);
}
return $attachments;
}
/**
* 소유자 없이 방치된 고아 첨부를 정리합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부(FileUploader 등)는 소유자 없이 먼저 만들어지므로,
* 폼을 저장하지 않고 이탈하면 파일과 기록이 그대로 남습니다.
*
* 오탐을 막기 위해 아래 조건을 모두 만족하는 행만 대상으로 삼습니다.
*
* 1. 소유자(attachmentable_type/id)가 없다
* 2. 업로드 후 보존기간이 지났다 (폼 작성 중 유예)
* 3. 확장 소유가 아니다 (source_identifier 없음 — 확장 라이프사이클 소관)
* 4. 현역으로 쓰이는 첨부가 아니다 (현재 사이트 로고 등)
*
* 4번 보호 조건은 이 메서드가 직접 해석합니다. 호출자가 넘기는 값으로 두면 그 인자를
* 빠뜨린 호출 하나가 운영 중인 로고를 파기하는데, 나머지 세 조건은 조회의 불변식이라
* 보호 조건만 규약으로 남는 비대칭이 생깁니다.
*
* 삭제는 단건 삭제 경로(delete)를 그대로 재사용하므로 파일 삭제·훅·로그가 동일합니다.
*
* @param int $days 보존기간(일)
* @param int $limit 한 회차에 처리할 최대 건수
* @param bool $dryRun true 면 대상만 세고 삭제하지 않음
* @return array{scanned: int, deleted: int, failed: int} 처리 결과
*/
public function pruneOrphans(int $days, int $limit, bool $dryRun = false): array
{
$threshold = now()->subDays($days);
$candidates = $this->repository->findOrphanCandidates(
$threshold,
$limit,
$this->protectedAttachmentIds(),
);
$result = ['scanned' => $candidates->count(), 'deleted' => 0, 'failed' => 0];
if ($dryRun) {
return $result;
}
foreach ($candidates as $candidate) {
$this->delete($candidate->id) ? $result['deleted']++ : $result['failed']++;
}
return $result;
}
/**
* 고아 정리에서 보호할 첨부 ID 목록을 반환합니다.
*
* 사이트 로고는 소유자(attachmentable) 없이 설정값이 직접 참조하는 첨부라 판정식만으로는
* 고아와 구분되지 않습니다. 현역으로 설정에 실린 ID 를 명시적으로 제외합니다.
*
* @return array<int, int> 보호할 첨부 ID 목록
*/
private function protectedAttachmentIds(): array
{
$siteLogo = g7_core_settings('general.site_logo', []);
if (! is_array($siteLogo)) {
return [];
}
$ids = [];
foreach ($siteLogo as $item) {
$id = is_array($item) ? ($item['id'] ?? null) : $item;
if (is_numeric($id)) {
$ids[] = (int) $id;
}
}
return array_values(array_unique($ids));
}
/**
* 첨부파일 삭제 (파일 + 기록 영구 삭제)
*
* 대상 조회는 소프트삭제된 행까지 포함한다. 이 메서드가 수행하는 것은 forceDelete 이므로
* 이미 소프트삭제된 행도 정당한 대상이고, 기본 조회로 찾으면 고아 정리가 그 행을 영원히
* 회수하지 못한 채 실패만 반복한다.
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
$attachment = $this->repository->findByIdWithTrashed($id);
if (! $attachment) {
return false;
}
// 삭제 후 재정렬을 위해 정보 저장
$attachmentableType = $attachment->attachmentable_type;
$attachmentableId = $attachment->attachmentable_id;
$collection = $attachment->collection;
// DB 를 먼저 지우고 파일은 커밋 후에 지운다.
// 순서를 반대로 두면 DB 삭제가 실패했을 때 파일 없는 행이 남아 다운로드가 404 가 된다.
// 반대로 이 순서에서는 최악이어도 참조되지 않는 고아 파일만 남고 데이터는 온전하다.
$result = DB::transaction(function () use ($id, $attachment, $attachmentableType, $attachmentableId, $collection) {
// Before 훅
HookManager::doAction('core.attachment.before_delete', $attachment);
// DB에서 영구 삭제
$deleted = $this->repository->forceDelete($id);
// 삭제 후 남은 파일들의 순서 재정렬
if ($deleted && $attachmentableType && $attachmentableId) {
$this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
}
return $deleted;
});
if ($result) {
// 커밋 후 스토리지 파일 삭제 (실패해도 DB 는 되돌리지 않는다)
try {
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
} catch (\Throwable $e) {
Log::warning('첨부파일 스토리지 삭제 실패 (고아 파일 잔존)', [
'attachment_id' => $id,
'path' => $attachment->path,
'error' => $e->getMessage(),
]);
}
Log::info('첨부파일 삭제 완료', [
'attachment_id' => $id,
'hash' => $attachment->hash,
]);
}
// After 훅
HookManager::doAction('core.attachment.after_delete', $attachment);
return $result;
}
/**
* 순서 변경
*
* @param array<int, array{id: int, order: int}> $orderData 순서 데이터
*/
public function reorder(array $orderData): void
{
$this->assertReorderWithinScope($orderData);
// Before 훅
HookManager::doAction('core.attachment.before_reorder', $orderData);
$this->repository->reorder($orderData);
// After 훅
HookManager::doAction('core.attachment.after_reorder', $orderData);
}
/**
* 순서 변경 대상이 액터의 스코프 안에 있는지 검사합니다.
*
* `PATCH admin/attachments/reorder` 는 라우트 모델이 없는 정적 경로다. PermissionMiddleware
* 는 `$request->route('attachment')` 가 Model 일 때만 스코프를 검사하고 없으면 목록
* 엔드포인트로 보아 건너뛰므로(`PermissionMiddleware`), 상세 경로(`DELETE {attachment}`)가
* 미들웨어로 강제하는 스코프 축이 이 경로에서만 비어 있었다. 배포 기본 역할 `manager` 가
* `core.attachments.update` 를 `self` 스코프로 보유하므로 이론 구성이 아니라 기본값에서
* 성립한다 — 타인 소유 첨부의 순서를 바꿀 수 있었다.
*
* 대상 일부만 걸러내지 않고 **전체를 거부**한다. 순서는 집합 전체에 대한 하나의 배열이라
* 일부만 반영하면 나머지와 어긋난 순서가 저장되기 때문이다(사용자 일괄 상태변경이
* "제외" 를 택한 것과 의미론이 다르다).
*
* @param array<int, array{id: int, order: int}> $orderData 순서 데이터
*
* @throws AuthorizationException 스코프 밖 첨부가 하나라도 포함된 경우
*/
private function assertReorderWithinScope(array $orderData): void
{
$ids = array_values(array_unique(array_filter(
array_map(static fn ($item): int => (int) ($item['id'] ?? 0), $orderData)
)));
if (empty($ids)) {
return;
}
$attachments = $this->repository->findByIds($ids);
// 판정은 상세 경로와 같은 SSoT 에 위임한다 — 여기서 재구현하면 두 경로의 강도가 갈린다.
$permitted = PermissionHelper::filterByScope($attachments, 'core.attachments.update');
if (count($permitted) !== $attachments->count()) {
throw new AuthorizationException(__('auth.scope_denied'));
}
}
/**
* 다운로드 응답 생성
*
* 기능 레벨 권한 체크 (permission_hooks) - 다운로드 기능 자체에 대한 접근 권한
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
* @return StreamedResponse|null 다운로드 응답 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
public function download(string $hash, mixed $user = null): ?StreamedResponse
{
$attachment = $this->repository->findByHash($hash);
if (! $attachment) {
return null;
}
// 기능 레벨 권한 체크 (permission_hooks)
// → 'core.attachment.download' 훅에 권한이 매핑되어 있으면 체크
// → 미매핑 시 모든 사용자 허용
HookManager::checkHookPermission('core.attachment.download', $user);
// 액션 훅 - IDV 정책 가드 지점 (filter 훅과 병행)
HookManager::doAction('core.attachment.before_download_action', $attachment, $user);
// 필터 훅 - 다운로드 전 처리 (다운로드 카운트 증가 등)
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
// 파일 존재 확인
$diskStorage = $this->storage->withDisk($attachment->disk);
if (! $diskStorage->exists('', $attachment->path)) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
]);
return null;
}
// 다운로드 응답 생성 (원본 파일명으로)
return $diskStorage->download('', $attachment->path, $attachment->original_filename);
}
/**
* 이미지 파일 정보 조회 (캐싱 응답용)
*
* 권한 체크 후 인라인 스트림 응답과 캐싱용 메타를 반환합니다.
* 로컬 절대 경로 조립(getBasePath) 방식은 S3 등 원격 디스크 행에서 성립하지
* 않으므로(#99 — filemtime stat 실패 500), 행 disk 를 그대로 따르는
* StorageInterface::response() 스트림으로 서빙합니다. 컨트롤러는
* streamedFileResponse() 로 캐싱 헤더(ETag/304)를 입혀 응답합니다.
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
* @return array{response: StreamedResponse, etag_source: string, mime_type: string, filename: string}|null 서빙 정보 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
public function getFileInfo(string $hash, mixed $user = null): ?array
{
$attachment = $this->repository->findByHash($hash);
if (! $attachment) {
return null;
}
// 기능 레벨 권한 체크 (permission_hooks)
HookManager::checkHookPermission('core.attachment.download', $user);
// 필터 훅 - 다운로드 전 처리
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
// 행 disk 기준 인라인 스트림 (존재 검사는 response() 내부에서 수행)
// Content-Type/Length 를 행 메타로 선지정해 원격 디스크의 추가 메타 조회를 생략한다.
$response = $this->storage->withDisk($attachment->disk)->response(
'',
$attachment->path,
$attachment->original_filename,
[
'Content-Type' => $attachment->mime_type,
'Content-Length' => (string) $attachment->size,
]
);
if (! $response) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
]);
return null;
}
return [
'response' => $response,
// 파일 stat 없이 결정적인 ETag 소스 (업로드 파일은 경로당 불변)
'etag_source' => implode('|', [
$attachment->disk,
$attachment->path,
(string) $attachment->updated_at?->getTimestamp(),
(string) $attachment->size,
]),
'mime_type' => $attachment->mime_type,
'filename' => $attachment->original_filename,
];
}
/**
* 해시로 첨부파일 조회
*
* @param string $hash 첨부파일 해시
* @return Attachment|null 첨부파일 또는 null
*/
public function findByHash(string $hash): ?Attachment
{
return $this->repository->findByHash($hash);
}
/**
* ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findById(int $id): ?Attachment
{
return $this->repository->findById($id);
}
/**
* 첨부 대상의 첨부파일 목록 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string|null $collection 컬렉션명 (null이면 전체)
* @return Collection<int, Attachment>
*/
public function getByAttachmentable(string $type, int $id, ?string $collection = null): Collection
{
return $this->repository->getByAttachmentable($type, $id, $collection);
}
/**
* 특정 소스 식별자의 첨부파일 일괄 삭제
* (모듈/플러그인 제거 시 사용)
*
* @param string $identifier 소스 식별자
* @return int 삭제된 개수
*/
public function deleteBySourceIdentifier(string $identifier): int
{
// Before 훅
HookManager::doAction('core.attachment.before_bulk_delete', $identifier);
// 삭제 대상 스냅샷 + 파일 경로를 먼저 수집한다 (DB 삭제 후에는 조회할 수 없다).
$attachments = $this->repository->getBySourceIdentifier($identifier);
$attachmentIds = $attachments->pluck('id')->toArray();
$snapshots = $attachments->keyBy('id')->map(fn ($a) => $a->toArray())->toArray();
$files = $attachments->map(fn ($a) => ['disk' => $a->disk, 'path' => $a->path])->all();
// DB 일괄 삭제를 먼저 커밋한다 — 루프 중간에 실패하면 파일만 일부 사라지고
// DB 행은 전부 남아 다운로드가 404 가 되기 때문이다.
$count = DB::transaction(fn () => $this->repository->deleteBySourceIdentifier($identifier));
// 커밋 후 파일 정리 (실패해도 DB 는 되돌리지 않는다 — 고아 파일은 비파괴)
foreach ($files as $file) {
try {
$this->storage->withDisk($file['disk'])->delete('', $file['path']);
} catch (\Throwable $e) {
Log::warning('첨부파일 스토리지 일괄 삭제 실패 (고아 파일 잔존)', [
'source_identifier' => $identifier,
'path' => $file['path'],
'error' => $e->getMessage(),
]);
}
}
Log::info('소스 식별자 기준 첨부파일 일괄 삭제', [
'source_identifier' => $identifier,
'deleted_count' => $count,
]);
// After 훅
HookManager::doAction('core.attachment.after_bulk_delete', $identifier, $count, $attachmentIds, $snapshots);
return $count;
}
}