Skip to content

Commit

Permalink
Feature/chung step5 (#12)
Browse files Browse the repository at this point in the history
# 구현 기능
- 파일 공유 링크 생성 / 다운로드
- 링크 일괄 삭제

## 파일 공유 링크 생성
- UUID.randomUUID 기반 링크 생성
- 링크 중복 검사

## 파일 공유 다운로드
- 링크 유효 기간은 3시간
- 유효 기간 내 요청만 다운로드 가능
- 파일 공유 이후 원본 파일 삭제 발생 시 다운 불가능
- 스케줄러를 통해 유효하지 않은 링크 삭제 ( 5분 단위)
  • Loading branch information
kochungcheon authored May 29, 2024
1 parent 0415c16 commit 9919fac
Show file tree
Hide file tree
Showing 19 changed files with 549 additions and 132 deletions.
50 changes: 27 additions & 23 deletions src/main/java/com/c4cometrue/mystorage/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,41 @@
@Getter
@AllArgsConstructor
public enum ErrorCode {
UNAUTHORIZED_FILE_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."),
UNAUTHORIZED_FILE_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."),

CANNOT_FOUND_FILE(HttpStatus.NOT_FOUND, "해당 파일을 찾을 수 없습니다."),
CANNOT_FOUND_FILE(HttpStatus.NOT_FOUND, "해당 파일을 찾을 수 없습니다."),

FILE_COPY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 복사 중 오류가 발생했습니다."),
FILE_COPY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 복사 중 오류가 발생했습니다."),

FILE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."),
FILE_DELETE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "파일 삭제 중 오류가 발생했습니다."),

DUPLICATE_FILE_NAME(HttpStatus.BAD_REQUEST, "파일 업로드에 중복이 발생 했습니다"),
DUPLICATE_FILE_NAME(HttpStatus.BAD_REQUEST, "파일 업로드에 중복이 발생 했습니다"),

FOLDER_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다"),
UNAUTHORIZED_FOLDER_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."),
DUPLICATE_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 업로드에 중복이 발생 했습니다"),
DUPLICATE_SERVER_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 UUID 중복이 발생 했습니다"),
CANNOT_FOUND_FOLDER(HttpStatus.NOT_FOUND, "해당 폴더를 찾을 수 없습니다."),
FOLDER_CREATE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "폴더 생성 중 오류가 발생했습니다"),
UNAUTHORIZED_FOLDER_ACCESS(HttpStatus.FORBIDDEN, "비정상적인 요청입니다."),
DUPLICATE_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 업로드에 중복이 발생 했습니다"),
DUPLICATE_SERVER_FOLDER_NAME(HttpStatus.BAD_REQUEST, "폴더 UUID 중복이 발생 했습니다"),
CANNOT_FOUND_FOLDER(HttpStatus.NOT_FOUND, "해당 폴더를 찾을 수 없습니다."),

DUPLICATE_BASE_PATH(HttpStatus.BAD_REQUEST, "기본 경로 생성에 중복이 발생했습니다"),
DUPLICATE_BASE_PATH(HttpStatus.BAD_REQUEST, "기본 경로 생성에 중복이 발생했습니다"),

MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 맴버를 찾지 못했습니다"),
EXCEEDED_CAPACITY(HttpStatus.INSUFFICIENT_STORAGE, "더 이상 업로드 할 수 없습니다"),
INVALID_OPERATION(HttpStatus.BAD_REQUEST, "사용 중인 공간보다 많은 공간은 해제할 수 없습니다"),
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다.");
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 맴버를 찾지 못했습니다"),
EXCEEDED_CAPACITY(HttpStatus.INSUFFICIENT_STORAGE, "더 이상 업로드 할 수 없습니다"),
INVALID_OPERATION(HttpStatus.BAD_REQUEST, "사용 중인 공간보다 많은 공간은 해제할 수 없습니다"),
VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "유효하지 않은 요청입니다."),

private final HttpStatus httpStatus;
private final String message;
DUPLICATE_SHARE_LINK(HttpStatus.INTERNAL_SERVER_ERROR, "링크 생성에 중복이 발생했습니다"),
NOT_FOUND_SHARE_LINK(HttpStatus.NOT_FOUND, "링크를 찾을 수 없습니다"),
NOT_FRESH_LINK(HttpStatus.BAD_REQUEST, "만료된 링크입니다.");

public ServiceException serviceException() {
return new ServiceException(this.name(), message);
}
private final HttpStatus httpStatus;
private final String message;

public ServiceException serviceException(String debugMessage, Object... debugMessageArgs) {
return new ServiceException(this.name(), message, String.format(debugMessage, debugMessageArgs));
}
public ServiceException serviceException() {
return new ServiceException(this.name(), message);
}

public ServiceException serviceException(String debugMessage, Object... debugMessageArgs) {
return new ServiceException(this.name(), message, String.format(debugMessage, debugMessageArgs));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,55 @@
@Service
@RequiredArgsConstructor
public class FileDataHandlerService {
private final FileRepository fileRepository;

@Transactional
public void deleteBy(Long fileId) {
existBy(fileId);
fileRepository.deleteById(fileId);
}

private void existBy(Long fileId) {
if (!fileRepository.existsById(fileId)) {
throw ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}", fileId);
}
}

public FileMetadata findBy(Long fileId, Long userId) {
return fileRepository.findByIdAndUploaderId(fileId, userId)
.orElseThrow(() -> ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}, userId : {}", fileId,
userId));
}

public void persist(FileMetadata fileMetadata) {
fileRepository.save(fileMetadata);
}

public void duplicateBy(Long parentId, String fileName) {
if (fileRepository.existsByParentIdAndOriginalFileName(parentId, fileName)) {
throw ErrorCode.DUPLICATE_FILE_NAME.serviceException("fileName : {}", fileName);
}
}

public List<FileMetadata> getFileList(Long parentId, Long cursorId, Long userId, Pageable page) {
return cursorId == null ? fileRepository.findAllByParentIdAndUploaderIdOrderByIdDesc(parentId, userId, page)
: fileRepository.findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(parentId, cursorId, userId, page);
}

public Boolean hashNext(Long parentId, Long userId, Long lastIdOfList) {
return fileRepository.existsByParentIdAndUploaderIdAndIdLessThan(parentId, userId, lastIdOfList);
}

public List<FileMetadata> findAllBy(Long parentId) {
return fileRepository.findAllByParentId(parentId);
}

public void deleteAll(List<FileMetadata> fileMetadataList) {
fileRepository.deleteAll(fileMetadataList);
}
private final FileRepository fileRepository;

@Transactional
public void deleteBy(Long fileId) {
existBy(fileId);
fileRepository.deleteById(fileId);
}

private void existBy(Long fileId) {
if (!fileRepository.existsById(fileId)) {
throw ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}", fileId);
}
}

public FileMetadata findBy(Long fileId, Long userId) {
return fileRepository.findByIdAndUploaderId(fileId, userId)
.orElseThrow(() -> ErrorCode.CANNOT_FOUND_FILE.serviceException("fileId : {}, userId : {}", fileId,
userId));
}

public void persist(FileMetadata fileMetadata) {
fileRepository.save(fileMetadata);
}

public void duplicateBy(Long parentId, String fileName) {
if (fileRepository.existsByParentIdAndOriginalFileName(parentId, fileName)) {
throw ErrorCode.DUPLICATE_FILE_NAME.serviceException("fileName : {}", fileName);
}
}

public List<FileMetadata> getFileList(Long parentId, Long cursorId, Long userId, Pageable page) {
return cursorId == null ? fileRepository.findAllByParentIdAndUploaderIdOrderByIdDesc(parentId, userId, page)
: fileRepository.findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(parentId, cursorId, userId, page);
}

public Boolean hashNext(Long parentId, Long userId, Long lastIdOfList) {
return fileRepository.existsByParentIdAndUploaderIdAndIdLessThan(parentId, userId, lastIdOfList);
}

public List<FileMetadata> findAllBy(Long parentId) {
return fileRepository.findAllByParentId(parentId);
}

public void deleteAll(List<FileMetadata> fileMetadataList) {
fileRepository.deleteAll(fileMetadataList);
}

// 파일이 삭제된 경우에는 파일 공유 링크로 다운 받을 수 없다.
public FileMetadata findBy(Long fileId) {
return fileRepository.findById(fileId).orElseThrow(ErrorCode.CANNOT_FOUND_FILE::serviceException);
}
}
19 changes: 9 additions & 10 deletions src/main/java/com/c4cometrue/mystorage/file/FileRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,22 @@

import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface FileRepository extends JpaRepository<FileMetadata, Long> {
Optional<FileMetadata> findByIdAndUploaderId(Long id, Long uploaderId);
Optional<FileMetadata> findByIdAndUploaderId(Long id, Long uploaderId);

boolean existsByParentIdAndOriginalFileName(Long parentId, String fileName);
boolean existsByParentIdAndOriginalFileName(Long parentId, String fileName);

List<FileMetadata> findByParentIdAndUploaderId(Long parentId, Long userId);
List<FileMetadata> findByParentIdAndUploaderId(Long parentId, Long userId);

Boolean existsByIdAndUploaderId(Long parentId, Long userId);
Boolean existsByIdAndUploaderId(Long parentId, Long userId);

List<FileMetadata> findAllByParentIdAndUploaderIdOrderByIdDesc(Long parentId, Long uploaderId, Pageable page);
List<FileMetadata> findAllByParentIdAndUploaderIdOrderByIdDesc(Long parentId, Long uploaderId, Pageable page);

List<FileMetadata> findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(Long parentId, Long userId, Long cursorId,
Pageable pageable);
List<FileMetadata> findByParentIdAndUploaderIdAndIdLessThanOrderByIdDesc(Long parentId, Long userId, Long cursorId,
Pageable pageable);

Boolean existsByParentIdAndUploaderIdAndIdLessThan(Long parentId, Long uploaderId, Long id);
Boolean existsByParentIdAndUploaderIdAndIdLessThan(Long parentId, Long uploaderId, Long id);

List<FileMetadata> findAllByParentId(Long parentId);
List<FileMetadata> findAllByParentId(Long parentId);
}
5 changes: 1 addition & 4 deletions src/main/java/com/c4cometrue/mystorage/file/FileService.java
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,7 @@ protected void uploadFile(Long userId, Long rootId, BigDecimal fileSize, FileMet

public void downloadFile(Long fileId, String userPath, Long userId) {
FileMetadata fileMetadata = fileDataHandlerService.findBy(fileId, userId);
Path originalPath = Paths.get(fileMetadata.getFilePath());
Path userDesignatedPath = Paths.get(userPath).resolve(fileMetadata.getOriginalFileName()).normalize();

FileUtil.download(originalPath, userDesignatedPath, bufferSize);
FileUtil.download(fileMetadata, userPath);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.c4cometrue.mystorage.fileshare;

import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.List;

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class DeleteLinkScheduler {
private static final int DELETE_SIZE = 100;

private final FileShareRepository fileShareRepository;

@Scheduled(cron = "*/5 * * * * ?")
public void deleteExpiredLinks() {
ZonedDateTime expirationTime = ZonedDateTime.now(ZoneOffset.UTC).minusHours(3);
List<Long> expirationLinkIds;

do {
expirationLinkIds = fileShareRepository.deleteByExpirations(expirationTime, DELETE_SIZE);
} while (expirationLinkIds.size() == DELETE_SIZE);
}
}
46 changes: 46 additions & 0 deletions src/main/java/com/c4cometrue/mystorage/fileshare/FileShare.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.c4cometrue.mystorage.fileshare;

import java.time.ZoneOffset;
import java.time.ZonedDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "file_share", indexes = {
@Index(name = "idx_share_link", columnList = "shareLink"),
@Index(name = "idx_share_created_at", columnList = "createAt")
})
public class FileShare {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long fileId;
@Column(nullable = false, unique = true)
private String shareLink;
@Column(updatable = false)
private ZonedDateTime createdAt;

@Builder
public FileShare(Long fileId, String shareLink) {
this.fileId = fileId;
this.shareLink = shareLink;
}

@PrePersist
public void prePersist() {
createdAt = ZonedDateTime.now(ZoneOffset.UTC);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.c4cometrue.mystorage.fileshare;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
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.RestController;

import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileReq;
import com.c4cometrue.mystorage.fileshare.dto.CreateShareFileRes;
import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileReq;
import com.c4cometrue.mystorage.fileshare.dto.DownloadShareFileRes;

import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/files")
@RequiredArgsConstructor
public class FileShareController {
private final FileShareService fileShareService;

@PostMapping("/share")
public ResponseEntity<CreateShareFileRes> createShareFile(@Valid @RequestBody CreateShareFileReq req) {
CreateShareFileRes res = fileShareService.createShareFileLink(req.fileId(), req.userId());
return ResponseEntity.ok(res);
}

@GetMapping("/share/download")
public ResponseEntity<DownloadShareFileRes> downloadShareFile(@Valid @ModelAttribute DownloadShareFileReq req) {
DownloadShareFileRes res = fileShareService.downloadShareFile(req.shareLink(), req.userPath());
return ResponseEntity.ok(res);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.c4cometrue.mystorage.fileshare;

import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface FileShareRepository extends JpaRepository<FileShare, Long> {
boolean existsByShareLink(String shareLink);

Optional<FileShare> findByShareLink(String shareLink);

@Query(value = "DELETE FROM FileShare fs WHERE fs.createdAt < :expirationTime LIMIT :limit", nativeQuery = true)
List<Long> deleteByExpirations(@Param("expirationTime") ZonedDateTime expirationTime, @Param("limit") int limit);

@Query("DELETE FROM FileShare fs WHERE fs.id IN :ids")
void deleteByIds(@Param("ids") List<Long> ids);
}
Loading

0 comments on commit 9919fac

Please sign in to comment.