From 4a058e96265c677f0e1f13a096d1293b5163964a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= <73276447+i960107@users.noreply.github.com> Date: Fri, 23 Aug 2024 15:59:06 +0900 Subject: [PATCH] =?UTF-8?q?[BE]=20=ED=8C=8C=EC=9D=BC/=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=EC=9C=A0=ED=95=9C=EB=8B=A4.=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: MethodArgumentNotValidException Handler 수정 - HandlerMethodValidationException 으로 핸들링 하고 있던 것 변경 * refactor: NO_PERMISSION을 ACCESS_DENIED로 변경 - 중복된 에러 코드 병합 * feat: 공유 링크 생성 기능 추가 - SharedLink Entity 수정 및 Unique 인덱스 추가 - 공유 링크 생성 시 profile 마다 다른 도메인 정보 활용 - Unique한 공유 링크 생성 실패 시 CONFLICT 응답 * feat: 공유 링크 권한 기능 추가 - 공유 링크 생성 시 쓰기 권한, 읽기 권한 설정 * refactor: 공유 링크 엔티티 생성 리팩토링 - SharedLinkFactory 클래스로 엔티티 생성 * refactor: 코드 리뷰 반영 - @ResponseStatus 추가 및 불필요한 공백 제거 * feat: SharedLink 엔티티에 sharedId 필드 추가 - 기존 url 전체를 저장하고 인덱싱해두어서 shareId만으로 조회가 어려움 - sharedId 필드 추가후 팩토리 메서드 및 조회 쿼리 메소드 추가 * fix: shared_uri 쿼리 스트링 key 수정 - 엔티티 이름과 통일하기 위해 shareId -> sharedId로 수정 - 공유 링크 만료 시간 초 기준으로 수정(기존 시간으로 표현하였는데 사용하는 쪽에서 time unit알기 어렵기 때문에 session timeout처럼 초로 표현) * feat: Folder/FileMetadata에 공유 관련 필드 추가 - 공유 여부 및 공유 만료 시간 필드 추가 - 공유되지 않았다면 LocalDateTime.MIn으로 추가. CommonConstant에 상수 추가 * feat: 폴더/파일 메타데이터에 공유 권한 추가 - PermissionType 필드 추가 * refactor: 변수명 컨벤션에 맞도록 수정 - @value로 주입받는 값이 상수(static final)이 아니기 때문에 대문자 스네이크 케이스 변수명 대신 카멜 케이스 사용 * rename: PermissionType 폴더 이동 - 기존 공유 링크 패키지에서만 사용하던 PermissionType을 엔티티 변경에 따라 폴더, 파일에서도 사용하므로 폴더 이동 * fix: mysql timestamp에서 지원하는 최소 값으로 수정 - 공유되지 않은 폴더 및 파일의 sharingExpiredAt, 폴더 조회시 첫 페이지 조건에 지정할 값 - timestamp에서는 LocalDate.Min() 지원되지 않으므로 지원되는 가장 작은 값인 '1970-1-1'로 지정 * feat: 폴더 조회시 조건 기본 값 넣어주도록 수정 - 공유 링크는 폴더 조회 링크로 리다이렉트해주기 때문에 기본값 필요. * feat: 상대경로로 서버 host 정보 포함한 절대 경로 생성해주는 UrlUtil 추가 - 공유 링크의 redirect url생성 및 폴더 생성 후 location header에서 사용 * feat: SharedLink 엔티티 수정 및 인덱스 추가 - 사용하지 않는 token필드 제거 - shareLinkUrl 대신 redirect Url(공유 링크 접속 시 리다이렉트 해줄 url) - 이미 생성된 링크 있는지 조회시에 is_file, target_id 조건으로 조회시 속도 높이기 위해 인덱스 추가 * feat: 공유 링크 생성시 비동기로 폴더 및 파일의 공유 상태 수정 기능 추가 - file entity에 updateShareStatus() 편의 메소드 추가 - application event사용해서 비동기로 처리. 새로운 트랜잭션에서 상태 업데이트 * feat: 기존에 생성된 공유 링크 있다면 반환 기능 추가 - 새로 생성하고 상태 update하지 않고 바로 기존 링크 반환해서 응답 속도 높임. * feat: 공유 취소 서비스 메서드 추가 - 현재 api 없이 서비스 레이어에만 만들어둠. 추후 사용 예정 * feat: 공유 링크로 접속시 해당 폴더나 파일 조회로 리다이렉트 기능 추가 - query string으로 userid추가한 url 생성 - 302 FOUND 응답 * feat: 공유 취소 기능 추가 - application event를 사용해서 비동기로 하위 폴더 및 파일의 공유 상태 수정 * feat: 폴더/파일 생성시 부모 폴더의 권한 상속 기능 추가 - Factory에서 부모 폴더 엔티티 매개 변수로 받아서 정보 추가 - 부모 폴더 생성자가 폴더 및 파일의 owner가 됨. creator는 요청한 유저 * feat: SharedLink 엔티티에 sharedId 필드 추가 - 기존 url 전체를 저장하고 인덱싱해두어서 shareId만으로 조회가 어려움 - sharedId 필드 추가후 팩토리 메서드 및 조회 쿼리 메소드 추가 * fix: shared_uri 쿼리 스트링 key 수정 - 엔티티 이름과 통일하기 위해 shareId -> sharedId로 수정 - 공유 링크 만료 시간 초 기준으로 수정(기존 시간으로 표현하였는데 사용하는 쪽에서 time unit알기 어렵기 때문에 session timeout처럼 초로 표현) * feat: Folder/FileMetadata에 공유 관련 필드 추가 - 공유 여부 및 공유 만료 시간 필드 추가 - 공유되지 않았다면 LocalDateTime.MIn으로 추가. CommonConstant에 상수 추가 * feat: 폴더/파일 메타데이터에 공유 권한 추가 - PermissionType 필드 추가 * refactor: 변수명 컨벤션에 맞도록 수정 - @value로 주입받는 값이 상수(static final)이 아니기 때문에 대문자 스네이크 케이스 변수명 대신 카멜 케이스 사용 * rename: PermissionType 폴더 이동 - 기존 공유 링크 패키지에서만 사용하던 PermissionType을 엔티티 변경에 따라 폴더, 파일에서도 사용하므로 폴더 이동 * fix: mysql timestamp에서 지원하는 최소 값으로 수정 - 공유되지 않은 폴더 및 파일의 sharingExpiredAt, 폴더 조회시 첫 페이지 조건에 지정할 값 - timestamp에서는 LocalDate.Min() 지원되지 않으므로 지원되는 가장 작은 값인 '1970-1-1'로 지정 * feat: 폴더 조회시 조건 기본 값 넣어주도록 수정 - 공유 링크는 폴더 조회 링크로 리다이렉트해주기 때문에 기본값 필요. * feat: 상대경로로 서버 host 정보 포함한 절대 경로 생성해주는 UrlUtil 추가 - 공유 링크의 redirect url생성 및 폴더 생성 후 location header에서 사용 * feat: SharedLink 엔티티 수정 및 인덱스 추가 - 사용하지 않는 token필드 제거 - shareLinkUrl 대신 redirect Url(공유 링크 접속 시 리다이렉트 해줄 url) - 이미 생성된 링크 있는지 조회시에 is_file, target_id 조건으로 조회시 속도 높이기 위해 인덱스 추가 * feat: 공유 링크 생성시 비동기로 폴더 및 파일의 공유 상태 수정 기능 추가 - file entity에 updateShareStatus() 편의 메소드 추가 - application event사용해서 비동기로 처리. 새로운 트랜잭션에서 상태 업데이트 * feat: 기존에 생성된 공유 링크 있다면 반환 기능 추가 - 새로 생성하고 상태 update하지 않고 바로 기존 링크 반환해서 응답 속도 높임. * feat: 공유 취소 서비스 메서드 추가 - 현재 api 없이 서비스 레이어에만 만들어둠. 추후 사용 예정 * feat: 공유 링크로 접속시 해당 폴더나 파일 조회로 리다이렉트 기능 추가 - query string으로 userid추가한 url 생성 - 302 FOUND 응답 * feat: 공유 취소 기능 추가 - application event를 사용해서 비동기로 하위 폴더 및 파일의 공유 상태 수정 * feat: 폴더/파일 생성시 부모 폴더의 권한 상속 기능 추가 - Factory에서 부모 폴더 엔티티 매개 변수로 받아서 정보 추가 - 부모 폴더 생성자가 폴더 및 파일의 owner가 됨. creator는 요청한 유저 * feat: 권한 검증에서 사용할 어노테이션 추가 - 권한 인증을 위해 사용하는 필드를 확인하기 위해 CheckField 어노테이션 추가 - DTO로 요청 데이터를 처리하기 위해 CheckDto 어노테이션을 추가 - DTO 내부의 필드는 CheckField 어노테이션을 사용하여 권한에 사용되는 필드 구분 * feat: 권한이 필요한 컨트롤러 구분을 위한 RequestType 어노테이션 추가 - 읽기, 쓰기 중 어떤 권한이 필요한지 명시하여 그에 맞게 권한을 처리 - File에 대한 작업인지, Folder에 대한 작업인지 명시하여 다른 권한 부여 로직 사용 * feat: AOP 사용 시 컨트롤러의 파라미터를 저장할 DTO 추가 * feat: 권한을 검증하는 Handler 추가 - 파일 타입, 권한 타입, 파라미터 정보를 바탕으로 권한 검증 - 만료 기간, 쓰기나 읽기 권한 등을 비교하여 검증 진행 - 파일 이동의 경우 이동할 폴더의 권한까지 함께 확인 * test: 권한을 검증하는 PermissionHandler 테스트 코드 추가 * feat: AOP에 사용할 필드 타입 추가 * feat: 권한 검증에 사용하는 에러 코드 추가 * feat: shared lock으로 파일을 조회하는 메소드 추가 - 권한 검증 중 변경되거나 삭제되면 안되니 읽기는 가능한 shared lock으로 조회 * feat: 파일 타입 enum 추가 - 권한 검증에 파일인지 폴더인지 구분하기 위해 사용 * feat: shared lock으로 폴더를 조회하는 메소드 추가 - 권한 검증 시 폴더의 상태가 변경되면 안되므로 읽기가 가능한 shared lock 사용 * feat: 권한 검증 AOP 추가 - 컨트롤러의 작업 수행 전에 파일과 폴더에 대한 검증을 진행 - 권한 검증이 완료되면 userId와 ownerId에 대한 값을 변경하여 파라미터에 적용 - 공유된 파일의 경우, 타 사용자의 userId를 ownerId로 변경 - 생성한 사용자를 구분하기 위해 creatorId에 타 사용자의 id를 추가 * feat: 권한 검증이 필요한 컨트롤러에 AOP 적용 - RequestType, CheckDto, CheckField 어노테이션 추가 * feat: 권한 검증이 필요한 DTO에 어노테이션 추가 - CheckField 어노테이션 추가 * feat: creatorId 필드 추가 - 파일 및 폴더를 추가한 사용자와 그에 대한 권한을 가진 사용자 구분하기 위해 추가 * feat: 파일 업로드 기능에 권한 검증 로직 추가 - 파싱을 하기 때문에 PermissionHandler를 별도로 호출하여 검증 진행 * feat: creatorId, ownerId를 구분하여 저장 - 공유 기능으로 파일 소유자와 생성자를 별도로 구분하여 저장 * feat: 동기로 하위 폴더/파일의 공유 상태 수정 - 비동기로 진행하던 것을 동기로 변경 - 공유 상태 업데이트 실패시 실패 응답 내려주기 위해서 * feat: string.format 대신 문자열 더하기 연산 사용 - string.format은 메모리 사용률 늘어나기 때문에 수정 * feat: 재귀 쿼리 수정 - CTE 쿼리 사용하던 것에서 어플리케이션에서 dfs 수행하는 쿼리로 수정 * refactor: SharedLinkService에서 다른 서비스에 의존하지 않도록 수정 순환 참조 방지 * refactor: dfs시 레코드에 lock걸로 batch update - select -> select for update로 수정 - id 모아서 한번에 batch update수정 * fix: 메서드 명 수정 * refactor: 코드 리뷰 반영 - switch case문 변경 - 불필요한 출력문 제거 --------- Co-authored-by: seungh1024 --- .../file/controller/FileController.java | 14 +- .../controller/MultipartFileController.java | 50 +++- .../storage/domain/file/dto/FileMoveDto.java | 7 +- .../domain/file/dto/FormMetadataDto.java | 15 + .../domain/file/entity/FileMetadata.java | 43 ++- .../file/entity/FileMetadataFactory.java | 7 +- .../file/repository/FileCustomRepository.java | 4 + .../repository/FileCustomRepositoryImpl.java | 11 + .../repository/FileMetadataRepository.java | 7 + .../domain/file/service/FileService.java | 3 +- .../domain/file/service/S3FileService.java | 19 +- .../folder/controller/FolderController.java | 27 +- .../dto/GetFolderContentsRequestParams.java | 25 +- .../dto/request/CreateFolderReqDto.java | 10 +- .../folder/dto/request/FolderMoveDto.java | 7 +- .../domain/folder/entity/FolderMetadata.java | 35 ++- .../folder/entity/FolderMetadataFactory.java | 19 +- .../repository/FolderCustomRepository.java | 6 +- .../FolderCustomRepositoryImpl.java | 10 + .../repository/FolderMetadataRepository.java | 7 + .../domain/folder/service/FolderService.java | 14 +- .../controller/SharedLinkController.java | 25 +- .../request/CancelSharedLinkRequestDto.java | 10 + .../dto/request/MakeSharedLinkRequestDto.java | 3 +- .../domain/shredlink/entity/SharedLink.java | 32 ++- .../shredlink/entity/SharedLinkFactory.java | 11 +- .../repository/SharedLinkRepository.java | 8 + .../shredlink/service/SharedLinkService.java | 212 ++++++++++++-- .../domain/user/service/UserService.java | 3 +- .../storage/global/annotation/CheckDto.java | 11 + .../storage/global/annotation/CheckField.java | 17 ++ .../global/annotation/RequestType.java | 20 ++ .../global/aop/PermissionCheckAspect.java | 235 ++++++++++++++++ .../global/aop/PermissionFieldsDto.java | 20 ++ .../storage/global/aop/PermissionHandler.java | 185 +++++++++++++ .../storage/global/aop/type/FieldType.java | 13 + .../storage/global/aop/type/FileType.java | 5 + .../global/constant/CommonConstant.java | 9 +- .../constant}/PermissionType.java | 4 +- .../storage/global/error/ErrorCode.java | 7 +- .../storage/global/util/UrlUtil.java | 23 ++ .../global/aop/PermissionHandlerTest.java | 260 ++++++++++++++++++ 42 files changed, 1320 insertions(+), 133 deletions(-) create mode 100644 src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/CancelSharedLinkRequestDto.java create mode 100644 src/main/java/com/woowacamp/storage/global/annotation/CheckDto.java create mode 100644 src/main/java/com/woowacamp/storage/global/annotation/CheckField.java create mode 100644 src/main/java/com/woowacamp/storage/global/annotation/RequestType.java create mode 100644 src/main/java/com/woowacamp/storage/global/aop/PermissionCheckAspect.java create mode 100644 src/main/java/com/woowacamp/storage/global/aop/PermissionFieldsDto.java create mode 100644 src/main/java/com/woowacamp/storage/global/aop/PermissionHandler.java create mode 100644 src/main/java/com/woowacamp/storage/global/aop/type/FieldType.java create mode 100644 src/main/java/com/woowacamp/storage/global/aop/type/FileType.java rename src/main/java/com/woowacamp/storage/{domain/shredlink/entity => global/constant}/PermissionType.java (83%) create mode 100644 src/main/java/com/woowacamp/storage/global/util/UrlUtil.java create mode 100644 src/test/java/com/woowacamp/storage/global/aop/PermissionHandlerTest.java diff --git a/src/main/java/com/woowacamp/storage/domain/file/controller/FileController.java b/src/main/java/com/woowacamp/storage/domain/file/controller/FileController.java index 7e85bef..5e0700e 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/controller/FileController.java +++ b/src/main/java/com/woowacamp/storage/domain/file/controller/FileController.java @@ -13,6 +13,12 @@ import com.woowacamp.storage.domain.file.dto.FileMoveDto; import com.woowacamp.storage.domain.file.service.FileService; import com.woowacamp.storage.domain.folder.service.FolderService; +import com.woowacamp.storage.global.annotation.CheckDto; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.annotation.RequestType; +import com.woowacamp.storage.global.aop.type.FieldType; +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; import lombok.RequiredArgsConstructor; @@ -24,15 +30,19 @@ public class FileController { private final FileService fileService; private final FolderService folderService; + @RequestType(permission = PermissionType.WRITE, fileType = FileType.FILE) @PatchMapping("/{fileId}") - public void moveFile(@PathVariable Long fileId, @RequestBody FileMoveDto dto) { + public void moveFile(@CheckField(FieldType.FILE_ID) @PathVariable Long fileId, + @CheckDto @RequestBody FileMoveDto dto) { fileService.getFileMetadataBy(fileId, dto.userId()); fileService.moveFile(fileId, dto); } + @RequestType(permission = PermissionType.WRITE, fileType = FileType.FILE) @DeleteMapping("/{fileId}") @ResponseStatus(HttpStatus.OK) - public void delete(@PathVariable Long fileId, @RequestParam Long userId) { + public void delete(@CheckField(FieldType.FILE_ID) @PathVariable Long fileId, + @CheckField(FieldType.USER_ID) @RequestParam Long userId) { fileService.deleteFile(fileId, userId); } } diff --git a/src/main/java/com/woowacamp/storage/domain/file/controller/MultipartFileController.java b/src/main/java/com/woowacamp/storage/domain/file/controller/MultipartFileController.java index 88bc973..ab58486 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/controller/MultipartFileController.java +++ b/src/main/java/com/woowacamp/storage/domain/file/controller/MultipartFileController.java @@ -36,6 +36,13 @@ import com.woowacamp.storage.domain.file.service.FileService; import com.woowacamp.storage.domain.file.service.FileWriterThreadPool; import com.woowacamp.storage.domain.file.service.S3FileService; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.annotation.RequestType; +import com.woowacamp.storage.global.aop.PermissionFieldsDto; +import com.woowacamp.storage.global.aop.PermissionHandler; +import com.woowacamp.storage.global.aop.type.FieldType; +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; import com.woowacamp.storage.global.error.ErrorCode; import jakarta.servlet.http.HttpServletRequest; @@ -55,15 +62,16 @@ public class MultipartFileController { private final FileWriterThreadPool fileWriterThreadPool; private final FileMetadataRepository fileMetadataRepository; private final FileService fileService; + private final PermissionHandler permissionHandler; @Value("${cloud.aws.credentials.bucketName}") - private String BUCKET_NAME; + private String bucketName; @Value("${file.reader.bufferSize}") - private int BUFFER_SIZE; + private int bufferSize; @Value("${file.reader.lineBufferMaxSize}") - private int LINE_BUFFER_MAX_SIZE; + private int lineBufferMaxSize; @Value("${file.reader.chunkSize}") - private int S3_CHUNK_SIZE; + private int s3ChunkSize; /** * MultipartFile은 임시 저장을 해서 직접 request를 통해 multipart/form-data를 파싱했습니다. @@ -86,7 +94,7 @@ public void handleFileUpload(HttpServletRequest request) throws Exception { * processBuffer 메소드에서 헤더 파싱을 하고 boundary 체크를 하여 각 파트를 구분합니다. */ private void processMultipartData(InputStream inputStream, UploadContext context) throws Exception { - byte[] buffer = new byte[BUFFER_SIZE]; + byte[] buffer = new byte[bufferSize]; ByteArrayOutputStream lineBuffer = new ByteArrayOutputStream(); ByteArrayOutputStream contentBuffer = new ByteArrayOutputStream(); PartContext partContext = new PartContext(); @@ -148,8 +156,21 @@ private boolean processBuffer(byte[] buffer, int bytesRead, ByteArrayOutputStrea // boundary 읽은 이후 processHeader(line, partContext); if (!partContext.isInHeader() && partContext.getCurrentFileName() != null) { - FileMetadataDto fileMetadataDto = s3FileService.createInitialMetadata( - FormMetadataDto.of(context.getFormFields()), partContext); + FormMetadataDto formMetadataDto = FormMetadataDto.of(context.getFormFields()); + // PermissionHandler로 접근 권한을 확인한다. + PermissionFieldsDto permissionFieldsDto = new PermissionFieldsDto(); + long userId = formMetadataDto.getUserId(); + long parentFolderId = formMetadataDto.getParentFolderId(); + permissionFieldsDto.setUserId(userId); + permissionFieldsDto.setFolderId(parentFolderId); + // 파일 쓰기는 현재 파일이 존재하지 않으므로 폴더에 대한 권한을 검증하고 통과하면 ownerId를 받아온다. + long ownerId = permissionHandler.getOwnerIdAndCheckPermission( + PermissionType.WRITE, FileType.FOLDER, + permissionFieldsDto); + formMetadataDto.setUserId(ownerId); + formMetadataDto.setCreatorId(userId); + + FileMetadataDto fileMetadataDto = s3FileService.createInitialMetadata(formMetadataDto, partContext); context.updateFileMetadata(fileMetadataDto); context.updateIsFileRead(); partContext.setUploadFileName(fileMetadataDto.uuid()); @@ -220,7 +241,7 @@ private InitiateMultipartUploadResult initializeFileUpload(String fileName, Stri fileWriterThreadPool.initializePartCount(fileName); ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(contentType); - InitiateMultipartUploadRequest initRequest = new InitiateMultipartUploadRequest(BUCKET_NAME, + InitiateMultipartUploadRequest initRequest = new InitiateMultipartUploadRequest(bucketName, fileName).withObjectMetadata(metadata); return amazonS3.initiateMultipartUpload(initRequest); @@ -232,7 +253,7 @@ private InitiateMultipartUploadResult initializeFileUpload(String fileName, Stri private void processContent(ByteArrayOutputStream contentBuffer, ByteArrayOutputStream lineBuffer, PartContext partContext, UploadState state) throws Exception { contentBuffer.write(lineBuffer.toByteArray()); - if (partContext.getCurrentFileName() != null && contentBuffer.size() >= S3_CHUNK_SIZE) { + if (partContext.getCurrentFileName() != null && contentBuffer.size() >= s3ChunkSize) { uploadChunk(contentBuffer, state, partContext); } } @@ -257,11 +278,11 @@ private void uploadChunk(ByteArrayOutputStream contentBuffer, UploadState state, */ private void checkLineBufferSize(ByteArrayOutputStream lineBuffer, ByteArrayOutputStream contentBuffer, PartContext partContext, UploadState state) throws Exception { - if (lineBuffer.size() >= LINE_BUFFER_MAX_SIZE) { + if (lineBuffer.size() >= lineBufferMaxSize) { contentBuffer.write(lineBuffer.toByteArray()); lineBuffer.reset(); - if (contentBuffer.size() >= S3_CHUNK_SIZE) { + if (contentBuffer.size() >= s3ChunkSize) { uploadChunk(contentBuffer, state, partContext); } } @@ -321,13 +342,14 @@ private String extractAttribute(String source, String attribute) { return null; } + @RequestType(permission = PermissionType.READ, fileType = FileType.FILE) @GetMapping("/download/{fileId}") @Validated - ResponseEntity download(@PathVariable Long fileId, - @Positive(message = "올바른 입력값이 아닙니다.") @RequestParam("userId") Long userId) { + ResponseEntity download(@CheckField(FieldType.FILE_ID) @PathVariable Long fileId, + @CheckField(FieldType.USER_ID) @Positive(message = "올바른 입력값이 아닙니다.") @RequestParam("userId") Long userId) { FileMetadata fileMetadata = fileService.getFileMetadataBy(fileId, userId); - FileDataDto fileDataDto = s3FileService.downloadByS3(fileId, BUCKET_NAME, fileMetadata.getUuidFileName()); + FileDataDto fileDataDto = s3FileService.downloadByS3(fileId, bucketName, fileMetadata.getUuidFileName()); HttpHeaders headers = new HttpHeaders(); // HTTP 응답 헤더에 Content-Type 설정 String fileType = fileMetadata.getFileType(); diff --git a/src/main/java/com/woowacamp/storage/domain/file/dto/FileMoveDto.java b/src/main/java/com/woowacamp/storage/domain/file/dto/FileMoveDto.java index 2479e25..ac6a802 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/dto/FileMoveDto.java +++ b/src/main/java/com/woowacamp/storage/domain/file/dto/FileMoveDto.java @@ -1,7 +1,10 @@ package com.woowacamp.storage.domain.file.dto; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.aop.type.FieldType; + public record FileMoveDto( - long targetFolderId, - long userId + @CheckField(FieldType.MOVE_FOLDER_ID) long targetFolderId, + @CheckField(FieldType.USER_ID) long userId ) { } diff --git a/src/main/java/com/woowacamp/storage/domain/file/dto/FormMetadataDto.java b/src/main/java/com/woowacamp/storage/domain/file/dto/FormMetadataDto.java index 766e746..22a7138 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/dto/FormMetadataDto.java +++ b/src/main/java/com/woowacamp/storage/domain/file/dto/FormMetadataDto.java @@ -13,6 +13,21 @@ public class FormMetadataDto { private long userId; private long parentFolderId; private long fileSize; + private long creatorId; + + public FormMetadataDto(long userId, long parentFolderId, long fileSize) { + this.userId = userId; + this.parentFolderId = parentFolderId; + this.fileSize = fileSize; + } + + public void setUserId(long userId) { + this.userId = userId; + } + + public void setCreatorId(long creatorId) { + this.creatorId = creatorId; + } public static FormMetadataDto of(Map formFields) { try { diff --git a/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadata.java b/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadata.java index e6fee69..c850571 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadata.java +++ b/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadata.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; +import com.woowacamp.storage.global.constant.CommonConstant; +import com.woowacamp.storage.global.constant.PermissionType; import com.woowacamp.storage.global.constant.UploadStatus; import jakarta.persistence.Column; @@ -77,21 +79,19 @@ public class FileMetadata { @NotNull private UploadStatus uploadStatus; + @Column(name = "sharing_expired_at", columnDefinition = "TIMESTAMP NOT NULL") + @NotNull + private LocalDateTime sharingExpiredAt; + + @Column(name = "permission_type", columnDefinition = "VARCHAR(10) NOT NULL") + @NotNull + @Enumerated(EnumType.STRING) + private PermissionType permissionType; + @Builder - public FileMetadata( - Long id, - Long rootId, - Long creatorId, - Long ownerId, - String fileType, - LocalDateTime createdAt, - LocalDateTime updatedAt, - Long parentFolderId, - Long fileSize, - String uploadFileName, - String uuidFileName, - UploadStatus uploadStatus - ) { + public FileMetadata(Long id, Long rootId, Long creatorId, Long ownerId, String fileType, LocalDateTime createdAt, + LocalDateTime updatedAt, Long parentFolderId, Long fileSize, String uploadFileName, String uuidFileName, + UploadStatus uploadStatus, LocalDateTime sharingExpiredAt, PermissionType permissionType) { this.id = id; this.rootId = rootId; this.creatorId = creatorId; @@ -104,6 +104,8 @@ public FileMetadata( this.uploadFileName = uploadFileName; this.uuidFileName = uuidFileName; this.uploadStatus = uploadStatus; + this.sharingExpiredAt = sharingExpiredAt; + this.permissionType = permissionType; } public void updateCreatedAt(LocalDateTime createdAt) { @@ -125,4 +127,17 @@ public void updateFinishUploadStatus() { public void updateParentFolderId(Long parentFolderId) { this.parentFolderId = parentFolderId; } + + public void updateShareStatus(PermissionType permissionType, LocalDateTime sharingExpiredAt) { + if (permissionType == null || permissionType.equals(PermissionType.NONE)) { + throw new IllegalArgumentException("잘못된 공유 권한 수정 입니다."); + } + this.permissionType = permissionType; + this.sharingExpiredAt = sharingExpiredAt; + } + + public void cancelShare() { + this.permissionType = PermissionType.NONE; + this.sharingExpiredAt = CommonConstant.UNAVAILABLE_TIME; + } } diff --git a/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadataFactory.java b/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadataFactory.java index abb1fb0..319a9c9 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadataFactory.java +++ b/src/main/java/com/woowacamp/storage/domain/file/entity/FileMetadataFactory.java @@ -2,17 +2,18 @@ import java.time.LocalDateTime; +import com.woowacamp.storage.domain.folder.entity.FolderMetadata; import com.woowacamp.storage.domain.user.entity.User; import com.woowacamp.storage.global.constant.UploadStatus; public class FileMetadataFactory { public static FileMetadata buildInitialMetadata(User user, long parentFolderId, long fileSize, String uuidFileName, - String fileName, String fileType) { + String fileName, String fileType, long creatorId, FolderMetadata parentFolderMetadata) { LocalDateTime now = LocalDateTime.now(); return FileMetadata.builder() .rootId(user.getRootFolderId()) - .creatorId(user.getId()) + .creatorId(creatorId) .ownerId(user.getId()) .parentFolderId(parentFolderId) .fileSize(fileSize) @@ -20,8 +21,10 @@ public static FileMetadata buildInitialMetadata(User user, long parentFolderId, .uploadStatus(UploadStatus.PENDING) .uploadFileName(fileName) .fileType(fileType) + .sharingExpiredAt(parentFolderMetadata.getSharingExpiredAt()) .createdAt(now) .updatedAt(now) + .permissionType(parentFolderMetadata.getPermissionType()) .build(); } } diff --git a/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepository.java b/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepository.java index fed9971..cf9583e 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepository.java +++ b/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepository.java @@ -7,8 +7,12 @@ import com.woowacamp.storage.domain.file.entity.FileMetadata; import com.woowacamp.storage.domain.folder.dto.FolderContentsSortField; +import com.woowacamp.storage.global.constant.PermissionType; public interface FileCustomRepository { List selectFilesWithPagination(long parentId, long cursorId, FolderContentsSortField sortBy, Sort.Direction direction, int limit, LocalDateTime time, Long size); + + void updateShareStatusInBatch(List folderIdsToUpdate, PermissionType permissionType, + LocalDateTime unavailableTime); } diff --git a/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepositoryImpl.java b/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepositoryImpl.java index 05745c8..dd5ac64 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepositoryImpl.java +++ b/src/main/java/com/woowacamp/storage/domain/file/repository/FileCustomRepositoryImpl.java @@ -13,6 +13,7 @@ import com.woowacamp.storage.domain.file.entity.FileMetadata; import com.woowacamp.storage.domain.file.entity.QFileMetadata; import com.woowacamp.storage.domain.folder.dto.FolderContentsSortField; +import com.woowacamp.storage.global.constant.PermissionType; import com.woowacamp.storage.global.constant.UploadStatus; import lombok.RequiredArgsConstructor; @@ -75,4 +76,14 @@ public List selectFilesWithPagination(long parentId, long cursorId return query.fetch(); } + + @Override + public void updateShareStatusInBatch(List fileIdsToUpdate, PermissionType permissionType, + LocalDateTime sharingExpireAt) { + queryFactory.update(fileMetadata) + .set(fileMetadata.permissionType, permissionType) + .set(fileMetadata.sharingExpiredAt, sharingExpireAt) + .where(fileMetadata.id.in(fileIdsToUpdate)) + .execute(); + } } diff --git a/src/main/java/com/woowacamp/storage/domain/file/repository/FileMetadataRepository.java b/src/main/java/com/woowacamp/storage/domain/file/repository/FileMetadataRepository.java index 1fbdf95..c87d1b2 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/repository/FileMetadataRepository.java +++ b/src/main/java/com/woowacamp/storage/domain/file/repository/FileMetadataRepository.java @@ -82,4 +82,11 @@ int finalizeMetadata(@Param("fileId") long fileId, @Param("fileSize") long fileS void setMetadataStatusFail(@Param("fileId") long fileId, @Param("uploadStatus") UploadStatus uploadStatus); boolean existsByParentFolderIdAndUploadStatus(Long parentFolderId, UploadStatus uploadStatus); + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query(value = """ + select f from FileMetadata f + where f.id = :fileId + """) + Optional findByIdForShare(@Param("fileId") long fileId); } diff --git a/src/main/java/com/woowacamp/storage/domain/file/service/FileService.java b/src/main/java/com/woowacamp/storage/domain/file/service/FileService.java index 6f96172..e1b0b6e 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/service/FileService.java +++ b/src/main/java/com/woowacamp/storage/domain/file/service/FileService.java @@ -70,7 +70,7 @@ public FileMetadata getFileMetadataBy(Long fileId, Long userId) { FileMetadata fileMetadata = fileMetadataRepository.findById(fileId) .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); - if (!Objects.equals(fileMetadata.getCreatorId(), userId)) { + if (!Objects.equals(fileMetadata.getOwnerId(), userId)) { throw ACCESS_DENIED.baseException(); } return fileMetadata; @@ -90,4 +90,5 @@ public void deleteFile(Long fileId, Long userId) { throw ErrorCode.FILE_DELETE_FAILED.baseException(); } } + } diff --git a/src/main/java/com/woowacamp/storage/domain/file/service/S3FileService.java b/src/main/java/com/woowacamp/storage/domain/file/service/S3FileService.java index e85c043..3499ab1 100644 --- a/src/main/java/com/woowacamp/storage/domain/file/service/S3FileService.java +++ b/src/main/java/com/woowacamp/storage/domain/file/service/S3FileService.java @@ -57,7 +57,7 @@ public FileMetadataDto createInitialMetadata(FormMetadataDto formMetadataDto, Pa String fileType = getFileTypeByFileName(fileName); User user = userRepository.findById(formMetadataDto.getUserId()) .orElseThrow(ErrorCode.USER_NOT_FOUND::baseException); - validateRequest(formMetadataDto, partContext, user, fileName, fileType); + FolderMetadata parentFolderMetadata = validateRequest(formMetadataDto, partContext, user, fileName, fileType); String uuidFileName = getUuidFileName(); @@ -65,7 +65,8 @@ public FileMetadataDto createInitialMetadata(FormMetadataDto formMetadataDto, Pa // TODO: 공유 기능이 생길 때, creatorId, ownerId 따로 FileMetadata fileMetadata = fileMetadataRepository.save( FileMetadataFactory.buildInitialMetadata(user, formMetadataDto.getParentFolderId(), - formMetadataDto.getFileSize(), uuidFileName, fileName, fileType)); + formMetadataDto.getFileSize(), uuidFileName, fileName, fileType, formMetadataDto.getCreatorId(), + parentFolderMetadata)); return FileMetadataDto.of(fileMetadata); } @@ -110,11 +111,15 @@ public void checkMetadata(UploadState state) { /** * validateParentFolder를 먼저 호출해야 부모 폴더에 락이 걸려서 같은 파일 이름으로 동시에 써지지 않는다. */ - private void validateRequest(FormMetadataDto formMetadataDto, PartContext partContext, User user, String fileName, + private FolderMetadata validateRequest(FormMetadataDto formMetadataDto, PartContext partContext, User user, + String fileName, String fileType) { validateFileSize(formMetadataDto.getFileSize(), user.getRootFolderId()); - validateParentFolder(formMetadataDto.getParentFolderId(), formMetadataDto.getUserId()); + FolderMetadata parentFolderMetadata = validateParentFolder(formMetadataDto.getParentFolderId(), + formMetadataDto.getUserId()); validateFile(partContext, formMetadataDto.getParentFolderId(), fileName, fileType); + + return parentFolderMetadata; } private void validateFileSize(long fileSize, Long rootFolderId) { @@ -150,12 +155,14 @@ private void updateFolderMetadataStatus(FileMetadataDto req, long fileSize, Loca /** * 요청한 parentFolderId가 자신의 폴더에 대한 id인지 확인 */ - private void validateParentFolder(long parentFolderId, long userId) { + private FolderMetadata validateParentFolder(long parentFolderId, long userId) { FolderMetadata folderMetadata = folderMetadataRepository.findByIdForUpdate(parentFolderId) .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); - if (!Objects.equals(folderMetadata.getCreatorId(), userId)) { + if (!Objects.equals(folderMetadata.getOwnerId(), userId)) { throw ACCESS_DENIED.baseException(); } + + return folderMetadata; } /** diff --git a/src/main/java/com/woowacamp/storage/domain/folder/controller/FolderController.java b/src/main/java/com/woowacamp/storage/domain/folder/controller/FolderController.java index e9ee3c7..a65b33c 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/controller/FolderController.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/controller/FolderController.java @@ -18,7 +18,15 @@ import com.woowacamp.storage.domain.folder.dto.request.CreateFolderReqDto; import com.woowacamp.storage.domain.folder.dto.request.FolderMoveDto; import com.woowacamp.storage.domain.folder.service.FolderService; +import com.woowacamp.storage.global.annotation.CheckDto; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.annotation.RequestType; +import com.woowacamp.storage.global.aop.type.FieldType; +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; +import com.woowacamp.storage.global.util.UrlUtil; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -29,15 +37,18 @@ public class FolderController { private final FolderService folderService; + @RequestType(permission = PermissionType.WRITE, fileType = FileType.FOLDER) @ResponseStatus(HttpStatus.CREATED) @PostMapping - public void createFolder(@Valid @RequestBody CreateFolderReqDto req) { - folderService.createFolder(req); + public void createFolder(@CheckDto @Valid @RequestBody CreateFolderReqDto req, HttpServletResponse response) { + Long folder = folderService.createFolder(req); + response.setHeader("Location", UrlUtil.getAbsoluteUrl("/api/v1/folders/" + folder)); } + @RequestType(permission = PermissionType.READ, fileType = FileType.FOLDER) @GetMapping("/{folderId}") - public FolderContentsDto getFolderContents(@PathVariable Long folderId, - @Valid @ModelAttribute GetFolderContentsRequestParams request) { + public FolderContentsDto getFolderContents(@CheckField(value = FieldType.FOLDER_ID) @PathVariable Long folderId, + @CheckDto @Valid @ModelAttribute GetFolderContentsRequestParams request) { folderService.checkFolderOwnedBy(folderId, request.userId()); @@ -45,16 +56,20 @@ public FolderContentsDto getFolderContents(@PathVariable Long folderId, request.sortBy(), request.sortDirection(), request.localDateTime(), request.size()); } + @RequestType(permission = PermissionType.WRITE, fileType = FileType.FOLDER) @PatchMapping("/{folderId}") - public void moveFolder(@PathVariable("folderId") Long sourceFolderId, @RequestBody FolderMoveDto dto) { + public void moveFolder(@PathVariable("folderId") @CheckField(value = FieldType.FOLDER_ID) Long sourceFolderId, + @CheckDto @RequestBody FolderMoveDto dto) { folderService.checkFolderOwnedBy(sourceFolderId, dto.userId()); folderService.checkFolderOwnedBy(dto.targetFolderId(), dto.userId()); folderService.moveFolder(sourceFolderId, dto); } + @RequestType(permission = PermissionType.WRITE, fileType = FileType.FOLDER) @DeleteMapping("/{folderId}") @ResponseStatus(HttpStatus.OK) - public void delete(@PathVariable Long folderId, @RequestParam Long userId) { + public void delete(@CheckField(FieldType.FOLDER_ID) @PathVariable Long folderId, + @CheckField(FieldType.USER_ID) @RequestParam Long userId) { folderService.deleteFolder(folderId, userId); } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/dto/GetFolderContentsRequestParams.java b/src/main/java/com/woowacamp/storage/domain/folder/dto/GetFolderContentsRequestParams.java index 0d8b045..15fcd02 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/dto/GetFolderContentsRequestParams.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/dto/GetFolderContentsRequestParams.java @@ -4,21 +4,24 @@ import org.springframework.data.domain.Sort; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.aop.type.FieldType; +import com.woowacamp.storage.global.constant.CommonConstant; + import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; -public record GetFolderContentsRequestParams(@NotNull @Positive Long userId, @NotNull @Positive Long cursorId, - @NotNull CursorType cursorType, @Min(0) @Max(MAX_SIZE) int limit, - FolderContentsSortField sortBy, Sort.Direction sortDirection, - LocalDateTime localDateTime, Long size) { +public record GetFolderContentsRequestParams(@NotNull @Positive @CheckField(value = FieldType.USER_ID) Long userId, + @NotNull @Positive Long cursorId, @NotNull CursorType cursorType, + @Positive @Max(MAX_SIZE) Integer limit, FolderContentsSortField sortBy, + Sort.Direction sortDirection, LocalDateTime localDateTime, Long size) { private static final int MAX_SIZE = 1000; private static final int DEFAULT_SIZE = 100; // 기본 생성자 정의 public GetFolderContentsRequestParams { - if (limit == 0) { + if (limit == null) { limit = DEFAULT_SIZE; } if (sortBy == null) { @@ -28,11 +31,19 @@ public record GetFolderContentsRequestParams(@NotNull @Positive Long userId, @No sortDirection = Sort.Direction.DESC; } + if (cursorId == null) { + cursorId = sortDirection.isAscending() ? Long.MAX_VALUE : 1L; + } + + if (cursorType == null) { + cursorType = CursorType.FOLDER; + } + if (isFirstPage(localDateTime, size)) { switch (sortBy) { case CREATED_AT: if (sortDirection.isAscending()) { - localDateTime = LocalDateTime.of(1970, 1, 1, 0, 0); + localDateTime = CommonConstant.UNAVAILABLE_TIME; } else { localDateTime = LocalDateTime.now().plusYears(1000); } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/dto/request/CreateFolderReqDto.java b/src/main/java/com/woowacamp/storage/domain/folder/dto/request/CreateFolderReqDto.java index ed1e471..1ee966b 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/dto/request/CreateFolderReqDto.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/dto/request/CreateFolderReqDto.java @@ -1,10 +1,14 @@ package com.woowacamp.storage.domain.folder.dto.request; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.aop.type.FieldType; + import jakarta.validation.constraints.Size; public record CreateFolderReqDto( - long userId, - long parentFolderId, - @Size(min = 1, max = 100) String uploadFolderName + @CheckField(FieldType.USER_ID) long userId, + @CheckField(FieldType.FOLDER_ID) long parentFolderId, + @Size(min = 1, max = 100) String uploadFolderName, + @CheckField(FieldType.CREATOR_ID) long creatorId ) { } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/dto/request/FolderMoveDto.java b/src/main/java/com/woowacamp/storage/domain/folder/dto/request/FolderMoveDto.java index 3db5e3f..079519a 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/dto/request/FolderMoveDto.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/dto/request/FolderMoveDto.java @@ -1,7 +1,10 @@ package com.woowacamp.storage.domain.folder.dto.request; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.aop.type.FieldType; + public record FolderMoveDto( - long userId, - long targetFolderId + @CheckField(FieldType.USER_ID) long userId, + @CheckField(FieldType.MOVE_FOLDER_ID) long targetFolderId ) { } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadata.java b/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadata.java index b236556..654f89d 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadata.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadata.java @@ -2,8 +2,13 @@ import java.time.LocalDateTime; +import com.woowacamp.storage.global.constant.CommonConstant; +import com.woowacamp.storage.global.constant.PermissionType; + import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -56,9 +61,19 @@ public class FolderMetadata { @Column(name = "folder_size", columnDefinition = "BIGINT NOT NULL DEFAULT 0") private long size; + @Column(name = "sharing_expired_at", columnDefinition = "TIMESTAMP NOT NULL") + @NotNull + private LocalDateTime sharingExpiredAt; + + @Column(name = "permission_type", columnDefinition = "VARCHAR(10) NOT NULL") + @NotNull + @Enumerated(EnumType.STRING) + private PermissionType permissionType; + @Builder - private FolderMetadata(Long id, Long rootId, Long ownerId, Long creatorId, LocalDateTime createdAt, - LocalDateTime updatedAt, Long parentFolderId, String uploadFolderName) { + public FolderMetadata(Long id, Long rootId, Long ownerId, Long creatorId, LocalDateTime createdAt, + LocalDateTime updatedAt, Long parentFolderId, String uploadFolderName, long size, + LocalDateTime sharingExpiredAt, PermissionType permissionType) { this.id = id; this.rootId = rootId; this.ownerId = ownerId; @@ -67,6 +82,9 @@ private FolderMetadata(Long id, Long rootId, Long ownerId, Long creatorId, Local this.updatedAt = updatedAt; this.parentFolderId = parentFolderId; this.uploadFolderName = uploadFolderName; + this.size = size; + this.sharingExpiredAt = sharingExpiredAt; + this.permissionType = permissionType; } public void initOwnerId(Long ownerId) { @@ -88,4 +106,17 @@ public void updateUpdatedAt(LocalDateTime now) { public void updateParentFolderId(Long parentFolderId) { this.parentFolderId = parentFolderId; } + + public void updateShareStatus(PermissionType permissionType, LocalDateTime sharingExpiredAt) { + if (permissionType == null || permissionType.equals(PermissionType.NONE)) { + throw new IllegalArgumentException("잘못된 공유 권한 수정 입니다."); + } + this.permissionType = permissionType; + this.sharingExpiredAt = sharingExpiredAt; + } + + public void cancelShare() { + this.permissionType = PermissionType.NONE; + this.sharingExpiredAt = CommonConstant.UNAVAILABLE_TIME; + } } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadataFactory.java b/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadataFactory.java index 0d2f455..ce667f8 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadataFactory.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/entity/FolderMetadataFactory.java @@ -4,25 +4,34 @@ import com.woowacamp.storage.domain.folder.dto.request.CreateFolderReqDto; import com.woowacamp.storage.domain.user.entity.User; +import com.woowacamp.storage.global.constant.CommonConstant; +import com.woowacamp.storage.global.constant.PermissionType; public class FolderMetadataFactory { - public static FolderMetadata createFolderMetadataBySignup(LocalDateTime localDateTime, String folderName) { + public static FolderMetadata createFolderMetadataBySignup(String folderName) { + LocalDateTime now = LocalDateTime.now(); return FolderMetadata.builder() - .createdAt(localDateTime) - .updatedAt(localDateTime) + .createdAt(now) + .updatedAt(now) .uploadFolderName(folderName) + .sharingExpiredAt(CommonConstant.UNAVAILABLE_TIME) + .permissionType(PermissionType.NONE) .build(); } - public static FolderMetadata createFolderMetadata(User user, LocalDateTime now, CreateFolderReqDto req) { + public static FolderMetadata createFolderMetadata(User user, FolderMetadata parentFolder, + CreateFolderReqDto req) { + LocalDateTime now = LocalDateTime.now(); return FolderMetadata.builder() .rootId(user.getRootFolderId()) .ownerId(user.getId()) - .creatorId(user.getId()) + .creatorId(req.creatorId()) .createdAt(now) .updatedAt(now) .parentFolderId(req.parentFolderId()) .uploadFolderName(req.uploadFolderName()) + .sharingExpiredAt(parentFolder.getSharingExpiredAt()) + .permissionType(parentFolder.getPermissionType()) .build(); } } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepository.java b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepository.java index c4acdee..a5fe087 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepository.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepository.java @@ -7,8 +7,12 @@ import com.woowacamp.storage.domain.folder.dto.FolderContentsSortField; import com.woowacamp.storage.domain.folder.entity.FolderMetadata; +import com.woowacamp.storage.global.constant.PermissionType; public interface FolderCustomRepository { - public List selectFoldersWithPagination(long parentId, long cursorId, + List selectFoldersWithPagination(long parentId, long cursorId, FolderContentsSortField sortBy, Sort.Direction direction, int limit, LocalDateTime dateTime, Long size); + + void updateShareStatusInBatch(List folderIdsToUpdate, PermissionType permissionType, + LocalDateTime unavailableTime); } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepositoryImpl.java b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepositoryImpl.java index dd7a765..b82d04d 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepositoryImpl.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderCustomRepositoryImpl.java @@ -12,6 +12,7 @@ import com.woowacamp.storage.domain.folder.dto.FolderContentsSortField; import com.woowacamp.storage.domain.folder.entity.FolderMetadata; import com.woowacamp.storage.domain.folder.entity.QFolderMetadata; +import com.woowacamp.storage.global.constant.PermissionType; import lombok.RequiredArgsConstructor; @@ -73,4 +74,13 @@ public List selectFoldersWithPagination(long parentId, long curs return query.fetch(); } + @Override + public void updateShareStatusInBatch(List folderIdsToUpdate, PermissionType permissionType, + LocalDateTime sharingExpireAt) { + queryFactory.update(folderMetadata) + .set(folderMetadata.permissionType, permissionType) + .set(folderMetadata.sharingExpiredAt, sharingExpireAt) + .where(folderMetadata.id.in(folderIdsToUpdate)) + .execute(); + } } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderMetadataRepository.java b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderMetadataRepository.java index 8efb0d3..348d1d1 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderMetadataRepository.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/repository/FolderMetadataRepository.java @@ -65,4 +65,11 @@ void updateParentFolderIdForDelete(@Param("newParentId") int newParentId, where f.parentFolderId = :parentFolderId """) void deleteOrphanFolders(@Param("parentFolderId") long parentFolderId); + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query(value = """ + select f from FolderMetadata f + where f.id = :folderId + """) + Optional findByIdForShare(@Param("folderId") Long folderId); } diff --git a/src/main/java/com/woowacamp/storage/domain/folder/service/FolderService.java b/src/main/java/com/woowacamp/storage/domain/folder/service/FolderService.java index b571662..5ec6ce7 100644 --- a/src/main/java/com/woowacamp/storage/domain/folder/service/FolderService.java +++ b/src/main/java/com/woowacamp/storage/domain/folder/service/FolderService.java @@ -179,20 +179,19 @@ private List fetchFolders(Long folderId, Long cursorId, int limi * 이미 제거되어 Null을 리턴한 경우 폴더가 생성되지 않습니다. */ @Transactional - public void createFolder(CreateFolderReqDto req) { + public Long createFolder(CreateFolderReqDto req) { User user = userRepository.findById(req.userId()).orElseThrow(ErrorCode.USER_NOT_FOUND::baseException); - // TODO: 이후 공유 기능이 생길 때, request에 ownerId, creatorId 따로 받아야함 long parentFolderId = req.parentFolderId(); long userId = req.userId(); - FolderMetadata folderMetadata = folderMetadataRepository.findByIdForUpdate(parentFolderId) + FolderMetadata parentFolder = folderMetadataRepository.findByIdForUpdate(parentFolderId) .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); - validatePermission(folderMetadata, userId); + validatePermission(parentFolder, userId); validateFolderName(req); validateFolder(req); - LocalDateTime now = LocalDateTime.now(); - folderMetadataRepository.save(createFolderMetadata(user, now, req)); + FolderMetadata newFolder = folderMetadataRepository.save(createFolderMetadata(user, parentFolder, req)); + return newFolder.getId(); } /** @@ -213,7 +212,7 @@ private void validateFolder(CreateFolderReqDto req) { * 부모 폴더가 요청한 사용자의 폴더인지 확인 */ private void validatePermission(FolderMetadata folderMetadata, long userId) { - if (!folderMetadata.getCreatorId().equals(userId)) { + if (!folderMetadata.getOwnerId().equals(userId)) { throw ErrorCode.ACCESS_DENIED.baseException(); } } @@ -353,5 +352,4 @@ private void deleteWithBfs(long parentFolderId, List folderIdListForDelete }); } } - } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/controller/SharedLinkController.java b/src/main/java/com/woowacamp/storage/domain/shredlink/controller/SharedLinkController.java index f632d92..639ba56 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/controller/SharedLinkController.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/controller/SharedLinkController.java @@ -1,17 +1,27 @@ package com.woowacamp.storage.domain.shredlink.controller; +import java.io.IOException; + import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import com.woowacamp.storage.domain.shredlink.dto.request.CancelSharedLinkRequestDto; import com.woowacamp.storage.domain.shredlink.dto.request.MakeSharedLinkRequestDto; import com.woowacamp.storage.domain.shredlink.dto.response.SharedLinkResponseDto; import com.woowacamp.storage.domain.shredlink.service.SharedLinkService; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; @RestController @@ -24,6 +34,19 @@ public class SharedLinkController { @ResponseStatus(HttpStatus.CREATED) @PostMapping public SharedLinkResponseDto createSharedLink(@Valid @RequestBody MakeSharedLinkRequestDto requestDto) { - return sharedLinkService.createSharedLink(requestDto); + return sharedLinkService.createShareLink(requestDto); + } + + @GetMapping + @ResponseStatus(HttpStatus.FOUND) + public void getTokenFromShareLink(@NotNull @Positive @RequestParam Long userId, + @NotBlank @RequestParam String sharedId, HttpServletResponse response) throws + IOException { + response.sendRedirect(sharedLinkService.getRedirectUrl(sharedId) + "?userId =" + userId); + } + + @DeleteMapping + public void deleteSharedLink(@Valid @RequestBody CancelSharedLinkRequestDto requestDto) { + sharedLinkService.cancelShare(requestDto); } } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/CancelSharedLinkRequestDto.java b/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/CancelSharedLinkRequestDto.java new file mode 100644 index 0000000..4db1949 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/CancelSharedLinkRequestDto.java @@ -0,0 +1,10 @@ +package com.woowacamp.storage.domain.shredlink.dto.request; + +import jakarta.validation.constraints.Positive; + +public record CancelSharedLinkRequestDto( + @Positive long userId, + boolean isFile, + @Positive long targetId +) { +} diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/MakeSharedLinkRequestDto.java b/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/MakeSharedLinkRequestDto.java index 7429857..60cfdca 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/MakeSharedLinkRequestDto.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/dto/request/MakeSharedLinkRequestDto.java @@ -1,6 +1,6 @@ package com.woowacamp.storage.domain.shredlink.dto.request; -import com.woowacamp.storage.domain.shredlink.entity.PermissionType; +import com.woowacamp.storage.global.constant.PermissionType; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; @@ -11,7 +11,6 @@ public record MakeSharedLinkRequestDto( @Positive long targetId, @NotNull String permissionType ) { - public PermissionType getPermissionType() { return PermissionType.fromValue(permissionType); } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLink.java b/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLink.java index 86161e9..3afa2ab 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLink.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLink.java @@ -2,6 +2,8 @@ import java.time.LocalDateTime; +import com.woowacamp.storage.global.constant.PermissionType; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -9,6 +11,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import jakarta.validation.constraints.NotNull; @@ -18,8 +21,9 @@ import lombok.NoArgsConstructor; @Entity -@Table(name = "shared_link", uniqueConstraints = {@UniqueConstraint(columnNames = {"shared_link_url"}), - @UniqueConstraint(columnNames = {"shared_token"})}) +@Table(name = "shared_link", + uniqueConstraints = {@UniqueConstraint(columnNames = {"shared_id"})}, + indexes = {@Index(name = "target_index", columnList = "is_file,target_id")}) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class SharedLink { @@ -33,21 +37,19 @@ public class SharedLink { @NotNull private LocalDateTime createdAt; - // 공유하는 링크 - @Column(name = "shared_link_url", columnDefinition = "VARCHAR(300) NOT NULL") + // 공유 링크로 접속시 리다이렉트할 url + @Column(name = "redirect_url", columnDefinition = "VARCHAR(300) NOT NULL") @NotNull - private String sharedLinkUrl; + private String redirectUrl; + + @Column(name = "shared_id", columnDefinition = "VARCHAR(100) NOT NULL") + private String sharedId; // 공유하는 사용자의 pk @Column(name = "shared_user_id", columnDefinition = "BIGINT NOT NULL") @NotNull private Long sharedUserId; - // 공유 받은 사용자를 인증할 토큰(쿠키) - @Column(name = "shared_token", columnDefinition = "VARCHAR(100) NOT NULL") - @NotNull - private String sharedToken; - @Column(name = "expired_at", columnDefinition = "TIMESTAMP NOT NULL") @NotNull private LocalDateTime expiredAt; @@ -66,16 +68,20 @@ public class SharedLink { private PermissionType permissionType; @Builder - public SharedLink(Long id, LocalDateTime createdAt, String sharedLinkUrl, Long sharedUserId, String sharedToken, + public SharedLink(Long id, LocalDateTime createdAt, String redirectUrl, String sharedId, Long sharedUserId, LocalDateTime expiredAt, Boolean isFile, Long targetId, PermissionType permissionType) { this.id = id; this.createdAt = createdAt; - this.sharedLinkUrl = sharedLinkUrl; + this.redirectUrl = redirectUrl; this.sharedUserId = sharedUserId; - this.sharedToken = sharedToken; + this.sharedId = sharedId; this.expiredAt = expiredAt; this.isFile = isFile; this.targetId = targetId; this.permissionType = permissionType; } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiredAt); + } } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLinkFactory.java b/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLinkFactory.java index b386469..a518c36 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLinkFactory.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/entity/SharedLinkFactory.java @@ -6,15 +6,16 @@ import com.woowacamp.storage.global.constant.CommonConstant; public class SharedLinkFactory { - public static SharedLink createSharedLink(MakeSharedLinkRequestDto requestDto, String sharedUrl, - String sharedToken) { + + public static SharedLink createSharedLink(MakeSharedLinkRequestDto requestDto, String sharedId, + String redirectUrl) { LocalDateTime createTime = LocalDateTime.now(); - LocalDateTime expiredTime = createTime.plusHours(CommonConstant.SHARED_LINK_VALID_TIME); + LocalDateTime expiredTime = createTime.plusSeconds(CommonConstant.SHARED_LINK_VALID_TIME); return SharedLink.builder() .createdAt(createTime) - .sharedLinkUrl(sharedUrl) + .redirectUrl(redirectUrl) .sharedUserId(requestDto.userId()) - .sharedToken(sharedToken) + .sharedId(sharedId) .expiredAt(expiredTime) .isFile(requestDto.isFile()) .targetId(requestDto.targetId()) diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/repository/SharedLinkRepository.java b/src/main/java/com/woowacamp/storage/domain/shredlink/repository/SharedLinkRepository.java index 79defaf..43b7a0a 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/repository/SharedLinkRepository.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/repository/SharedLinkRepository.java @@ -1,8 +1,16 @@ package com.woowacamp.storage.domain.shredlink.repository; +import java.util.List; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.woowacamp.storage.domain.shredlink.entity.SharedLink; public interface SharedLinkRepository extends JpaRepository { + Optional findBySharedId(String shareId); + + List findByIsFileAndTargetId(boolean isFile, long targetId); + + void deleteByIsFileAndTargetId(boolean isFile, Long targetId); } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/service/SharedLinkService.java b/src/main/java/com/woowacamp/storage/domain/shredlink/service/SharedLinkService.java index 17fbe75..9538e8c 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/service/SharedLinkService.java +++ b/src/main/java/com/woowacamp/storage/domain/shredlink/service/SharedLinkService.java @@ -1,80 +1,232 @@ package com.woowacamp.storage.domain.shredlink.service; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Stack; import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import com.woowacamp.storage.domain.file.entity.FileMetadata; -import com.woowacamp.storage.domain.file.repository.FileMetadataJpaRepository; +import com.woowacamp.storage.domain.file.repository.FileMetadataRepository; import com.woowacamp.storage.domain.folder.entity.FolderMetadata; import com.woowacamp.storage.domain.folder.repository.FolderMetadataRepository; +import com.woowacamp.storage.domain.shredlink.dto.request.CancelSharedLinkRequestDto; import com.woowacamp.storage.domain.shredlink.dto.request.MakeSharedLinkRequestDto; import com.woowacamp.storage.domain.shredlink.dto.response.SharedLinkResponseDto; import com.woowacamp.storage.domain.shredlink.entity.SharedLink; import com.woowacamp.storage.domain.shredlink.entity.SharedLinkFactory; import com.woowacamp.storage.domain.shredlink.repository.SharedLinkRepository; import com.woowacamp.storage.global.constant.CommonConstant; +import com.woowacamp.storage.global.constant.PermissionType; import com.woowacamp.storage.global.error.ErrorCode; +import com.woowacamp.storage.global.util.UrlUtil; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor public class SharedLinkService { - @Value("${share.server-domain}") - private String SERVER_DOMAIN; - private final SharedLinkRepository sharedLinkRepository; private final FolderMetadataRepository folderMetadataRepository; - private final FileMetadataJpaRepository fileMetadataJpaRepository; + private final FileMetadataRepository fileMetadataRepository; /** * 공유 링크 생성 메소드 - * 공유 링크와 토큰 값은 UUID를 사용했습니다. + *공유 대상 폴더/파일(폴더라면 하위 폴더 및 파일까지)의 공유 상태를 업데이트 하고 공유 링크를 반환합니다. */ - @Transactional - public SharedLinkResponseDto createSharedLink(MakeSharedLinkRequestDto requestDto) { - if (requestDto.isFile()) { // file인 경우 - FileMetadata fileMetadata = fileMetadataJpaRepository.findById(requestDto.targetId()) - .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + @Transactional(isolation = Isolation.READ_COMMITTED) + public SharedLinkResponseDto createShareLink(MakeSharedLinkRequestDto requestDto) { + validateRequest(requestDto.userId(), requestDto.isFile(), requestDto.targetId()); + if (requestDto.getPermissionType().equals(PermissionType.NONE)) { + throw ErrorCode.WRONG_PERMISSION_TYPE.baseException(); + } - if (!isOwner(fileMetadata.getOwnerId(), requestDto.userId())) { + // 기존에 발급된 링크가 있다면 반환 + Optional existingSharedLink = getExistingSharedLink(requestDto.isFile(), requestDto.targetId(), + requestDto.getPermissionType()); + if (existingSharedLink.isPresent()) { + return new SharedLinkResponseDto(createSharedLinkUrl(existingSharedLink.get().getSharedId())); + } + + SharedLink sharedLink = createSharedLink(requestDto); + try { + sharedLinkRepository.saveAndFlush(sharedLink); + } catch (DataIntegrityViolationException e) { + throw ErrorCode.DUPLICATED_SHARED_LINK.baseException(); + } + + updateShareStatus(sharedLink); + return new SharedLinkResponseDto(createSharedLinkUrl(sharedLink.getSharedId())); + } + + private void validateRequest(Long userId, boolean isFile, long targetId) { + if (isFile) { // file인 경우 + FileMetadata fileMetadata = fileMetadataRepository.findById(targetId) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + if (!Objects.equals(fileMetadata.getOwnerId(), userId)) { throw ErrorCode.ACCESS_DENIED.baseException(); } } else { // folder인 경우 - FolderMetadata folderMetadata = folderMetadataRepository.findById(requestDto.targetId()) + FolderMetadata folderMetadata = folderMetadataRepository.findById(targetId) .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); - - if (!isOwner(folderMetadata.getOwnerId(), requestDto.userId())) { + if (!Objects.equals(folderMetadata.getOwnerId(), userId)) { throw ErrorCode.ACCESS_DENIED.baseException(); } } + } - String sharedUrl = createSharedLink(); - String sharedToken = createToken(); - SharedLink sharedLink = SharedLinkFactory.createSharedLink(requestDto, sharedUrl, sharedToken); + private Optional getExistingSharedLink(boolean isFile, long targetId, PermissionType permissionType) { + List existingLinks = sharedLinkRepository.findByIsFileAndTargetId(isFile, targetId); + for (SharedLink link : existingLinks) { + if (!link.isExpired() && Objects.equals(permissionType, link.getPermissionType())) { + return Optional.of(link); + } + } + return Optional.empty(); + } - try { - sharedLinkRepository.saveAndFlush(sharedLink); - } catch (DataIntegrityViolationException e) { - throw ErrorCode.DUPLICATED_SHARED_LINK.baseException(); + private SharedLink createSharedLink(MakeSharedLinkRequestDto requestDto) { + String sharedId = UUID.randomUUID().toString(); + String redirectUrl = createRedirectUrl(requestDto.isFile(), requestDto.targetId()); + return SharedLinkFactory.createSharedLink(requestDto, sharedId, redirectUrl); + } + + private String createRedirectUrl(boolean isFile, long targetId) { + String template = isFile ? CommonConstant.FILE_READ_URI : CommonConstant.FOLDER_READ_URI; + return UrlUtil.getAbsoluteUrl(template + targetId); + } + + private String createSharedLinkUrl(String sharedId) { + return UrlUtil.getAbsoluteUrl(CommonConstant.SHARED_LINK_URI + sharedId); + } + + /** 공유 대상을 조회하는 url을 반환합니다. + **/ + public String getRedirectUrl(String sharedId) { + SharedLink sharedLink = sharedLinkRepository.findBySharedId(sharedId) + .orElseThrow(ErrorCode.SHARED_LINK_NOT_FOUND::baseException); + if (sharedLink.isExpired()) { + throw ErrorCode.EXPIRED_SHARED_LINK.baseException(); + } + return sharedLink.getRedirectUrl(); + } + + public void updateShareStatus(SharedLink sharedLink) { + if (Boolean.TRUE.equals(sharedLink.getIsFile())) { + updateFileShareStatus(sharedLink.getTargetId(), sharedLink.getPermissionType(), + sharedLink.getExpiredAt()); + } else { + updateSubFolderShareStatus(sharedLink.getTargetId(), + sharedLink.getPermissionType(), sharedLink.getExpiredAt()); } + } + + public void updateFileShareStatus(Long fileId, PermissionType permissionType, LocalDateTime sharingExpireAt) { + FileMetadata fileMetadata = fileMetadataRepository.findById(fileId) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + fileMetadata.updateShareStatus(permissionType, sharingExpireAt); + } + + public void updateSubFolderShareStatus(Long folderId, PermissionType permissionType, + LocalDateTime sharingExpireAt) { + FolderMetadata folder = folderMetadataRepository.findById(folderId) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + folder.cancelShare(); + + Stack folderIdStack = new Stack<>(); + folderIdStack.push(folderId); + + List folderIdsToUpdate = new ArrayList<>(); + List fileIdsToUpdate = new ArrayList<>(); + folderIdsToUpdate.add(folderId); + + while (!folderIdStack.isEmpty()) { + Long currentFolderId = folderIdStack.pop(); + + // 하위의 파일 조회 + List childFileMetadata = fileMetadataRepository.findByParentFolderIdForUpdate( + currentFolderId); + + // 하위 파일의 공유 상태 수정 + childFileMetadata.forEach(fileMetadata -> { + fileIdsToUpdate.add(fileMetadata.getId()); + }); + + // 하위의 폴더 조회 + List childFolders = folderMetadataRepository.findByParentFolderIdForUpdate(currentFolderId); - return new SharedLinkResponseDto(sharedUrl); + // 하위 폴더들을 스택에 추가 + for (FolderMetadata childFolder : childFolders) { + folderIdsToUpdate.add(childFolder.getId()); + folderIdStack.push(childFolder.getId()); + } + } + fileMetadataRepository.updateShareStatusInBatch(fileIdsToUpdate, permissionType, + sharingExpireAt); + folderMetadataRepository.updateShareStatusInBatch(folderIdsToUpdate, permissionType, + sharingExpireAt); } - private boolean isOwner(long ownerId, long userId) { - return ownerId == userId; + @Transactional + public void cancelShare(CancelSharedLinkRequestDto requestDto) { + validateRequest(requestDto.userId(), requestDto.isFile(), requestDto.targetId()); + cancelShare(requestDto.isFile(), requestDto.targetId()); } - private String createSharedLink() { - return SERVER_DOMAIN + CommonConstant.SHARED_URI + UUID.randomUUID(); + private void cancelShare(boolean isFile, Long targetId) { + sharedLinkRepository.deleteByIsFileAndTargetId(isFile, targetId); + if (isFile) { + FileMetadata fileMetadata = fileMetadataRepository.findById(targetId) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + fileMetadata.cancelShare(); + } else { + cancelFolderShare(targetId); + } } - private String createToken() { - return UUID.randomUUID().toString(); + public void cancelFolderShare(Long folderId) { + FolderMetadata folder = folderMetadataRepository.findById(folderId) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + folder.cancelShare(); + + Stack folderIdStack = new Stack<>(); + folderIdStack.push(folderId); + + List folderIdsToUpdate = new ArrayList<>(); + List fileIdsToUpdate = new ArrayList<>(); + folderIdsToUpdate.add(folderId); + + while (!folderIdStack.isEmpty()) { + Long currentFolderId = folderIdStack.pop(); + + // 하위의 파일 조회 + List childFileMetadata = fileMetadataRepository.findByParentFolderIdForUpdate( + currentFolderId); + + // 하위 파일의 공유 상태 수정 + childFileMetadata.forEach(fileMetadata -> { + fileIdsToUpdate.add(fileMetadata.getId()); + }); + + // 하위의 폴더 조회 + List childFolders = folderMetadataRepository.findByParentFolderIdForUpdate(currentFolderId); + + // 하위 폴더들을 스택에 추가 + for (FolderMetadata childFolder : childFolders) { + folderIdsToUpdate.add(childFolder.getId()); + folderIdStack.push(childFolder.getId()); + } + } + fileMetadataRepository.updateShareStatusInBatch(fileIdsToUpdate, PermissionType.NONE, + CommonConstant.UNAVAILABLE_TIME); + folderMetadataRepository.updateShareStatusInBatch(folderIdsToUpdate, PermissionType.NONE, + CommonConstant.UNAVAILABLE_TIME); } } diff --git a/src/main/java/com/woowacamp/storage/domain/user/service/UserService.java b/src/main/java/com/woowacamp/storage/domain/user/service/UserService.java index 4e80041..3ae2567 100644 --- a/src/main/java/com/woowacamp/storage/domain/user/service/UserService.java +++ b/src/main/java/com/woowacamp/storage/domain/user/service/UserService.java @@ -21,7 +21,6 @@ @Service @RequiredArgsConstructor public class UserService { - private static final String rootFolderName = "rootFolder"; private final UserRepository userRepository; private final FolderMetadataRepository folderMetadataRepository; @@ -38,7 +37,7 @@ public UserDto findById(long userId) { public UserDto save(CreateUserReqDto req) { LocalDateTime now = LocalDateTime.now(); - FolderMetadata rootFolder = folderMetadataRepository.save(createFolderMetadataBySignup(now, rootFolderName)); + FolderMetadata rootFolder = folderMetadataRepository.save(createFolderMetadataBySignup(rootFolderName)); User user = userRepository.save(createUser(req.userName(), rootFolder)); diff --git a/src/main/java/com/woowacamp/storage/global/annotation/CheckDto.java b/src/main/java/com/woowacamp/storage/global/annotation/CheckDto.java new file mode 100644 index 0000000..84be3a1 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/annotation/CheckDto.java @@ -0,0 +1,11 @@ +package com.woowacamp.storage.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckDto { +} diff --git a/src/main/java/com/woowacamp/storage/global/annotation/CheckField.java b/src/main/java/com/woowacamp/storage/global/annotation/CheckField.java new file mode 100644 index 0000000..ac58594 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/annotation/CheckField.java @@ -0,0 +1,17 @@ +package com.woowacamp.storage.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.woowacamp.storage.global.aop.type.FieldType; + +/** + * 권한을 확인해야 하는 File, Folder에 해당 어노테이션을 명시하면, 그 값을 바탕으로 권한을 검증한다. + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CheckField { + FieldType value(); +} diff --git a/src/main/java/com/woowacamp/storage/global/annotation/RequestType.java b/src/main/java/com/woowacamp/storage/global/annotation/RequestType.java new file mode 100644 index 0000000..c163d28 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/annotation/RequestType.java @@ -0,0 +1,20 @@ +package com.woowacamp.storage.global.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; + +/** + * 요청한 작업이 읽기 작업인지, 쓰기 작업인지 명시하는 어노테이션 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequestType { + PermissionType permission(); + + FileType fileType(); +} diff --git a/src/main/java/com/woowacamp/storage/global/aop/PermissionCheckAspect.java b/src/main/java/com/woowacamp/storage/global/aop/PermissionCheckAspect.java new file mode 100644 index 0000000..2ea275c --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/aop/PermissionCheckAspect.java @@ -0,0 +1,235 @@ +package com.woowacamp.storage.global.aop; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.RecordComponent; +import java.util.Arrays; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.stereotype.Component; + +import com.woowacamp.storage.global.annotation.CheckDto; +import com.woowacamp.storage.global.annotation.CheckField; +import com.woowacamp.storage.global.annotation.RequestType; +import com.woowacamp.storage.global.aop.type.FieldType; +import com.woowacamp.storage.global.error.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class PermissionCheckAspect { + private final PermissionHandler permissionHandler; + + /** + * 해당 파일에 대한 권한이 있는지 확인하는 메소드 + * 권한이 있는지 확인 후(hasPermission 호출 후) 요청한 사용자의 정보를 파일이나 폴더의 ownerId로 변경해준다. + */ + @Around("@annotation(requestType)") + public Object checkPermission(ProceedingJoinPoint joinPoint, RequestType requestType) throws Throwable { + + PermissionFieldsDto permissionFieldsDto = new PermissionFieldsDto(); + + MethodSignature signature = (MethodSignature)joinPoint.getSignature(); + Parameter[] parameters = signature.getMethod().getParameters(); + Object[] args = joinPoint.getArgs(); + + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + Object arg = args[i]; + + CheckField checkField = parameter.getAnnotation(CheckField.class); + if (checkField != null) { + setField(permissionFieldsDto, checkField.value().getValue(), arg); + } + + CheckDto checkDto = parameter.getAnnotation(CheckDto.class); + if (checkDto != null) { + processClassFields(arg, permissionFieldsDto); + } + + } + + // 권한 인증 진행 + permissionHandler.hasPermission(requestType.permission(), requestType.fileType(), permissionFieldsDto); + + updateMethodParameters(parameters, args, permissionFieldsDto); + + return joinPoint.proceed(args); + } + + private void updateMethodParameters(Parameter[] parameters, Object[] args, + PermissionFieldsDto permissionFieldsDto) { + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; + CheckField checkField = parameter.getAnnotation(CheckField.class); + if (checkField != null) { + FieldType fieldType = checkField.value(); + Object newValue = null; + switch (fieldType) { + case USER_ID -> newValue = permissionFieldsDto.getOwnerId(); + case FILE_ID -> newValue = permissionFieldsDto.getFileId(); + case FOLDER_ID -> newValue = permissionFieldsDto.getFolderId(); + case MOVE_FOLDER_ID -> newValue = permissionFieldsDto.getMoveFolderId(); + case CREATOR_ID -> newValue = permissionFieldsDto.getUserId(); + } + if (newValue != null) { + args[i] = convertValueIfNeeded(newValue, parameter.getType()); + } + } + + CheckDto checkDto = parameter.getAnnotation(CheckDto.class); + if (checkDto != null) { + Object requestDto = args[i]; + Class clazz = requestDto.getClass(); + if (clazz.isRecord()) { + RecordComponent[] components = clazz.getRecordComponents(); + Object[] currentValues = new Object[components.length]; + try { + for (int j = 0; j < components.length; j++) { + RecordComponent component = components[j]; + Method accessor = component.getAccessor(); + currentValues[j] = accessor.invoke(requestDto); + } + + boolean valueChanged = false; + for (int j = 0; j < components.length; j++) { + Field field = clazz.getDeclaredField(components[j].getName()); + CheckField dtoCheckField = field.getAnnotation(CheckField.class); + if (dtoCheckField != null) { + Object newValue = null; + FieldType fieldType = dtoCheckField.value(); + switch (fieldType) { + case USER_ID -> newValue = permissionFieldsDto.getOwnerId(); + case FILE_ID -> newValue = permissionFieldsDto.getFileId(); + case FOLDER_ID -> newValue = permissionFieldsDto.getFolderId(); + case MOVE_FOLDER_ID -> newValue = permissionFieldsDto.getMoveFolderId(); + case CREATOR_ID -> newValue = permissionFieldsDto.getUserId(); + } + if (newValue != null && !newValue.equals(currentValues[j])) { + currentValues[j] = convertValueIfNeeded(newValue, components[j].getType()); + valueChanged = true; + } + } + } + + if (valueChanged) { + Constructor constructor = clazz.getDeclaredConstructor( + Arrays.stream(components).map(RecordComponent::getType).toArray(Class[]::new)); + args[i] = constructor.newInstance(currentValues); + } + } catch (Exception e) { + log.error("[Reflection Error] 요청 DTO record 클래스 생성 중 예외 발생", e); + throw ErrorCode.PERMISSION_CHECK_FAILED.baseException(); + } + + } + } + } + } + + /** + * 데이터를 클래스 필드의 타입에 맞춰 반환하는 메소드 + */ + private Object convertValueIfNeeded(Object value, Class targetType) { + // 문자열 변환 + if (targetType == String.class) { + return value.toString(); + } + + // 기본 타입 및 래퍼 클래스 변환 + if (targetType == Boolean.class || targetType == boolean.class) { + return Boolean.valueOf(value.toString()); + } else if (targetType == Byte.class || targetType == byte.class) { + return Byte.valueOf(value.toString()); + } else if (targetType == Short.class || targetType == short.class) { + return Short.valueOf(value.toString()); + } else if (targetType == Integer.class || targetType == int.class) { + return Integer.valueOf(value.toString()); + } else if (targetType == Long.class || targetType == long.class) { + return Long.valueOf(value.toString()); + } else if (targetType == Float.class || targetType == float.class) { + return Float.valueOf(value.toString()); + } else if (targetType == Double.class || targetType == double.class) { + return Double.valueOf(value.toString()); + } else if (targetType == Character.class || targetType == char.class) { + String s = value.toString(); + if (s.length() != 1) { + throw new IllegalArgumentException("Cannot convert to char: " + s); + } + return s.charAt(0); + } + + // Enum 변환 + if (targetType.isEnum()) { + return Enum.valueOf((Class)targetType, value.toString()); + } + // 필요에 따라 다른 타입 변환 로직 추가 + log.error("[Reflection Error] 요청 데이터 타입 변환 중 예외 발생, value = {}, targetClassType = {}", value, targetType); + throw ErrorCode.PERMISSION_CHECK_FAILED.baseException(); + } + + /** + * 권한 인증용 클래스에 필요한 데이터를 추가하는 메소드 + * + * @param permissionFieldsDto 권한 인증에 사용할 클래스 + * @param fieldName 요청으로 들어온 필드의 이름 + * @param value 요청으로 들어온 필드의 값 + */ + private void setField(PermissionFieldsDto permissionFieldsDto, String fieldName, Object value) { + switch (fieldName) { + case "userId": + permissionFieldsDto.setUserId((Long)value); + break; + case "folderId": + permissionFieldsDto.setFolderId((Long)value); + break; + case "fileId": + permissionFieldsDto.setFileId((Long)value); + break; + case "moveFolderId": + permissionFieldsDto.setMoveFolderId((Long)value); + break; + case "creatorId": + permissionFieldsDto.setCreatorId((Long)value); + break; + default: + throw ErrorCode.PERMISSION_CHECK_FAILED.baseException("요청 파라미터 AOP 처리 시 예외 발생, 필드 이름 = {}", fieldName); + } + } + + /** + * 파라미터 타입이 별도의 클래스인 경우 필요한 값을 추출하는 메소드 + * @param requestDto 컨트롤러의 파라미터가 별도의 DTO인 객체 + * @param permissionFieldsDto 권한 인증에 사용할 클래스 + */ + private void processClassFields(Object requestDto, PermissionFieldsDto permissionFieldsDto) { + if (requestDto == null) + return; + + Class clazz = requestDto.getClass(); + for (Field field : clazz.getDeclaredFields()) { + CheckField checkField = field.getAnnotation(CheckField.class); + if (checkField != null) { + field.setAccessible(true); + try { + Object value = field.get(requestDto); + setField(permissionFieldsDto, checkField.value().getValue(), value); + } catch (IllegalAccessException e) { + log.error("[PermissionCheckAspect] request dto AOP 처리 시 예외 발생, error message = {}", e.getMessage()); + throw ErrorCode.PERMISSION_CHECK_FAILED.baseException(); + } + } + } + + } + +} diff --git a/src/main/java/com/woowacamp/storage/global/aop/PermissionFieldsDto.java b/src/main/java/com/woowacamp/storage/global/aop/PermissionFieldsDto.java new file mode 100644 index 0000000..59a8604 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/aop/PermissionFieldsDto.java @@ -0,0 +1,20 @@ +package com.woowacamp.storage.global.aop; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@Getter +@Setter +@NoArgsConstructor +@ToString +public class PermissionFieldsDto { + private Long userId; + private Long fileId; + private Long folderId; + private Long moveFolderId; // 파일이나 폴더 이동의 경우 이동할 폴더의 권한도 검증해야 한다. + private Long ownerId; + private Long creatorId; + +} diff --git a/src/main/java/com/woowacamp/storage/global/aop/PermissionHandler.java b/src/main/java/com/woowacamp/storage/global/aop/PermissionHandler.java new file mode 100644 index 0000000..04ef0a8 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/aop/PermissionHandler.java @@ -0,0 +1,185 @@ +package com.woowacamp.storage.global.aop; + +import java.time.LocalDateTime; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import com.woowacamp.storage.domain.file.entity.FileMetadata; +import com.woowacamp.storage.domain.file.repository.FileMetadataRepository; +import com.woowacamp.storage.domain.folder.entity.FolderMetadata; +import com.woowacamp.storage.domain.folder.repository.FolderMetadataRepository; +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; +import com.woowacamp.storage.global.error.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PermissionHandler { + private final FolderMetadataRepository folderMetadataRepository; + private final FileMetadataRepository fileMetadataRepository; + + /** + * 권한을 확인하는 메소드 + * + * @param permissionType 읽기 작업인지, 쓰기 작업인지 명시 + * @param fileType 파일 작업인지, 폴더 작업인지 명시 + * @param permissionFieldsDto 요청 객체의 필드 데이터 + * @return + */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public void hasPermission(PermissionType permissionType, FileType fileType, + PermissionFieldsDto permissionFieldsDto) { + + switch (fileType) { + case FILE: + validateFile(permissionFieldsDto, permissionType); + // 이동할 폴더의 값이 있다면 이동 요청일 수 있으므로 권한을 확인한다. + if (permissionFieldsDto.getMoveFolderId() != null) { + validateFileMove(permissionFieldsDto, permissionType); + } + break; + case FOLDER: + validateFolder(permissionFieldsDto, permissionType); + break; + default: + throw ErrorCode.PERMISSION_CHECK_FAILED.baseException("존재하지 않는 파일 타입을 사용했습니다. file type = " + fileType); + } + } + + private void validateFileMove(PermissionFieldsDto permissionFieldsDto, PermissionType permissionType) { + FolderMetadata folderMetadata = null; + + if (Objects.equals(permissionType, PermissionType.READ)) { + folderMetadata = folderMetadataRepository.findById(permissionFieldsDto.getMoveFolderId()) + .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + } else { + folderMetadata = folderMetadataRepository.findByIdForShare(permissionFieldsDto.getMoveFolderId()) + .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + } + + LocalDateTime sharingExpiredAt = folderMetadata.getSharingExpiredAt(); + Long ownerId = folderMetadata.getOwnerId(); + PermissionType sharedPermissionType = folderMetadata.getPermissionType(); + LocalDateTime now = LocalDateTime.now(); + + validateExpiredAndPermission(sharingExpiredAt, now, ownerId, permissionFieldsDto.getUserId(), + sharedPermissionType, permissionType); + } + + /** + * 폴더에 대한 권한을 확인하는 메소드 + * 폴더 이동의 경우 이동할 폴더에 대한 권한도 추가로 검증한다. + * @param permissionFieldsDto 사용자 정보, 파일 정보 등의 요청 데이터가 존재하는 dto + * @param permissionType 컨트롤러에서 필요한 권한 타입(쓰기, 읽기) + */ + private void validateFolder(PermissionFieldsDto permissionFieldsDto, PermissionType permissionType) { + FolderMetadata folderMetadata = null; + + if (Objects.equals(permissionType, PermissionType.READ)) { + folderMetadata = folderMetadataRepository.findById(permissionFieldsDto.getFolderId()) + .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + } else { + folderMetadata = folderMetadataRepository.findByIdForShare(permissionFieldsDto.getFolderId()) + .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + } + + LocalDateTime sharingExpiredAt = folderMetadata.getSharingExpiredAt(); + Long ownerId = folderMetadata.getOwnerId(); + PermissionType sharedPermissionType = folderMetadata.getPermissionType(); + LocalDateTime now = LocalDateTime.now(); + + validateExpiredAndPermission(sharingExpiredAt, now, ownerId, permissionFieldsDto.getUserId(), + sharedPermissionType, permissionType); + + // moveFolderId에 값이 존재하면 이동에 대한 권한을 추가로 확인한다. + if (permissionFieldsDto.getMoveFolderId() != null) { + FolderMetadata moveFolderMetadata = folderMetadataRepository.findByIdForShare( + permissionFieldsDto.getMoveFolderId()).orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + + LocalDateTime movingExpiredAt = moveFolderMetadata.getSharingExpiredAt(); + Long movingOwnerId = moveFolderMetadata.getOwnerId(); + PermissionType movingPermissionType = moveFolderMetadata.getPermissionType(); + validateExpiredAndPermission(movingExpiredAt, now, movingOwnerId, permissionFieldsDto.getUserId(), + movingPermissionType, permissionType); + } + + permissionFieldsDto.setOwnerId(ownerId); + } + + /** + * 파일에 대한 권한을 확인하는 메소드 + * @param permissionFieldsDto 사용자 정보, 파일 정보 등의 요청 데이터가 존재하는 dto + * @param permissionType 컨트롤러에서 필요한 권한 타입(쓰기, 읽기) + */ + private void validateFile(PermissionFieldsDto permissionFieldsDto, PermissionType permissionType) { + FileMetadata fileMetadata = null; + + // 읽기 작업인 경우 락을 걸지 않고 권한을 확인한다. + if (Objects.equals(permissionType, PermissionType.READ)) { + fileMetadata = fileMetadataRepository.findById(permissionFieldsDto.getFileId()) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + } else { + fileMetadata = fileMetadataRepository.findByIdForShare(permissionFieldsDto.getFileId()) + .orElseThrow(ErrorCode.FILE_NOT_FOUND::baseException); + } + + LocalDateTime sharingExpiredAt = fileMetadata.getSharingExpiredAt(); + Long ownerId = fileMetadata.getOwnerId(); + PermissionType sharedPermissionType = fileMetadata.getPermissionType(); + LocalDateTime now = LocalDateTime.now(); + + validateExpiredAndPermission(sharingExpiredAt, now, ownerId, permissionFieldsDto.getUserId(), + sharedPermissionType, permissionType); + + permissionFieldsDto.setOwnerId(ownerId); + } + + /** + * 만료 기간과 쓰기 및 읽기 권한을 검증하는 메소드 + * @param sharingExpiredAt 공유한 파일의 만료 기간 + * @param now 사용자가 접근한 시간 + * @param ownerId 공유한 파일의 소유자 + * @param userId 요청한 사용자 pk + * @param sharedPermissionType 공유한 파일의 권한 타입(쓰기, 읽기) + * @param permissionType 실제 작업에 필요한 권한 타입(쓰기, 읽기) + */ + private void validateExpiredAndPermission(LocalDateTime sharingExpiredAt, LocalDateTime now, Long ownerId, + Long userId, PermissionType sharedPermissionType, PermissionType permissionType) { + // 공유 기한이 지났는데 파일에 대한 소유주가 아닌 경우 예외를 반환한다. + if (sharingExpiredAt.isBefore(now) && !Objects.equals(userId, ownerId)) { + throw ErrorCode.ACCESS_DENIED.baseException(); + } + + // 읽기 권한만 있는 파일에 쓰기 작업을 시도하면 예외를 반환한다. + if (Objects.equals(permissionType, PermissionType.WRITE) && Objects.equals(sharedPermissionType, + PermissionType.READ)) { + throw ErrorCode.ACCESS_DENIED.baseException(); + } + } + + /** + * MultipartUpload에서 사용하는 권한 검증 메소드입니다. + * 우선 요청한 폴더에 대한 권한을 확인합니다. + * 이후 AOP로 파라미터를 처리한 것 처럼 활용하기 위해 onwerId를 리턴합니다. + * @param permissionType 메소드 실행에 필요한 권한 + * @param fileType 검증할 파일의 타입(파일, 폴더) + * @param permissionFieldsDto + * @return + */ + @Transactional + public long getOwnerIdAndCheckPermission(PermissionType permissionType, FileType fileType, + PermissionFieldsDto permissionFieldsDto) { + // 요청에 대한 권한을 먼저 확인한다. + hasPermission(permissionType, fileType, permissionFieldsDto); + FolderMetadata folderMetadata = folderMetadataRepository.findByIdForShare(permissionFieldsDto.getFolderId()) + .orElseThrow(ErrorCode.FOLDER_NOT_FOUND::baseException); + return folderMetadata.getOwnerId(); + } +} diff --git a/src/main/java/com/woowacamp/storage/global/aop/type/FieldType.java b/src/main/java/com/woowacamp/storage/global/aop/type/FieldType.java new file mode 100644 index 0000000..0ecd7a1 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/aop/type/FieldType.java @@ -0,0 +1,13 @@ +package com.woowacamp.storage.global.aop.type; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FieldType { + USER_ID("userId"), FILE_ID("fileId"), FOLDER_ID("folderId"), MOVE_FOLDER_ID("moveFolderId"), CREATOR_ID( + "creatorId"), + ; + private final String value; +} diff --git a/src/main/java/com/woowacamp/storage/global/aop/type/FileType.java b/src/main/java/com/woowacamp/storage/global/aop/type/FileType.java new file mode 100644 index 0000000..f7ba14a --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/aop/type/FileType.java @@ -0,0 +1,5 @@ +package com.woowacamp.storage.global.aop.type; + +public enum FileType { + FILE, FOLDER +} diff --git a/src/main/java/com/woowacamp/storage/global/constant/CommonConstant.java b/src/main/java/com/woowacamp/storage/global/constant/CommonConstant.java index f1bc53b..13ad7bc 100644 --- a/src/main/java/com/woowacamp/storage/global/constant/CommonConstant.java +++ b/src/main/java/com/woowacamp/storage/global/constant/CommonConstant.java @@ -1,5 +1,7 @@ package com.woowacamp.storage.global.constant; +import java.time.LocalDateTime; + public class CommonConstant { public static final Character[] FILE_NAME_BLACK_LIST = {'\\', '/', ':', '*', '?', '"', '<', '>', '|'}; public static final int MAX_FOLDER_DEPTH = 50; @@ -8,6 +10,9 @@ public class CommonConstant { public static final int FILE_WRITER_KEEP_ALIVE_TIME = 10; public static final int FILE_WRITER_QUEUE_SIZE = 400; public static final int ORPHAN_PARENT_ID = -1; - public static final long SHARED_LINK_VALID_TIME = 3; - public static final String SHARED_URI = "/api/v1/share?shareId="; + public static final int SHARED_LINK_VALID_TIME = 3 * 60 * 60; + public static final LocalDateTime UNAVAILABLE_TIME = LocalDateTime.of(1970, 1, 1, 1, 0); + public static final String SHARED_LINK_URI = "/api/v1/share?sharedId="; + public static final String FOLDER_READ_URI = "/api/v1/folders/"; + public static final String FILE_READ_URI = "/api/v1/files/"; } diff --git a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/PermissionType.java b/src/main/java/com/woowacamp/storage/global/constant/PermissionType.java similarity index 83% rename from src/main/java/com/woowacamp/storage/domain/shredlink/entity/PermissionType.java rename to src/main/java/com/woowacamp/storage/global/constant/PermissionType.java index 300f44c..c375378 100644 --- a/src/main/java/com/woowacamp/storage/domain/shredlink/entity/PermissionType.java +++ b/src/main/java/com/woowacamp/storage/global/constant/PermissionType.java @@ -1,4 +1,4 @@ -package com.woowacamp.storage.domain.shredlink.entity; +package com.woowacamp.storage.global.constant; import java.util.Arrays; @@ -10,7 +10,7 @@ @Getter @AllArgsConstructor public enum PermissionType { - WRITE("Write"), READ("Read"); + WRITE("Write"), READ("Read"), NONE("None"); private final String value; public static PermissionType fromValue(String value) { diff --git a/src/main/java/com/woowacamp/storage/global/error/ErrorCode.java b/src/main/java/com/woowacamp/storage/global/error/ErrorCode.java index 6809ddf..d61e055 100644 --- a/src/main/java/com/woowacamp/storage/global/error/ErrorCode.java +++ b/src/main/java/com/woowacamp/storage/global/error/ErrorCode.java @@ -3,7 +3,9 @@ import org.springframework.http.HttpStatus; import lombok.AllArgsConstructor; +import lombok.Getter; +@Getter @AllArgsConstructor public enum ErrorCode { @@ -34,9 +36,12 @@ public enum ErrorCode { INVALID_MULTIPART_FORM_DATA(HttpStatus.BAD_REQUEST, "올바른 데이터 형식으로 요청을 보내주세요."), DUPLICATED_SHARED_LINK(HttpStatus.CONFLICT, "공유 링크 생성에 실패했습니다. 잠시 후에 다시 시도해 주세요."), WRONG_PERMISSION_TYPE(HttpStatus.BAD_REQUEST, "잘못된 권한 타입입니다."), + SHARED_LINK_NOT_FOUND(HttpStatus.NOT_FOUND, "공유 링크를 찾을 수 없습니다."), + EXPIRED_SHARED_LINK(HttpStatus.BAD_REQUEST, "만료된 공유 링크입니다."), // 500, FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."), - FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."); + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제에 실패했습니다."), + PERMISSION_CHECK_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "권한 확인 중 예외가 발생했습니다."); private final HttpStatus status; private final String message; diff --git a/src/main/java/com/woowacamp/storage/global/util/UrlUtil.java b/src/main/java/com/woowacamp/storage/global/util/UrlUtil.java new file mode 100644 index 0000000..b2d4b28 --- /dev/null +++ b/src/main/java/com/woowacamp/storage/global/util/UrlUtil.java @@ -0,0 +1,23 @@ +package com.woowacamp.storage.global.util; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +@Component +public class UrlUtil { + public static String serverDomain; + + @Value("${share.server-domain}") + private String injectedServerDomain; + + @PostConstruct + public void init() { + serverDomain = injectedServerDomain; + } + + public static String getAbsoluteUrl(String relativeUrl) { + return serverDomain + relativeUrl; + } +} diff --git a/src/test/java/com/woowacamp/storage/global/aop/PermissionHandlerTest.java b/src/test/java/com/woowacamp/storage/global/aop/PermissionHandlerTest.java new file mode 100644 index 0000000..482fa6b --- /dev/null +++ b/src/test/java/com/woowacamp/storage/global/aop/PermissionHandlerTest.java @@ -0,0 +1,260 @@ +package com.woowacamp.storage.global.aop; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.woowacamp.storage.domain.file.entity.FileMetadata; +import com.woowacamp.storage.domain.file.repository.FileMetadataRepository; +import com.woowacamp.storage.domain.folder.entity.FolderMetadata; +import com.woowacamp.storage.domain.folder.repository.FolderMetadataRepository; +import com.woowacamp.storage.global.aop.type.FileType; +import com.woowacamp.storage.global.constant.PermissionType; +import com.woowacamp.storage.global.error.CustomException; +import com.woowacamp.storage.global.error.ErrorCode; + +@ExtendWith(MockitoExtension.class) +class PermissionHandlerTest { + + @InjectMocks + PermissionHandler permissionHandler; + @Mock + FolderMetadataRepository folderMetadataRepository; + @Mock + FileMetadataRepository fileMetadataRepository; + + long userId = 1; + long fileId = 1; + long folderId = 1; + long moveFolderId = 2; + LocalDateTime expiredAt = LocalDateTime.of(2025, 1, 1, 0, 0); + LocalDateTime expiredTime = LocalDateTime.of(2024, 1, 1, 0, 0); + + PermissionFieldsDto getPermissionFieldsDto() { + PermissionFieldsDto permissionFieldsDto = new PermissionFieldsDto(); + permissionFieldsDto.setUserId(userId); + permissionFieldsDto.setFileId(fileId); + permissionFieldsDto.setFolderId(folderId); + permissionFieldsDto.setMoveFolderId(moveFolderId); + + return permissionFieldsDto; + } + + FileMetadata.FileMetadataBuilder getFileMetadataBuilder() { + return FileMetadata.builder().id(fileId).ownerId(userId).sharingExpiredAt(expiredAt); + } + + FileMetadata getWriteFileMetadata() { + return getFileMetadataBuilder().permissionType(PermissionType.WRITE).build(); + } + + FileMetadata getReadFileMetadata() { + return getFileMetadataBuilder().permissionType(PermissionType.READ).build(); + } + + FileMetadata getExpiredFileMetadata() { + return getFileMetadataBuilder().permissionType(PermissionType.WRITE).sharingExpiredAt(expiredTime).build(); + } + + FolderMetadata.FolderMetadataBuilder getFolderMetadataBuilder() { + return FolderMetadata.builder().id(folderId).ownerId(userId).sharingExpiredAt(expiredAt); + } + + FolderMetadata getWriteFolderMetadata() { + return getFolderMetadataBuilder().permissionType(PermissionType.WRITE).build(); + } + + FolderMetadata getReadFolderMetadata() { + return getFolderMetadataBuilder().permissionType(PermissionType.READ).build(); + } + + FolderMetadata getExpiredFolderMetadata() { + return getFolderMetadataBuilder().permissionType(PermissionType.WRITE).sharingExpiredAt(expiredTime).build(); + } + + @Nested + @DisplayName("파일 권한 확인 테스트") + class ValidateFile { + + @Test + @DisplayName("접근 권한이 있으면 권한 인증에 성공한다.") + void request_with_valid_permission() { + // Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setUserId(2L); + permissionFieldsDto.setMoveFolderId(null); + given(fileMetadataRepository.findById(fileId)).willReturn(Optional.of(getWriteFileMetadata())); + + // When + permissionHandler.hasPermission(PermissionType.READ, FileType.FILE, permissionFieldsDto); + } + + @Test + @DisplayName("읽기 권한인 파일에 쓰기 작업을 요청한 경우 인증에 실패한다.") + void request_with_read_permission_to_write_permission() { + // Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setUserId(2L); + given(fileMetadataRepository.findByIdForShare(fileId)).willReturn(Optional.of(getReadFileMetadata())); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FILE, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.ACCESS_DENIED.getMessage(), customException.getMessage()); + } + + @Test + @DisplayName("공유 기한이 지난 파일에 대한 요청에서, 소유주가 아닌 경우 예외를 반환한다.") + void request_with_permission_expired_and_not_owner() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setUserId(2L); + given(fileMetadataRepository.findByIdForShare(fileId)).willReturn(Optional.of(getExpiredFileMetadata())); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FILE, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.ACCESS_DENIED.getMessage(), customException.getMessage()); + } + + @Test + @DisplayName("공유 기한이 지났는데 파일에 대한 소유주라면 권한 인증에 성공한다.") + void request_with_permission_expired_and_owner() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setMoveFolderId(null); + given(fileMetadataRepository.findByIdForShare(fileId)).willReturn(Optional.of(getExpiredFileMetadata())); + + // When + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FILE, permissionFieldsDto); + } + + @Test + @DisplayName("존재하지 않는 파일에 대한 요청 시 FILE_NOT_FOUND 예외를 반환한다.") + void request_with_not_exist_file() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + given(fileMetadataRepository.findByIdForShare(fileId)).willReturn(Optional.empty()); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FILE, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.FILE_NOT_FOUND.getMessage(), customException.getMessage()); + } + } + + @Nested + @DisplayName("폴더 권한 확인 테스트") + class ValidateFolder { + + @Test + @DisplayName("접근 권한이 있으면 권한 인증에 성공한다.") + void request_with_valid_permission() { + // Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setMoveFolderId(null); + given(folderMetadataRepository.findById(folderId)).willReturn(Optional.of(getWriteFolderMetadata())); + + // when + permissionHandler.hasPermission(PermissionType.READ, FileType.FOLDER, permissionFieldsDto); + } + + @Test + @DisplayName("읽기 권한인 폴더에 쓰기 작업을 요청한 경우 인증에 실패한다.") + void request_with_read_permission_to_write_permission() { + // Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setUserId(2L); + given(folderMetadataRepository.findByIdForShare(folderId)).willReturn(Optional.of(getReadFolderMetadata())); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FOLDER, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.ACCESS_DENIED.getMessage(), customException.getMessage()); + } + + @Test + @DisplayName("공유 기한이 지난 폴더에 대한 요청에서, 소유주가 아닌 경우 예외를 반환한다.") + void request_with_permission_expired_and_not_owner() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setUserId(2L); + given(folderMetadataRepository.findByIdForShare(folderId)).willReturn( + Optional.of(getExpiredFolderMetadata())); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FOLDER, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.ACCESS_DENIED.getMessage(), customException.getMessage()); + } + + @Test + @DisplayName("공유 기한이 지났지만 파일에 대한 소유주라면 권한 인증에 성공한다.") + void request_with_permission_expired_and_owner() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + permissionFieldsDto.setMoveFolderId(null); + given(folderMetadataRepository.findByIdForShare(folderId)).willReturn( + Optional.of(getExpiredFolderMetadata())); + + // When + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FOLDER, permissionFieldsDto); + } + + @Test + @DisplayName("폴더 이동 요청에서는 이동할 폴더에 대한 권한까지 존재해야 이동에 성공한다.") + void request_with_move_folder() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + given(folderMetadataRepository.findByIdForShare(folderId)).willReturn( + Optional.of(getExpiredFolderMetadata())); + given(folderMetadataRepository.findByIdForShare(moveFolderId)).willReturn( + Optional.of(getExpiredFolderMetadata())); + + // When + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FOLDER, permissionFieldsDto); + } + + @Test + @DisplayName("존재하지 않는 폴더에 대한 요청 시 FOLDER_NOT_FOUND 예외를 반환한다..") + void request_with_not_exist_folder() { + //Given + PermissionFieldsDto permissionFieldsDto = getPermissionFieldsDto(); + given(folderMetadataRepository.findByIdForShare(folderId)).willReturn(Optional.empty()); + + // When + CustomException customException = assertThrows(CustomException.class, () -> { + permissionHandler.hasPermission(PermissionType.WRITE, FileType.FOLDER, permissionFieldsDto); + }); + + // Then + assertEquals(ErrorCode.FOLDER_NOT_FOUND.getMessage(), customException.getMessage()); + } + + } +}