업로드의 최대 가로/세로·품질을 실제로 적용한다. // 임시 파일을 제자리에서 줄이므로 아래의 저장·크기·메타 계산이 모두 축소본을 본다. $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 $files 업로드할 파일 배열 * @param string|null $attachmentableType 첨부 대상 타입 * @param int|null $attachmentableId 첨부 대상 ID * @param string $collection 컬렉션명 * @param AttachmentSourceType $sourceType 소스 타입 * @param string|null $sourceIdentifier 소스 식별자 * @return Collection */ 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; } /** * 첨부파일 삭제 * * @param int $id 첨부파일 ID * @return bool 삭제 성공 여부 */ public function delete(int $id): bool { $attachment = $this->repository->findById($id); if (! $attachment) { return false; } // 삭제 후 재정렬을 위해 정보 저장 $attachmentableType = $attachment->attachmentable_type; $attachmentableId = $attachment->attachmentable_id; $collection = $attachment->collection; // Before 훅 HookManager::doAction('core.attachment.before_delete', $attachment); // 스토리지에서 파일 삭제 $this->storage->withDisk($attachment->disk)->delete('', $attachment->path); // DB에서 영구 삭제 $result = $this->repository->forceDelete($id); Log::info('첨부파일 삭제 완료', [ 'attachment_id' => $id, 'hash' => $attachment->hash, ]); // 삭제 후 남은 파일들의 순서 재정렬 if ($result && $attachmentableType && $attachmentableId) { $this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection); } // After 훅 HookManager::doAction('core.attachment.after_delete', $attachment); return $result; } /** * 순서 변경 * * @param array $orderData 순서 데이터 */ public function reorder(array $orderData): void { // Before 훅 HookManager::doAction('core.attachment.before_reorder', $orderData); $this->repository->reorder($orderData); // After 훅 HookManager::doAction('core.attachment.after_reorder', $orderData); } /** * 다운로드 응답 생성 * * 기능 레벨 권한 체크 (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 */ 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); // 해당 첨부파일들의 스토리지 파일 삭제 $attachments = $this->repository->getBySourceIdentifier($identifier); $attachmentIds = $attachments->pluck('id')->toArray(); $snapshots = $attachments->keyBy('id')->map(fn ($a) => $a->toArray())->toArray(); foreach ($attachments as $attachment) { $this->storage->withDisk($attachment->disk)->delete('', $attachment->path); } // DB에서 삭제 $count = $this->repository->deleteBySourceIdentifier($identifier); Log::info('소스 식별자 기준 첨부파일 일괄 삭제', [ 'source_identifier' => $identifier, 'deleted_count' => $count, ]); // After 훅 HookManager::doAction('core.attachment.after_bulk_delete', $identifier, $count, $attachmentIds, $snapshots); return $count; } }