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()); + } + + } +}