diff --git a/docker-compose.yaml b/docker-compose.yaml index 13e1b65..d3f9924 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,6 +22,9 @@ services: build: . container_name: app-backend environment: + FILE_STORAGE_PATH: /data/uploads + MAX_FILE_SIZE: 1073741824 + FILE_CHUNK_SIZE: 5242880 POSTGRES_DB: no_copy_ POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -37,6 +40,8 @@ services: - app - backend - api + volumes: + - ./uploads:/data/uploads:rw grafana: image: grafana/grafana:10.3.1 @@ -141,6 +146,7 @@ volumes: loki_chunks: loki_index: loki_rules: +# uploads_volume: networks: app-network: diff --git a/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java new file mode 100644 index 0000000..5b3c744 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/configuration/HandlerConfig.java @@ -0,0 +1,30 @@ +package ru.soune.nocopy.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import ru.soune.nocopy.handler.*; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +public class HandlerConfig { + + @Bean + public Map handlers( + RegRequestHandler reg, + LoginRequestHandler login, + FileUploadHandler upload, + FileEntityHandler file, + LogoutRequestHandler logoutHandler + ) { + Map map = new HashMap<>(); + map.put(20001, login); + map.put(20002, reg); + map.put(20004, upload); + map.put(20005, file); + map.put(20006, logoutHandler); + + return map; + } +} diff --git a/src/main/java/ru/soune/nocopy/configuration/file/AsyncConfig.java b/src/main/java/ru/soune/nocopy/configuration/file/AsyncConfig.java new file mode 100644 index 0000000..6882014 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/configuration/file/AsyncConfig.java @@ -0,0 +1,24 @@ +package ru.soune.nocopy.configuration.file; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "fileUploadTaskExecutor") + public Executor fileUploadTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(3); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("FileUpload-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/ru/soune/nocopy/controller/ApiController.java b/src/main/java/ru/soune/nocopy/controller/ApiController.java index 5c8c457..9ecad18 100644 --- a/src/main/java/ru/soune/nocopy/controller/ApiController.java +++ b/src/main/java/ru/soune/nocopy/controller/ApiController.java @@ -1,24 +1,38 @@ package ru.soune.nocopy.controller; -import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; import ru.soune.nocopy.dto.BaseRequest; import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; import ru.soune.nocopy.dto.RegAnswer; +import ru.soune.nocopy.dto.file.ChunkUploadResponse; +import ru.soune.nocopy.dto.file.CompleteUploadResponse; +import ru.soune.nocopy.dto.file.FileEntityResponse; +import ru.soune.nocopy.dto.file.UploadProgress; +import ru.soune.nocopy.entity.AuthToken; +import ru.soune.nocopy.entity.file.UploadStatus; +import ru.soune.nocopy.exception.NotFoundAuthToken; import ru.soune.nocopy.exception.NotValidFieldException; import ru.soune.nocopy.exception.ValidationException; -import ru.soune.nocopy.handler.LoginRequestHandler; -import ru.soune.nocopy.handler.LogoutRequestHandler; -import ru.soune.nocopy.handler.RegRequestHandler; -import ru.soune.nocopy.handler.RequestHandler; +import ru.soune.nocopy.handler.*; +import ru.soune.nocopy.repository.AuthTokenRepository; +import ru.soune.nocopy.service.file.FileEntityService; +import ru.soune.nocopy.service.file.FileUploadService; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,20 +43,13 @@ import java.util.stream.Collectors; @RequestMapping("/api") @RequiredArgsConstructor public class ApiController { - private final ApplicationContext applicationContext; + private final FileUploadService fileUploadService; - private Map handlers = new HashMap<>(); + private final Map handlers; - @PostConstruct - public void init() { - RegRequestHandler regHandler = applicationContext.getBean(RegRequestHandler.class); - LoginRequestHandler loginHandler = applicationContext.getBean(LoginRequestHandler.class); - LogoutRequestHandler logoutHandler = applicationContext.getBean(LogoutRequestHandler.class); + private final FileEntityService fileEntityService; - handlers.put(20001, loginHandler); - handlers.put(20002, regHandler); - handlers.put(20006, logoutHandler); - } + private final AuthTokenRepository authTokenRepository; @PostMapping("/v{version}/data") public ResponseEntity handlePostRequest(@RequestBody BaseRequest request, @@ -79,6 +86,266 @@ public class ApiController { } } + @PostMapping("/v{version}/files/chunk") + public ResponseEntity uploadChunk( + @PathVariable("version") int version, + @RequestParam("upload_id") String uploadId, + @RequestParam("chunk_number") Integer chunkNumber, + @RequestParam("chunk") MultipartFile chunk) { + + log.info("Uploading chunk {} for session {}, file size: {} bytes, version: {}", + chunkNumber, uploadId, chunk.getSize(), version); + + if (chunk.isEmpty()) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Chunk file is empty", null)); + } + + if (uploadId == null || uploadId.isBlank()) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Upload ID is required", null)); + } + + if (chunkNumber == null || chunkNumber < 0) { + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Valid chunk number is required", null)); + } + + try { + fileUploadService.uploadChunk(uploadId, chunkNumber, chunk); + + ChunkUploadResponse responseBody = ChunkUploadResponse.builder() + .uploadId(uploadId) + .chunkNumber(chunkNumber) + .chunkSize(chunk.getSize()) + .message("Chunk uploaded successfully") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "Chunk uploaded successfully", responseBody)); + + } catch (Exception e) { + log.error("Error uploading chunk", e); + + ChunkUploadResponse responseBody = ChunkUploadResponse.builder() + .uploadId(uploadId) + .chunkNumber(chunkNumber) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to upload chunk: " + e.getMessage(), responseBody)); + } + } + + @GetMapping("/v{version}/files/progress/{uploadId}") + public ResponseEntity getUploadProgress( + @PathVariable("version") int version, + @PathVariable String uploadId) { + + log.info("Getting progress for upload session: {}, version: {}", uploadId, version); + + try { + var progress = fileUploadService.getUploadProgress(uploadId); + + UploadProgress responseBody = UploadProgress.builder() + .uploadId(progress.getUploadId()) + .fileName(progress.getFileName()) + .totalChunks(progress.getTotalChunks()) + .uploadedChunks(progress.getUploadedChunks()) + .status(progress.getStatus().toString()) + .progressPercentage(progress.getProgressPercentage()) + .filePath(progress.getFilePath()) + .remainingChunks(progress.getTotalChunks() - progress.getUploadedChunks()) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), responseBody)); + + } catch (Exception e) { + log.error("Error getting progress for upload: {}", uploadId, e); + + UploadProgress responseBody = UploadProgress.builder() + .uploadId(uploadId) + .build(); + + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get upload progress: " + e.getMessage(), responseBody)); + } + } + + @PostMapping("/v{version}/files/{uploadId}/complete") + public ResponseEntity completeUpload( + @PathVariable("version") int version, + @PathVariable String uploadId) { + + log.info("Completing upload session: {}, version: {}", uploadId, version); + + try { + var progress = fileUploadService.getUploadProgress(uploadId); + + if (progress.getStatus() == UploadStatus.COMPLETED) { + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("Upload already completed") + .filePath(progress.getFilePath()) + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "Upload already completed", responseBody)); + } + + if (progress.getUploadedChunks() < progress.getTotalChunks()) { + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("Not all chunks uploaded") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.INCOMPLETE_UPLOAD.getCode(), + "Not all chunks uploaded", responseBody)); + } + + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(progress.getUploadId()) + .status(progress.getStatus().toString()) + .uploadedChunks(progress.getUploadedChunks()) + .totalChunks(progress.getTotalChunks()) + .message("File assembly in progress") + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.SUCCESS.getCode(), + "File assembly in progress", responseBody)); + + } catch (Exception e) { + log.error("Error completing upload: {}", uploadId, e); + + CompleteUploadResponse responseBody = CompleteUploadResponse.builder() + .uploadId(uploadId) + .build(); + + return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to complete upload: " + e.getMessage(), responseBody)); + } + } + + + @GetMapping("/v{version}/files/download/{fileId}") + public ResponseEntity downloadFile( + @PathVariable String fileId, + @PathVariable Integer version, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId, version); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + if (!fileInfo.isExistsOnDisk()) { + return ResponseEntity.status(404) + .body(null); + } + + Path filePath = Paths.get(fileInfo.getFilePath()); + Resource resource = new UrlResource(filePath.toUri()); + + if (!resource.exists()) { + return ResponseEntity.status(404).build(); + } + + String contentType = determineContentType(filePath); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + fileInfo.getOriginalFileName() + "\"") + .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(fileInfo.getFileSize())) + .body(resource); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error downloading file", e); + return ResponseEntity.status(500).build(); + } + } + + @GetMapping("/v{version}/files/info/{fileId}") + public ResponseEntity getFileInfo( + @PathVariable String fileId, + @PathVariable Integer version, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId, version); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + return ResponseEntity.ok(fileInfo); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error getting file info", e); + return ResponseEntity.status(500).build(); + } + } + + @GetMapping("/v{version}/files/preview/{fileId}") + public ResponseEntity previewFile( + @PathVariable String fileId, + @PathVariable Integer version, + @RequestHeader("Authorization") String tokenHeader) { + + try { + Long userId = getUserIdFromToken(tokenHeader); + FileEntityResponse fileInfo = fileEntityService.getById(fileId, version); + + if (!fileInfo.getUserId().equals(userId)) { + return ResponseEntity.status(403).build(); + } + + if (!isPreviewSupported(fileInfo.getMimeType())) { + return ResponseEntity.status(415) + .body(null); + } + + Path filePath = Paths.get(fileInfo.getFilePath()); + Resource resource = new UrlResource(filePath.toUri()); + + if (!resource.exists()) { + return ResponseEntity.status(404).build(); + } + + String contentType = determineContentType(filePath); + + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .header(HttpHeaders.CONTENT_DISPOSITION, + "inline; filename=\"" + fileInfo.getOriginalFileName() + "\"") + .body(resource); + + } catch (NotFoundAuthToken e) { + return ResponseEntity.status(401).build(); + } catch (Exception e) { + log.error("Error previewing file", e); + return ResponseEntity.status(500).build(); + } + } + + private ResponseEntity createValidationErrorResponse(BindingResult bindingResult, Integer msgId) { List> fieldErrors = bindingResult.getFieldErrors() .stream() @@ -105,4 +372,32 @@ public class ApiController { return errorDetail; } + + + private String determineContentType(Path filePath) throws IOException { + String contentType = Files.probeContentType(filePath); + if (contentType == null) { + contentType = "application/octet-stream"; + } + return contentType; + } + + private boolean isPreviewSupported(String mimeType) { + if (mimeType == null) { + return false; + } + + return mimeType.startsWith("image/") || + mimeType.startsWith("text/") || + mimeType.equals("application/pdf") || + mimeType.startsWith("video/") || + mimeType.startsWith("audio/"); + } + + private Long getUserIdFromToken(String tokenHeader) { + String token = tokenHeader.replace("Bearer ", ""); + AuthToken authToken = authTokenRepository.findByToken(token) + .orElseThrow(() -> new NotFoundAuthToken("Token not found")); + return authToken.getUser().getId(); + } } \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/AuthResponse.java b/src/main/java/ru/soune/nocopy/dto/AuthResponse.java new file mode 100644 index 0000000..168e09a --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/AuthResponse.java @@ -0,0 +1,10 @@ +package ru.soune.nocopy.dto; + +import java.time.LocalDateTime; + +public record AuthResponse ( + boolean success, + String message, + String token, + LocalDateTime expiresAt +) {} diff --git a/src/main/java/ru/soune/nocopy/dto/MessageCode.java b/src/main/java/ru/soune/nocopy/dto/MessageCode.java index fd0113c..a3ac162 100644 --- a/src/main/java/ru/soune/nocopy/dto/MessageCode.java +++ b/src/main/java/ru/soune/nocopy/dto/MessageCode.java @@ -5,12 +5,18 @@ public enum MessageCode { REG_EMAIL_EXISTS(1, "Email already registered"), REG_EMAIL_OR_PHONE_EXISTS(1, "Email or phone already registered"), INVALID_FIELD(2, "Invalid field"), + INVALID_TOKEN(2, "Invalid token"), + INVALID_ACTION(2, "Invalid action"), + FILE_UPLOAD_ERROR(2, "File upload error"), INVALID_JSON_BODY(2, "Invalid fields in JSON object"), + INCOMPLETE_UPLOAD(2, "Not load all chunks"), MSG_ID_NOT_FOUND(4, "Message id not found"), - + FILE_ENTITY_ERROR(2, "File entity error"), + ACCESS_DENIED(2, "Access denied"), AUTH_EMAIL_NOT_FOUND(4, "Email not found"), AUTH_EMAIL_OR_TOKEN_NOT_FOUND(4, "Email or Token not found "), AUTH_TOKEN_MISMATCH(4, "Token mismatch"), + FILE_NOT_FOUND(4, "File not found"), AUTH_PASSWORD_NOT_MATCHES(2, "Password does not match"); private final Integer code; diff --git a/src/main/java/ru/soune/nocopy/dto/UserContentResponse.java b/src/main/java/ru/soune/nocopy/dto/UserContentResponse.java index 9257b05..8c14615 100644 --- a/src/main/java/ru/soune/nocopy/dto/UserContentResponse.java +++ b/src/main/java/ru/soune/nocopy/dto/UserContentResponse.java @@ -1,7 +1,7 @@ package ru.soune.nocopy.dto; import ru.soune.nocopy.entity.ContentStatus; -import ru.soune.nocopy.entity.FileType; +import ru.soune.nocopy.entity.file.FileType; import java.time.LocalDateTime; diff --git a/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java new file mode 100644 index 0000000..f492b6c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ActionResponse.java @@ -0,0 +1,17 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ActionResponse { + private List availableActions; + private String action; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java new file mode 100644 index 0000000..99dc43e --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CanceledUploadResponse.java @@ -0,0 +1,15 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class CanceledUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java new file mode 100644 index 0000000..df78fd8 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ChunkStatusResponse.java @@ -0,0 +1,28 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@Builder +public class ChunkStatusResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("chunk_status") + private Map chunkStatus; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("missing_chunks") + private Integer missingChunks; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java new file mode 100644 index 0000000..09bce18 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java @@ -0,0 +1,25 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ChunkUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("chunk_number") + private Integer chunkNumber; + + @JsonProperty("chunk_size") + private Long chunkSize; + + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java new file mode 100644 index 0000000..0275520 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/CompleteUploadResponse.java @@ -0,0 +1,31 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CompleteUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("status") + private String status; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("message") + private String message; + + @JsonProperty("file_path") + private String filePath; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/file/DeleteFileResponse.java b/src/main/java/ru/soune/nocopy/dto/file/DeleteFileResponse.java new file mode 100644 index 0000000..c45163e --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/DeleteFileResponse.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class DeleteFileResponse { + @JsonProperty("file_id") + private String fileId; + + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileApiResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileApiResponse.java new file mode 100644 index 0000000..9feb546 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileApiResponse.java @@ -0,0 +1,41 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileApiResponse { + + private boolean success; + private String message; + private T data; + private String error; + + public static FileApiResponse success(T data) { + return FileApiResponse.builder() + .success(true) + .message("Operation completed successfully") + .data(data) + .build(); + } + + public static FileApiResponse success(String message, T data) { + return FileApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + public static FileApiResponse error(String error) { + return FileApiResponse.builder() + .success(false) + .error(error) + .build(); + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java new file mode 100644 index 0000000..4682dac --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java @@ -0,0 +1,34 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FileEntityRequest { + @JsonProperty("action") + private String action; + + @JsonProperty("file_id") + private String fileId; + + @JsonProperty("upload_session_id") + private String uploadSessionId; + + @JsonProperty("query") + private String query; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("page_size") + private Integer pageSize; + + @JsonProperty("token") + private String token; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java new file mode 100644 index 0000000..3e89725 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java @@ -0,0 +1,33 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.soune.nocopy.entity.file.FileStatus; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileEntityResponse { + + private String id; + private Long userId; + private String originalFileName; + private String storedFileName; + private String filePath; + private Long fileSize; + private String mimeType; + private String fileExtension; + private String checksum; + private String uploadSessionId; + private FileStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String formattedSize; + private String downloadUrl; + private boolean existsOnDisk; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileListResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileListResponse.java new file mode 100644 index 0000000..bbc5a5f --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileListResponse.java @@ -0,0 +1,33 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class FileListResponse { + @JsonProperty("files") + private List files; + + @JsonProperty("total_count") + private Integer totalCount; + + @JsonProperty("total_size") + private Long totalSize; + + @JsonProperty("formatted_total_size") + private String formattedTotalSize; + + @JsonProperty("page") + private Integer page; + + @JsonProperty("page_size") + private Integer pageSize; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileResponse.java new file mode 100644 index 0000000..4786748 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileResponse.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.dto.file; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FileResponse { + + private List files; + private int totalCount; + private long totalSize; + private String formattedTotalSize; + private int page; + private int pageSize; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java new file mode 100644 index 0000000..e4b8af9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileTypesResponse.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; +import ru.soune.nocopy.entity.file.FileType; + +import java.util.List; + +@Data +@Builder +public class FileTypesResponse { + @JsonProperty("file_types") + private List fileTypes; + + @JsonProperty("count") + private Integer count; +} + diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java b/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java new file mode 100644 index 0000000..3b72bb4 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/FileUploadRequest.java @@ -0,0 +1,31 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class FileUploadRequest { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("file_type") + private String fileType; + + @JsonProperty("extension") + private String extension; + + @JsonProperty("file_size") + private Long fileSize; + + @JsonProperty("chunk_number") + private Integer chunkNumber; + + @JsonProperty("action") + private String action; + + @JsonProperty("token") + private String token; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java b/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java new file mode 100644 index 0000000..0c16a41 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/InitFileResponse.java @@ -0,0 +1,30 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +public class InitFileResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("chunk_size") + private Long chunkSize; + + @JsonProperty("status") + private String status; + + @JsonProperty("field_errors") + private List> fieldErrors; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/InitUploadRequest.java b/src/main/java/ru/soune/nocopy/dto/file/InitUploadRequest.java new file mode 100644 index 0000000..5f43124 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/InitUploadRequest.java @@ -0,0 +1,23 @@ +package ru.soune.nocopy.dto.file; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +@Data +public class InitUploadRequest { + + @NotBlank(message = "File name is required") + private String fileName; + + @NotBlank(message = "File type is required") + private String fileType; + + @NotBlank(message = "File extension is required") + private String extension; + + @NotNull(message = "File size is required") + @Positive(message = "File size must be positive") + private Long fileSize; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java new file mode 100644 index 0000000..33d02de --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/RetryUploadResponse.java @@ -0,0 +1,18 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class RetryUploadResponse { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("message") + private String message; + + @JsonProperty("status") + private String status; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/StorageUsageResponse.java b/src/main/java/ru/soune/nocopy/dto/file/StorageUsageResponse.java new file mode 100644 index 0000000..c4ae5ec --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/StorageUsageResponse.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class StorageUsageResponse { + @JsonProperty("user_id") + private Long userId; + + @JsonProperty("storage_used") + private Long storageUsed; + + @JsonProperty("formatted_storage_used") + private String formattedStorageUsed; +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java b/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java new file mode 100644 index 0000000..7fc1ac2 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/UploadProgress.java @@ -0,0 +1,40 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Builder +@Data +@AllArgsConstructor +@NoArgsConstructor +public class UploadProgress { + @JsonProperty("upload_id") + private String uploadId; + + @JsonProperty("file_name") + private String fileName; + + @JsonProperty("total_chunks") + private Integer totalChunks; + + @JsonProperty("uploaded_chunks") + private Integer uploadedChunks; + + @JsonProperty("status") + private String status; + + @JsonProperty("progress_percentage") + private Integer progressPercentage; + + @JsonProperty("file_path") + private String filePath; + + @JsonProperty("remaining_chunks") + private Integer remainingChunks; + + @JsonProperty("message") + private String message; +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/dto/file/UploadProgressResponse.java b/src/main/java/ru/soune/nocopy/dto/file/UploadProgressResponse.java new file mode 100644 index 0000000..f5e343d --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/UploadProgressResponse.java @@ -0,0 +1,39 @@ +package ru.soune.nocopy.dto.file; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.entity.file.UploadStatus; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UploadProgressResponse { + + private String uploadId; + private String fileName; + private Integer totalChunks; + private Integer uploadedChunks; + private UploadStatus status; + private Integer progressPercentage; + private String filePath; + + public static UploadProgressResponse fromSession(FileUploadSession session) { + int progress = session.getTotalChunks() == 0 ? 0 : + (session.getChunksUploaded() * 100) / session.getTotalChunks(); + + return UploadProgressResponse.builder() + .uploadId(session.getUploadId()) + .fileName(session.getFileName()) + .totalChunks(session.getTotalChunks()) + .uploadedChunks(session.getChunksUploaded()) + .status(session.getStatus()) + .progressPercentage(progress) + .filePath(session.getFilePath()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/entity/UserContent.java b/src/main/java/ru/soune/nocopy/entity/UserContent.java index 4c7fe9a..1691926 100644 --- a/src/main/java/ru/soune/nocopy/entity/UserContent.java +++ b/src/main/java/ru/soune/nocopy/entity/UserContent.java @@ -4,6 +4,7 @@ import jakarta.persistence.*; import lombok.*; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import ru.soune.nocopy.entity.file.FileType; import java.time.LocalDateTime; diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java b/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java new file mode 100644 index 0000000..ffdd985 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java @@ -0,0 +1,78 @@ +package ru.soune.nocopy.entity.file; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "file_entities") +@EntityListeners(AuditingEntityListener.class) +public class FileEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "original_file_name", nullable = false) + private String originalFileName; + + @Column(name = "stored_file_name", nullable = false) + private String storedFileName; + + @Column(name = "file_path", nullable = false, unique = true) + private String filePath; + + @Column(name = "file_size", nullable = false) + private Long fileSize; + + @Column(name = "mime_type") + private String mimeType; + + @Column(name = "file_extension") + private String fileExtension; + + @Column(name = "checksum") + private String checksum; + + @Column(name = "upload_session_id") + private String uploadSessionId; + + @Enumerated(EnumType.STRING) + @Column(name = "status") + private FileStatus status; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + public void prePersist() { + if (this.status == null) { + this.status = FileStatus.ACTIVE; + } + if (this.createdAt == null) { + this.createdAt = LocalDateTime.now(); + } + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileStatus.java b/src/main/java/ru/soune/nocopy/entity/file/FileStatus.java new file mode 100644 index 0000000..8e96247 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/FileStatus.java @@ -0,0 +1,8 @@ +package ru.soune.nocopy.entity.file; + +public enum FileStatus { + ACTIVE, + DELETED, + PROCESSING, + ERROR +} diff --git a/src/main/java/ru/soune/nocopy/entity/FileType.java b/src/main/java/ru/soune/nocopy/entity/file/FileType.java similarity index 88% rename from src/main/java/ru/soune/nocopy/entity/FileType.java rename to src/main/java/ru/soune/nocopy/entity/file/FileType.java index 7802a4f..e8d53f7 100644 --- a/src/main/java/ru/soune/nocopy/entity/FileType.java +++ b/src/main/java/ru/soune/nocopy/entity/file/FileType.java @@ -1,4 +1,4 @@ -package ru.soune.nocopy.entity; +package ru.soune.nocopy.entity.file; import lombok.Getter; diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java b/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java new file mode 100644 index 0000000..996f0de --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/FileUploadSession.java @@ -0,0 +1,100 @@ +package ru.soune.nocopy.entity.file; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "file_upload_sessions") +public class FileUploadSession { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "upload_id") + private String uploadId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "file_name", nullable = false) + private String fileName; + + @Column(name = "file_type") + private String fileType; + + @Column(name = "file_size") + private Long fileSize; + + @Column(name = "chunk_size") + private Long chunkSize; + + @Column(name = "total_chunks") + private Integer totalChunks; + + @Column(name = "chunks_uploaded") + private Integer chunksUploaded; + + @Column(name = "status") + @Enumerated(EnumType.STRING) + private UploadStatus status; + + @Column(name = "file_path") + private String filePath; + + @Column(name = "checksum") + private String checksum; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; + + @Version + private Long version; + + @Column(name = "last_error") + private String lastError; + + @Column(name = "extension") + private String extension; + + @Column(name = "retry_count") + private Integer retryCount = 0; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @ElementCollection + @CollectionTable( + name = "uploaded_chunks", + joinColumns = @JoinColumn(name = "upload_id") + ) + @MapKeyColumn(name = "chunk_number") + @Column(name = "chunk_path") + private Map chunkPaths = new HashMap<>(); + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + this.expiresAt = LocalDateTime.now().plusHours(24); + if (this.chunksUploaded == null) { + this.chunksUploaded = 0; + } + if (this.status == null) { + this.status = UploadStatus.INITIATED; + } + if (this.retryCount == null) { + this.retryCount = 0; + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/entity/file/UploadStatus.java b/src/main/java/ru/soune/nocopy/entity/file/UploadStatus.java new file mode 100644 index 0000000..577413c --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/UploadStatus.java @@ -0,0 +1,9 @@ +package ru.soune.nocopy.entity.file; + +public enum UploadStatus { + INITIATED, + UPLOADING, + COMPLETED, + FAILED, + CANCELLED +} diff --git a/src/main/java/ru/soune/nocopy/exception/ChunkSizeExceededException.java b/src/main/java/ru/soune/nocopy/exception/ChunkSizeExceededException.java new file mode 100644 index 0000000..a19cd95 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/ChunkSizeExceededException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class ChunkSizeExceededException extends RuntimeException { + public ChunkSizeExceededException(long actualSize, long maxSize) { + super(String.format("Chunk size exceeded: %d > %d", actualSize, maxSize)); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/FileEntityNotFoundException.java b/src/main/java/ru/soune/nocopy/exception/FileEntityNotFoundException.java new file mode 100644 index 0000000..2924414 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/FileEntityNotFoundException.java @@ -0,0 +1,11 @@ +package ru.soune.nocopy.exception; + +public class FileEntityNotFoundException extends RuntimeException { + public FileEntityNotFoundException(String fileId) { + super("FileEntity not found with ID: " + fileId); + } + + public FileEntityNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/exception/FileIntegrityException.java b/src/main/java/ru/soune/nocopy/exception/FileIntegrityException.java new file mode 100644 index 0000000..bb10cb9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/FileIntegrityException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class FileIntegrityException extends RuntimeException { + public FileIntegrityException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/FileUploadException.java b/src/main/java/ru/soune/nocopy/exception/FileUploadException.java new file mode 100644 index 0000000..5a38031 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/FileUploadException.java @@ -0,0 +1,11 @@ +package ru.soune.nocopy.exception; + +public class FileUploadException extends RuntimeException { + public FileUploadException(String message) { + super(message); + } + + public FileUploadException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/ru/soune/nocopy/exception/UploadSessionNotFoundException.java b/src/main/java/ru/soune/nocopy/exception/UploadSessionNotFoundException.java new file mode 100644 index 0000000..35b0eb5 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/exception/UploadSessionNotFoundException.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.exception; + +public class UploadSessionNotFoundException extends RuntimeException { + public UploadSessionNotFoundException(String uploadId) { + super("Upload session not found: " + uploadId); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java new file mode 100644 index 0000000..bbc8da9 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java @@ -0,0 +1,293 @@ +package ru.soune.nocopy.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.soune.nocopy.dto.*; +import ru.soune.nocopy.dto.file.*; +import ru.soune.nocopy.entity.AuthToken; +import ru.soune.nocopy.exception.NotFoundAuthToken; +import ru.soune.nocopy.repository.AuthTokenRepository; +import ru.soune.nocopy.service.file.FileEntityService; + +import java.util.Arrays; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileEntityHandler implements RequestHandler { + + private final FileEntityService fileEntityService; + private final AuthTokenRepository authTokenRepository; + private final ObjectMapper objectMapper; + + @Override + public BaseResponse handle(BaseRequest request) { + try { + FileEntityRequest fileRequest = objectMapper.convertValue( + request.getMessageBody(), FileEntityRequest.class); + + String action = fileRequest.getAction(); + + switch (action) { + case "get_file_info": + return handleGetFileInfo(request, fileRequest); + case "get_file_by_session": + return handleGetFileBySession(request, fileRequest); + case "get_user_files": + return handleGetUserFiles(request, fileRequest); + case "search_files": + return handleSearchFiles(request, fileRequest); + case "get_storage_usage": + return handleGetStorageUsage(request, fileRequest); + case "delete_file": + return handleDeleteFile(request, fileRequest); + default: + ActionResponse response = ActionResponse.builder() + .action(action) + .availableActions(Arrays.asList( + "get_file_info", "get_file_by_session", "get_user_files", + "search_files", "get_storage_usage", "delete_file")) + .build(); + + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_ACTION.getCode(), + "Invalid action: " + action, + response); + } + } catch (Exception e) { + log.error("Error in FileEntityHandler", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Handler error: " + e.getMessage(), + null); + } + } + + private BaseResponse handleGetFileInfo(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + FileEntityResponse fileInfo = fileEntityService.getById(fileRequest.getFileId(), request.getVersion()); + + if (!fileInfo.getUserId().equals(userId)) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + "Access denied", + null); + } + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + fileInfo); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error getting file info", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get file info: " + e.getMessage(), + null); + } + } + + private BaseResponse handleGetFileBySession(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + FileEntityResponse fileInfo = fileEntityService.getByUploadSessionId(fileRequest.getUploadSessionId(), + request.getVersion()); + + if (!fileInfo.getUserId().equals(userId)) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + "Access denied", + null); + } + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + fileInfo); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error getting file by session", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get file: " + e.getMessage(), + null); + } + } + + private BaseResponse handleGetUserFiles(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1; + int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20; + + FileResponse files = fileEntityService.getUserFiles(userId, page, pageSize, request.getVersion()); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + files); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error getting user files", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get files: " + e.getMessage(), + null); + } + } + + private BaseResponse handleSearchFiles(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + int page = fileRequest.getPage() != null ? fileRequest.getPage() : 1; + int pageSize = fileRequest.getPageSize() != null ? fileRequest.getPageSize() : 20; + + if (fileRequest.getQuery() == null || fileRequest.getQuery().isBlank()) { + return handleGetUserFiles(request, fileRequest); + } + + FileResponse allFiles = fileEntityService.getUserFiles(userId, 1, 1000, request.getVersion()); + + var filteredFiles = allFiles.getFiles().stream() + .filter(f -> f.getOriginalFileName().toLowerCase().contains(fileRequest.getQuery().toLowerCase())) + .toList(); + + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, filteredFiles.size()); + + FileListResponse response = FileListResponse.builder() + .files(filteredFiles.subList(start, Math.min(end, filteredFiles.size()))) + .totalCount(filteredFiles.size()) + .totalSize(allFiles.getTotalSize()) + .formattedTotalSize(allFiles.getFormattedTotalSize()) + .page(page) + .pageSize(pageSize) + .build(); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + response); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error searching files", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to search files: " + e.getMessage(), + null); + } + } + + private BaseResponse handleGetStorageUsage(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + long usage = fileEntityService.getUserStorageUsed(userId); + + StorageUsageResponse response = StorageUsageResponse.builder() + .userId(userId) + .storageUsed(usage) + .formattedStorageUsed(formatFileSize(usage)) + .build(); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), + response); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error getting storage usage", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get storage usage: " + e.getMessage(), + null); + } + } + + private BaseResponse handleDeleteFile(BaseRequest request, FileEntityRequest fileRequest) { + try { + Long userId = getUserIdFromToken(fileRequest.getToken()); + FileEntityResponse fileInfo = fileEntityService.getById(fileRequest.getFileId(), request.getVersion()); + + if (!fileInfo.getUserId().equals(userId)) { + return new BaseResponse(request.getMsgId(), + MessageCode.ACCESS_DENIED.getCode(), + "Access denied", + null); + } + + fileEntityService.markAsDeleted(fileRequest.getFileId()); + + DeleteFileResponse response = DeleteFileResponse.builder() + .fileId(fileRequest.getFileId()) + .message("File marked as deleted") + .build(); + + return new BaseResponse(request.getMsgId(), + MessageCode.SUCCESS.getCode(), + "File marked as deleted", + response); + + } catch (NotFoundAuthToken e) { + return new BaseResponse(request.getMsgId(), + MessageCode.INVALID_TOKEN.getCode(), + "Authentication required", + null); + } catch (Exception e) { + log.error("Error deleting file", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to delete file: " + e.getMessage(), + null); + } + } + + private Long getUserIdFromToken(String token) { + if (token == null || token.isBlank()) { + throw new NotFoundAuthToken("Token is required"); + } + + if (token.startsWith("Bearer ")) { + token = token.replace("Bearer ", ""); + } + + AuthToken authToken = authTokenRepository.findByToken(token) + .orElseThrow(() -> new NotFoundAuthToken("Token not found")); + return authToken.getUser().getId(); + } + + private String formatFileSize(long size) { + if (size < 1024) return size + " B"; + int exp = (int) (Math.log(size) / Math.log(1024)); + String pre = "KMGTPE".charAt(exp-1) + ""; + return String.format("%.1f %sB", size / Math.pow(1024, exp), pre); + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java b/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java new file mode 100644 index 0000000..26f6dba --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/FileUploadHandler.java @@ -0,0 +1,243 @@ +package ru.soune.nocopy.handler; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import ru.soune.nocopy.dto.*; +import ru.soune.nocopy.dto.file.*; +import ru.soune.nocopy.entity.AuthToken; +import ru.soune.nocopy.entity.file.FileType; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.exception.*; +import ru.soune.nocopy.handler.validator.FileUploadRequestValidator; +import ru.soune.nocopy.repository.AuthTokenRepository; +import ru.soune.nocopy.repository.FileUploadSessionRepository; +import ru.soune.nocopy.service.file.FileUploadService; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileUploadHandler implements RequestHandler { + private final FileUploadService fileUploadService; + private final AuthTokenRepository authTokenRepository; + private final FileUploadSessionRepository fileUploadSessionRepository; + private final ObjectMapper objectMapper; + private final FileUploadRequestValidator fileUploadRequestValidator; + + @Override + public BaseResponse handle(BaseRequest request) { + try { + FileUploadRequest fileRequest = objectMapper.convertValue( + request.getMessageBody(), FileUploadRequest.class); + + String action = fileRequest.getAction(); + + switch (action) { + case "init": + return handleInitUpload(request, fileRequest); + case "file_types": + return handleGetFileTypes(request); + case "chunks": + return handleGetChunkStatus(request, fileRequest); + case "cancel": + return handleCancelUpload(request, fileRequest); + case "progress": + return handleGetProgress(request, fileRequest); + case "retry": + return handleRetryUpload(request, fileRequest); + default: + ActionResponse response = ActionResponse.builder() + .action(action) + .availableActions(Arrays.asList("init", "file_types", "chunks", "cancel", "progress", "retry")) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.INVALID_ACTION.getCode(), + "Invalid action: " + action, response); + } + } catch (Exception e) { + log.error("Error in FileUploadHandler", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Handler error: " + e.getMessage(), null); + } + } + + private BaseResponse handleInitUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + String token = fileRequest.getToken(); + + Optional tokenOptional = authTokenRepository.findByToken(token); + + AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token not found")); + + BindingResult bindingResult = new BeanPropertyBindingResult(fileRequest, "fileRequest"); + fileUploadRequestValidator.validate(fileRequest, bindingResult); + + if (bindingResult.hasErrors()) { + throw new ValidationException(bindingResult, request.getMsgId()); + } + + FileUploadSession session = fileUploadService.initUpload( + authToken.getUser().getId(), + fileRequest.getFileName(), + fileRequest.getFileType(), + fileRequest.getExtension(), + fileRequest.getFileSize()); + + InitFileResponse fileResponse = InitFileResponse.builder() + .uploadId(session.getUploadId()) + .fileName(session.getFileName()) + .totalChunks(session.getTotalChunks()) + .chunkSize(session.getChunkSize()) + .status(session.getStatus().toString()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), fileResponse); + + } catch (NotFoundAuthToken e) { + InitFileResponse initFileResponse = InitFileResponse.builder() + .build(); + + throw new NotValidFieldException("Invalid or expired token: " + fileRequest.getToken(), + new BaseResponse(request.getMsgId(), MessageCode.INVALID_TOKEN.getCode(), + MessageCode.INVALID_TOKEN.getDescription(), initFileResponse)); + } + } + + private BaseResponse handleGetProgress(BaseRequest request, FileUploadRequest fileRequest) { + try { + ru.soune.nocopy.dto.file.UploadProgressResponse progress = + fileUploadService.getUploadProgress(fileRequest.getUploadId()); + + UploadProgressResponse responseDto = UploadProgressResponse.builder() + .uploadId(progress.getUploadId()) + .fileName(progress.getFileName()) + .totalChunks(progress.getTotalChunks()) + .uploadedChunks(progress.getUploadedChunks()) + .status(progress.getStatus()) + .progressPercentage(progress.getProgressPercentage()) + .filePath(progress.getFilePath()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), responseDto); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + MessageCode.FILE_NOT_FOUND.getDescription(), null); + } catch (Exception e) { + log.error("Error getting progress", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + MessageCode.FILE_UPLOAD_ERROR.getDescription(), null); + } + } + + private BaseResponse handleCancelUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + fileUploadService.cancelUpload(fileRequest.getUploadId()); + + CanceledUploadResponse response = CanceledUploadResponse.builder() + .uploadId(fileRequest.getUploadId()) + .message("Upload cancelled successfully") + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + "Upload cancelled successfully", response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (FileUploadException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(), null); + } catch (Exception e) { + log.error("Error cancelling upload", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to cancel upload", null); + } + } + + private BaseResponse handleRetryUpload(BaseRequest request, FileUploadRequest fileRequest) { + try { + fileUploadService.retryFailedUpload(fileRequest.getUploadId()); + ru.soune.nocopy.dto.file.UploadProgressResponse progress = + fileUploadService.getUploadProgress(fileRequest.getUploadId()); + + RetryUploadResponse response = RetryUploadResponse.builder() + .uploadId(progress.getUploadId()) + .message("Upload retry initiated") + .status(progress.getStatus().toString()) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), "Upload retry initiated", + response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (FileUploadException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), e.getMessage(), + null); + } catch (Exception e) { + log.error("Error retrying upload", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to retry upload", null); + } + } + + private BaseResponse handleGetChunkStatus(BaseRequest request, FileUploadRequest fileRequest) { + try { + FileUploadSession session = fileUploadSessionRepository.findById(fileRequest.getUploadId()) + .orElseThrow(() -> new UploadSessionNotFoundException(fileRequest.getUploadId())); + + Map chunkStatus = new HashMap<>(); + for (int i = 0; i < session.getTotalChunks(); i++) { + chunkStatus.put("chunk_" + i, session.getChunkPaths().containsKey(i)); + } + + ChunkStatusResponse response = ChunkStatusResponse.builder() + .uploadId(fileRequest.getUploadId()) + .totalChunks(session.getTotalChunks()) + .uploadedChunks(session.getChunksUploaded()) + .missingChunks(session.getTotalChunks() - session.getChunksUploaded()) + .chunkStatus(chunkStatus) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), response); + + } catch (UploadSessionNotFoundException e) { + return new BaseResponse(request.getMsgId(), MessageCode.FILE_NOT_FOUND.getCode(), + "Upload session not found", null); + } catch (Exception e) { + log.error("Error getting chunk status", e); + return new BaseResponse(request.getMsgId(), + MessageCode.FILE_UPLOAD_ERROR.getCode(), "Failed to get chunk status", + null); + } + } + + private BaseResponse handleGetFileTypes(BaseRequest request) { + try { + FileTypesResponse response = FileTypesResponse.builder() + .fileTypes(Arrays.asList(FileType.values())) + .count(FileType.values().length) + .build(); + + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), + MessageCode.SUCCESS.getDescription(), response); + + } catch (Exception e) { + log.error("Error getting file types", e); + return new BaseResponse(request.getMsgId(), MessageCode.FILE_UPLOAD_ERROR.getCode(), + "Failed to get file types", null); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/handler/JwtTokenProvider.java b/src/main/java/ru/soune/nocopy/handler/JwtTokenProvider.java new file mode 100644 index 0000000..9b18a21 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/JwtTokenProvider.java @@ -0,0 +1,7 @@ +package ru.soune.nocopy.handler; + +import org.springframework.stereotype.Component; + +@Component +public class JwtTokenProvider { +} diff --git a/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java b/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java new file mode 100644 index 0000000..f40487a --- /dev/null +++ b/src/main/java/ru/soune/nocopy/handler/validator/FileUploadRequestValidator.java @@ -0,0 +1,59 @@ +package ru.soune.nocopy.handler.validator; + +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; +import ru.soune.nocopy.dto.file.FileUploadRequest; +import ru.soune.nocopy.entity.file.FileType; + +import java.util.Arrays; + +@Component +public class FileUploadRequestValidator implements Validator { + @Override + public boolean supports(Class clazz) { + return FileUploadRequest.class.isAssignableFrom(clazz); + } + + @Override + public void validate(Object target, Errors errors) { + FileUploadRequest request = (FileUploadRequest) target; + + validateType(request.getFileType(), errors); + validateFileName(request.getFileName(), errors); + validateExtension(request.getExtension(), errors); + } + + private void validateType(String fileType, Errors errors) { + if (fileType == null || fileType.isBlank()) { + errors.rejectValue("fileType", "fileType.required", "File type is required"); + return; + } + + try { + FileType.valueOf(fileType.toUpperCase()); + } catch (IllegalArgumentException e) { + errors.rejectValue("fileType", "fileType.invalid", + "Invalid file type. Valid types: " + Arrays.toString(FileType.values())); + } + } + + private void validateFileName(String fileName, Errors errors) { + if (fileName == null || fileName.isBlank()) { + errors.rejectValue("fileName", "fileName.required", "File name is required"); + return; + } + + if (fileName.length() > 255) { + errors.rejectValue("fileName", "fileName.too.long", + "File name too long, max 255 characters"); + } + } + + private void validateExtension(String extension, Errors errors) { + if (extension == null || extension.isBlank()) { + errors.rejectValue("extension", "extension.required", "Extension is required"); + return; + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java new file mode 100644 index 0000000..3ee41cb --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java @@ -0,0 +1,33 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.entity.file.FileStatus; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface FileEntityRepository extends JpaRepository { + + List findByUserId(Long userId); + + List findByUserIdAndStatus(Long userId, FileStatus status); + + Optional findByFilePath(String filePath); + + Optional findByUploadSessionId(String uploadSessionId); + + boolean existsByFilePath(String filePath); + + @Query("SELECT SUM(f.fileSize) FROM FileEntity f WHERE f.userId = :userId AND f.status = 'ACTIVE'") + Long getTotalSizeByUserId(@Param("userId") Long userId); + + @Query("SELECT f FROM FileEntity f WHERE f.userId = :userId AND f.originalFileName LIKE %:keyword%") + List searchByFileName(@Param("userId") Long userId, @Param("keyword") String keyword); + + long countByUserId(Long userId); +} diff --git a/src/main/java/ru/soune/nocopy/repository/FileUploadSessionRepository.java b/src/main/java/ru/soune/nocopy/repository/FileUploadSessionRepository.java new file mode 100644 index 0000000..2b222a3 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/repository/FileUploadSessionRepository.java @@ -0,0 +1,30 @@ +package ru.soune.nocopy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.entity.file.UploadStatus; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Repository +public interface FileUploadSessionRepository extends JpaRepository { + + List findByUserId(Long userId); + + List findByStatus(UploadStatus status); + + @Query("SELECT s FROM FileUploadSession s WHERE s.status IN :statuses AND s.createdAt < :threshold") + List findByStatusInAndCreatedAtBefore( + @Param("statuses") Set statuses, + @Param("threshold") LocalDateTime threshold); + + @Query("SELECT s FROM FileUploadSession s WHERE s.expiresAt < :now AND s.status IN :statuses") + List findExpiredSessions( + @Param("now") LocalDateTime now, + @Param("statuses") Set statuses); +} diff --git a/src/main/java/ru/soune/nocopy/repository/UserContentRepository.java b/src/main/java/ru/soune/nocopy/repository/UserContentRepository.java index f1103a4..74fcd4b 100644 --- a/src/main/java/ru/soune/nocopy/repository/UserContentRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/UserContentRepository.java @@ -2,7 +2,7 @@ package ru.soune.nocopy.repository; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import ru.soune.nocopy.entity.FileType; +import ru.soune.nocopy.entity.file.FileType; import ru.soune.nocopy.entity.UserContent; import java.util.List; diff --git a/src/main/java/ru/soune/nocopy/service/UserContentService.java b/src/main/java/ru/soune/nocopy/service/UserContentService.java index b1fe0b3..e113752 100644 --- a/src/main/java/ru/soune/nocopy/service/UserContentService.java +++ b/src/main/java/ru/soune/nocopy/service/UserContentService.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.soune.nocopy.dto.UserContentRequest; import ru.soune.nocopy.dto.UserContentUpdateRequest; -import ru.soune.nocopy.entity.FileType; +import ru.soune.nocopy.entity.file.FileType; import ru.soune.nocopy.entity.User; import ru.soune.nocopy.entity.UserContent; import ru.soune.nocopy.exception.ContentNotFoundException; diff --git a/src/main/java/ru/soune/nocopy/service/file/FileCleanupService.java b/src/main/java/ru/soune/nocopy/service/file/FileCleanupService.java new file mode 100644 index 0000000..f86aa89 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/FileCleanupService.java @@ -0,0 +1,80 @@ +package ru.soune.nocopy.service.file; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.*; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Slf4j +@Component +@RequiredArgsConstructor +public class FileCleanupService { + + @Value("${file.storage.base-path}") + private String basePath; + + @Value("${file.storage.temp-ttl-hours}") + private int tempTtlHours; + + @Scheduled(cron = "0 0 3 * * *") + public void cleanupExpiredFiles() { + log.info("Starting cleanup of expired temporary files"); + + Path tempDir = Paths.get(basePath, "temp"); + if (!Files.exists(tempDir)) { + return; + } + + try { + Files.walkFileTree(tempDir, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + + LocalDateTime fileTime = LocalDateTime.ofInstant( + Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()), + ZoneId.systemDefault() + ); + + LocalDateTime cutoffTime = LocalDateTime.now() + .minusHours(tempTtlHours); + + if (fileTime.isBefore(cutoffTime)) { + Files.delete(file); + log.debug("Deleted expired file: {}", file); + } + + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) + throws IOException { + + if (exc == null) { + if (Files.list(dir).count() == 0 && + !dir.equals(tempDir)) { + Files.delete(dir); + log.debug("Deleted empty directory: {}", dir); + } + } + + return FileVisitResult.CONTINUE; + } + }); + + log.info("Cleanup completed successfully"); + + } catch (IOException e) { + log.error("Error during cleanup", e); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java new file mode 100644 index 0000000..8b239a5 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java @@ -0,0 +1,231 @@ +package ru.soune.nocopy.service.file; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import ru.soune.nocopy.dto.file.FileEntityResponse; +import ru.soune.nocopy.dto.file.FileResponse; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.entity.file.FileStatus; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.exception.FileEntityNotFoundException; +import ru.soune.nocopy.repository.FileEntityRepository; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileEntityService { + + private final FileEntityRepository fileEntityRepository; + + @Transactional + public FileEntity createFromUploadSession(FileUploadSession session, String checksum) { + log.info("Creating FileEntity for upload session: {}", session.getUploadId()); + + try { + Path filePath = Paths.get(session.getFilePath()); + + if (!Files.exists(filePath)) { + throw new IOException("File not found on disk: " + filePath); + } + + long fileSize = Files.size(filePath); + String originalName = session.getFileName(); + String extension = extractFileExtension(originalName); + String storedName = filePath.getFileName().toString(); + + FileEntity fileEntity = FileEntity.builder() + .userId(session.getUserId()) + .originalFileName(originalName) + .storedFileName(storedName) + .filePath(session.getFilePath()) + .fileSize(fileSize) + .mimeType(session.getFileType()) + .fileExtension(extension) + .checksum(checksum) + .uploadSessionId(session.getUploadId()) + .status(FileStatus.ACTIVE) + .build(); + + FileEntity saved = fileEntityRepository.save(fileEntity); + log.info("FileEntity created successfully: {} (size: {} bytes)", + saved.getId(), fileSize); + + return saved; + + } catch (IOException e) { + log.error("Failed to create FileEntity for session {}: {}", + session.getUploadId(), e.getMessage(), e); + throw new RuntimeException("Failed to create file entity: " + e.getMessage(), e); + } + } + + + private String extractFileExtension(String fileName) { + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < fileName.length() - 1) { + return fileName.substring(dotIndex + 1).toLowerCase(); + } + return ""; + } + + @Transactional(readOnly = true) + public FileEntityResponse getById(String fileId, int version) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + return convertToResponse(fileEntity, version); + } + + @Transactional(readOnly = true) + public FileEntityResponse getByUploadSessionId(String uploadSessionId, int version) { + FileEntity fileEntity = fileEntityRepository.findByUploadSessionId(uploadSessionId) + .orElseThrow(() -> new FileEntityNotFoundException( + "Not found for upload session: " + uploadSessionId)); + + return convertToResponse(fileEntity, version); + } + + @Transactional(readOnly = true) + public FileEntityResponse getByFilePath(String filePath, int version) { + FileEntity fileEntity = fileEntityRepository.findByFilePath(filePath) + .orElseThrow(() -> new FileEntityNotFoundException("Path: " + filePath)); + + return convertToResponse(fileEntity, version); + } + + + @Transactional(readOnly = true) + public FileResponse getAllUserFiles(Long userId, int version) { + List fileEntities = fileEntityRepository.findByUserIdAndStatus( + userId, FileStatus.ACTIVE); + + List files = fileEntities.stream() + .map(file -> convertToResponse(file, version)) + .collect(Collectors.toList()); + + long totalSize = fileEntities.stream() + .mapToLong(FileEntity::getFileSize) + .sum(); + + return FileResponse.builder() + .files(files) + .totalCount(files.size()) + .totalSize(totalSize) + .formattedTotalSize(formatFileSize(totalSize)) + .page(1) + .pageSize(files.size()) + .build(); + } + + + @Transactional(readOnly = true) + public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) { + List allFiles = fileEntityRepository.findByUserIdAndStatus( + userId, FileStatus.ACTIVE); + + int start = (page - 1) * pageSize; + int end = Math.min(start + pageSize, allFiles.size()); + + if (start >= allFiles.size()) { + return FileResponse.builder() + .files(List.of()) + .totalCount(allFiles.size()) + .totalSize(0) + .formattedTotalSize("0 B") + .page(page) + .pageSize(pageSize) + .build(); + } + + List pageFiles = allFiles.subList(start, end); + + List files = pageFiles.stream() + .map(file -> convertToResponse(file, version)) + .collect(Collectors.toList()); + + long totalSize = allFiles.stream() + .mapToLong(FileEntity::getFileSize) + .sum(); + + return FileResponse.builder() + .files(files) + .totalCount(allFiles.size()) + .totalSize(totalSize) + .formattedTotalSize(formatFileSize(totalSize)) + .page(page) + .pageSize(pageSize) + .build(); + } + + @Transactional + public void markAsDeleted(String fileId) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + + fileEntity.setStatus(FileStatus.DELETED); + fileEntity.setUpdatedAt(LocalDateTime.now()); + fileEntityRepository.save(fileEntity); + + log.info("FileEntity marked as deleted: {}", fileId); + } + + @Transactional(readOnly = true) + public long getUserStorageUsed(Long userId) { + Long totalSize = fileEntityRepository.getTotalSizeByUserId(userId); + return totalSize != null ? totalSize : 0L; + } + + private boolean checkFileExistsOnDisk(String filePath) { + try { + return Files.exists(Paths.get(filePath)); + } catch (Exception e) { + log.warn("Error checking file existence: {}", filePath, e); + return false; + } + } + + private FileEntityResponse convertToResponse(FileEntity fileEntity, int version) { + boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath()); + + return FileEntityResponse.builder() + .id(fileEntity.getId()) + .userId(fileEntity.getUserId()) + .originalFileName(fileEntity.getOriginalFileName()) + .storedFileName(fileEntity.getStoredFileName()) + .filePath(fileEntity.getFilePath()) + .fileSize(fileEntity.getFileSize()) + .mimeType(fileEntity.getMimeType()) + .fileExtension(fileEntity.getFileExtension()) + .checksum(fileEntity.getChecksum()) + .uploadSessionId(fileEntity.getUploadSessionId()) + .status(fileEntity.getStatus()) + .createdAt(fileEntity.getCreatedAt()) + .updatedAt(fileEntity.getUpdatedAt()) + .formattedSize(formatFileSize(fileEntity.getFileSize())) + .downloadUrl("/api/v" + version + "/files/download/" + fileEntity.getId()) + .existsOnDisk(existsOnDisk) + .build(); + } + + private String formatFileSize(long size) { + if (size < 1024) { + return size + " B"; + } else if (size < 1024 * 1024) { + return String.format("%.1f KB", size / 1024.0); + } else if (size < 1024 * 1024 * 1024) { + return String.format("%.1f MB", size / (1024.0 * 1024.0)); + } else { + return String.format("%.1f GB", size / (1024.0 * 1024.0 * 1024.0)); + } + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java b/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java new file mode 100644 index 0000000..cfb9aec --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java @@ -0,0 +1,23 @@ +package ru.soune.nocopy.service.file; + +import org.springframework.web.multipart.MultipartFile; +import ru.soune.nocopy.dto.file.UploadProgressResponse; +import ru.soune.nocopy.entity.file.FileUploadSession; + +import java.io.IOException; + +public interface FileUploadService { + FileUploadSession initUpload(Long userId, String fileName, + String fileType, String extension, long fileSize); + + UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, + MultipartFile chunkFile) throws IOException; + + UploadProgressResponse getUploadProgress(String uploadId); + + void cleanupExpiredSessions(); + + public void retryFailedUpload(String uploadId); + + public void cancelUpload(String uploadId); +} diff --git a/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java b/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java new file mode 100644 index 0000000..e8fb956 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java @@ -0,0 +1,734 @@ +package ru.soune.nocopy.service.file; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import ru.soune.nocopy.dto.file.UploadProgressResponse; +import ru.soune.nocopy.entity.file.FileUploadSession; +import ru.soune.nocopy.entity.file.UploadStatus; +import ru.soune.nocopy.exception.ChunkSizeExceededException; +import ru.soune.nocopy.exception.FileUploadException; +import ru.soune.nocopy.exception.UploadSessionNotFoundException; +import ru.soune.nocopy.repository.FileUploadSessionRepository; + +import java.io.*; +import java.nio.file.*; +import java.nio.file.attribute.PosixFilePermission; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FileUploadServiceImpl implements FileUploadService { + + private final FileUploadSessionRepository sessionRepository; + + @Value("${file.storage.base-path}") + private String basePath; + + @Value("${file.storage.chunk-size}") + private int chunkSize; + + @Value("${file.storage.max-file-size}") + private long maxFileSize; + + @Value("${file.storage.max-retry-attempts:3}") + private int maxRetryAttempts; + + @Value("${file.storage.chunk-timeout-ms:300000}") // 5 минут + private long chunkTimeoutMs; + + @Value("${file.storage.session-expiry-hours:24}") + private int sessionExpiryHours; + + private Path storageRoot; + + @Autowired + private FileEntityService fileEntityService; + + @PostConstruct + public void init() { + try { + storageRoot = Paths.get(basePath).toAbsolutePath().normalize(); + log.info("Initializing file storage at: {}", storageRoot); + + createDirectoryIfNotExists(storageRoot); + createDirectoryIfNotExists(storageRoot.resolve("temp")); + createDirectoryIfNotExists(storageRoot.resolve("uploads")); + + if (!System.getProperty("os.name").toLowerCase().contains("win")) { + setDirectoryPermissions(storageRoot); + } + + log.info("File storage initialized successfully"); + + } catch (IOException e) { + log.error("Failed to initialize file storage at: {}", basePath, e); + throw new RuntimeException("Storage initialization failed", e); + } + } + + @Override + @Transactional + public FileUploadSession initUpload(Long userId, String fileName, + String fileType, String extension, long fileSize) { + log.info("Initializing upload for user {}: {} ({} bytes, type: {})", + userId, fileName, fileSize, fileType); + + if (fileSize > maxFileSize) { + throw new FileUploadException( + String.format("File size %d exceeds maximum allowed size %d", + fileSize, maxFileSize)); + } + + if (fileSize <= 0) { + throw new FileUploadException("File size must be positive"); + } + + int totalChunks = (int) Math.ceil((double) fileSize / chunkSize); + log.debug("File will be split into {} chunks (chunk size: {} bytes)", + totalChunks, chunkSize); + + FileUploadSession session = FileUploadSession.builder() + .userId(userId) + .extension(extension) + .fileName(fileName) + .fileType(fileType) + .fileSize(fileSize) + .totalChunks(totalChunks) + .chunksUploaded(0) + .status(UploadStatus.INITIATED) + .expiresAt(LocalDateTime.now().plusHours(sessionExpiryHours)) + .retryCount(0) + .build(); + + FileUploadSession savedSession = sessionRepository.save(session); + log.info("Upload session created: {} for file: {}", + savedSession.getUploadId(), fileName); + + return savedSession; + } + + @Override + @Transactional + public UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, + MultipartFile chunkFile) { + + log.info("Processing chunk {} for session {}, size: {} bytes", + chunkNumber, uploadId, chunkFile.getSize()); + + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + validateSession(session); + + if (session.getExpiresAt().isBefore(LocalDateTime.now())) { + handleExpiredSession(session); + throw new FileUploadException("Upload session expired"); + } + + if (chunkNumber < 0 || chunkNumber >= session.getTotalChunks()) { + throw new FileUploadException( + String.format("Invalid chunk number %d. Expected 0-%d", + chunkNumber, session.getTotalChunks() - 1)); + } + + if (chunkFile.getSize() > chunkSize) { + throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize); + } + + return processChunk(session, chunkNumber, chunkFile); + } + + private void validateSession(FileUploadSession session) { + UploadStatus status = session.getStatus(); + + if (status == UploadStatus.FAILED) { + if (session.getRetryCount() >= maxRetryAttempts) { + throw new FileUploadException( + "Upload failed after maximum retry attempts"); + } + session.setStatus(UploadStatus.UPLOADING); + session.setRetryCount(session.getRetryCount() + 1); + sessionRepository.save(session); + log.info("Retrying failed upload session: {}, attempt: {}", + session.getUploadId(), session.getRetryCount()); + } + + if (status == UploadStatus.COMPLETED) { + throw new FileUploadException("Upload already completed"); + } + + if (status == UploadStatus.CANCELLED) { + throw new FileUploadException("Upload was cancelled"); + } + + if (status == UploadStatus.INITIATED) { + session.setStatus(UploadStatus.UPLOADING); + sessionRepository.save(session); + } + } + + private void handleExpiredSession(FileUploadSession session) { + session.setStatus(UploadStatus.FAILED); + session.setLastError("Upload session expired"); + sessionRepository.save(session); + + CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); + } + + private UploadProgressResponse processChunk(FileUploadSession session, + Integer chunkNumber, + MultipartFile chunkFile) { + String chunkPath = null; + + try { + if (session.getChunkPaths().containsKey(chunkNumber)) { + return handleExistingChunk(session, chunkNumber, chunkFile); + } + + chunkPath = saveChunkWithIntegrityCheck(session, chunkNumber, chunkFile); + + session.getChunkPaths().put(chunkNumber, chunkPath); + session.setChunksUploaded(session.getChunksUploaded() + 1); + session.setStatus(UploadStatus.UPLOADING); + + log.debug("Chunk {} saved successfully. Uploaded: {}/{}", + chunkNumber, session.getChunksUploaded(), session.getTotalChunks()); + + if (session.getChunksUploaded().equals(session.getTotalChunks())) { + log.info("All chunks uploaded for session {}. Starting assembly...", + session.getUploadId()); + sessionRepository.save(session); + assembleFileAsync(session); + } else { + sessionRepository.save(session); + } + + return UploadProgressResponse.fromSession(session); + + } catch (Exception e) { + if (chunkPath != null) { + cleanupFailedChunk(chunkPath); + } + + log.error("Failed to process chunk {} for session {}: {}", + chunkNumber, session.getUploadId(), e.getMessage(), e); + + session.setStatus(UploadStatus.FAILED); + session.setLastError(e.getMessage()); + sessionRepository.save(session); + + throw new FileUploadException("Failed to upload chunk: " + e.getMessage(), e); + } + } + + private UploadProgressResponse handleExistingChunk(FileUploadSession session, + Integer chunkNumber, + MultipartFile chunkFile) throws IOException { + String existingPath = session.getChunkPaths().get(chunkNumber); + Path chunkPath = Paths.get(existingPath); + + if (!Files.exists(chunkPath)) { + log.warn("Chunk file missing, re-uploading: {}", chunkPath); + session.getChunkPaths().remove(chunkNumber); + session.setChunksUploaded(session.getChunksUploaded() - 1); + sessionRepository.save(session); + return processChunk(session, chunkNumber, chunkFile); + } + + long existingSize = Files.size(chunkPath); + if (existingSize != chunkFile.getSize()) { + log.warn("Chunk size mismatch, re-uploading: {} != {}", + existingSize, chunkFile.getSize()); + Files.deleteIfExists(chunkPath); + session.getChunkPaths().remove(chunkNumber); + session.setChunksUploaded(session.getChunksUploaded() - 1); + sessionRepository.save(session); + return processChunk(session, chunkNumber, chunkFile); + } + + log.debug("Chunk {} already uploaded and valid", chunkNumber); + return UploadProgressResponse.fromSession(session); + } + + private String saveChunkWithIntegrityCheck(FileUploadSession session, + Integer chunkNumber, + MultipartFile chunkFile) throws IOException { + + Long userId = session.getUserId(); + String uploadId = session.getUploadId(); + + Path chunkDir = getChunkDirectory(userId, uploadId); + Files.createDirectories(chunkDir); + + String tempFileName = String.format("chunk_%04d.%s.tmp", + chunkNumber, UUID.randomUUID()); + String finalFileName = String.format("chunk_%04d.tmp", chunkNumber); + + Path tempPath = chunkDir.resolve(tempFileName); + Path finalPath = chunkDir.resolve(finalFileName); + + try { + chunkFile.transferTo(tempPath.toFile()); + + long savedSize = Files.size(tempPath); + long uploadedSize = chunkFile.getSize(); + + if (uploadedSize > 0 && savedSize != uploadedSize) { + throw new IOException( + String.format("Size mismatch: saved %d, uploaded %d", + savedSize, uploadedSize)); + } + + Files.move(tempPath, finalPath, StandardCopyOption.ATOMIC_MOVE); + + log.debug("Chunk {} saved successfully: {} bytes", + chunkNumber, savedSize); + + return finalPath.toString(); + + } finally { + Files.deleteIfExists(tempPath); + } + } + + private void cleanupFailedChunk(String chunkPath) { + try { + Files.deleteIfExists(Paths.get(chunkPath)); + log.debug("Cleaned up failed chunk: {}", chunkPath); + } catch (IOException e) { + log.warn("Failed to cleanup chunk: {}", chunkPath, e); + } + } + + @Async("fileUploadTaskExecutor") + @Transactional + public void assembleFileAsync(FileUploadSession session) { + try { + assembleFile(session); + log.info("File assembly completed successfully for session: {}", + session.getUploadId()); + + } catch (Exception e) { + log.error("Failed to assemble file for session {}: {}", + session.getUploadId(), e.getMessage(), e); + + handleAssemblyFailure(session, e); + } + } + +// private void assembleFile(FileUploadSession session) throws IOException { +// log.info("Starting file assembly for session: {} ({})", +// session.getUploadId(), session.getFileName()); +// +// Path finalFilePath = null; +// +// try { +// finalFilePath = prepareFinalFile(session); +// +// validateAllChunksExist(session); +// +// mergeChunksToFile(session, finalFilePath); +// +// validateFinalFile(session, finalFilePath); +// +// updateSessionOnSuccess(session, finalFilePath); +// +// cleanupSessionFiles(session); +// +// log.info("File assembly completed: {} -> {} ({} bytes)", +// session.getFileName(), finalFilePath, session.getFileSize()); +// +// } catch (Exception e) { +// if (finalFilePath != null) { +// Files.deleteIfExists(finalFilePath); +// } +// throw e; +// } +// } + + private void assembleFile(FileUploadSession session) throws IOException { + log.info("Starting file assembly for session: {} ({})", + session.getUploadId(), session.getFileName()); + + Path finalFilePath = null; + String checksum = null; + + try { + finalFilePath = prepareFinalFile(session); + log.info("Final file path: {}", finalFilePath); + + validateAllChunksExist(session); + + mergeChunksToFile(session, finalFilePath); + + validateFinalFile(session, finalFilePath); + + checksum = calculateChecksum(finalFilePath); + log.debug("File checksum calculated: {}", checksum); + + session.setFilePath(finalFilePath.toString()); + session.setChecksum(checksum); + session.setStatus(UploadStatus.COMPLETED); + session.setCompletedAt(LocalDateTime.now()); + sessionRepository.save(session); + log.info("Upload session updated to COMPLETED: {}", session.getUploadId()); + + try { + fileEntityService.createFromUploadSession(session, checksum); + log.info("FileEntity successfully created for session: {}", + session.getUploadId()); + } catch (Exception e) { + log.error("Failed to create FileEntity for session {} (file uploaded, but metadata not saved): {}", + session.getUploadId(), e.getMessage()); + } + + cleanupSessionFiles(session); + + log.info("File assembly completed successfully: {} -> {} ({} bytes)", + session.getFileName(), finalFilePath, session.getFileSize()); + + } catch (Exception e) { + log.error("File assembly failed for session {}: {}", + session.getUploadId(), e.getMessage(), e); + + if (finalFilePath != null) { + try { + Files.deleteIfExists(finalFilePath); + log.debug("Cleaned up partial file: {}", finalFilePath); + } catch (IOException ioException) { + log.warn("Failed to cleanup partial file: {}", finalFilePath, ioException); + } + } + + session.setStatus(UploadStatus.FAILED); + session.setLastError(e.getMessage()); + sessionRepository.save(session); + + throw new IOException("File assembly failed: " + e.getMessage(), e); + } + } + + private Path prepareFinalFile(FileUploadSession session) throws IOException { + Path userUploadsDir = storageRoot.resolve("uploads") + .resolve(String.valueOf(session.getUserId())) + .resolve(extractCategory(session.getFileType())); + + Files.createDirectories(userUploadsDir); + + String safeFileName = generateUniqueFileName(session.getFileName(), session.getExtension()); + Path finalPath = userUploadsDir.resolve(safeFileName); + + if (Files.exists(finalPath)) { + throw new IOException("File already exists: " + finalPath); + } + + return finalPath; + } + + private String generateUniqueFileName(String originalName, String extension) { + int dotIndex = originalName.lastIndexOf('.'); + log.info("dotIndex: {}", dotIndex); + log.info("originalName: {}", originalName); + String nameWithoutExtension; + + if (dotIndex > 0) { + nameWithoutExtension = originalName.substring(0, dotIndex) + .replaceAll("[^a-zA-Z0-9\\-_]", "_"); + } else { + nameWithoutExtension = originalName.replaceAll("[^a-zA-Z0-9\\-_]", "_"); + } + + String timestamp = String.valueOf(System.currentTimeMillis()); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("%s_%s_%s.%s", + nameWithoutExtension, timestamp, uuid, extension); + } + + private void validateAllChunksExist(FileUploadSession session) throws IOException { + Path chunkDir = getChunkDirectory(session.getUserId(), session.getUploadId()); + + for (int i = 0; i < session.getTotalChunks(); i++) { + Path chunkPath = chunkDir.resolve(String.format("chunk_%04d.tmp", i)); + if (!Files.exists(chunkPath)) { + throw new IOException("Missing chunk: " + chunkPath.getFileName()); + } + + if (Files.size(chunkPath) == 0) { + throw new IOException("Empty chunk: " + chunkPath.getFileName()); + } + } + } + + private void mergeChunksToFile(FileUploadSession session, Path finalPath) + throws IOException { + + Path chunkDir = getChunkDirectory(session.getUserId(), session.getUploadId()); + byte[] buffer = new byte[8192]; + + try (BufferedOutputStream outputStream = new BufferedOutputStream( + Files.newOutputStream(finalPath, StandardOpenOption.CREATE_NEW))) { + + long totalWritten = 0; + + for (int i = 0; i < session.getTotalChunks(); i++) { + Path chunkPath = chunkDir.resolve(String.format("chunk_%04d.tmp", i)); + + try (BufferedInputStream inputStream = new BufferedInputStream( + Files.newInputStream(chunkPath))) { + + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalWritten += bytesRead; + } + } + + if (log.isDebugEnabled()) { + log.debug("Processed chunk {}: {} bytes (total: {})", + i, Files.size(chunkPath), totalWritten); + } + } + + outputStream.flush(); + log.info("Total bytes written to final file: {}", totalWritten); + } + } + + private void validateFinalFile(FileUploadSession session, Path finalPath) + throws IOException { + + long actualSize = Files.size(finalPath); + + if (actualSize != session.getFileSize()) { + throw new IOException( + String.format("File size mismatch: expected %d, got %d", + session.getFileSize(), actualSize)); + } + + String checksum = calculateChecksum(finalPath); + log.debug("Final file checksum: {}", checksum); + } + + private void updateSessionOnSuccess(FileUploadSession session, Path finalPath) { + session.setFilePath(finalPath.toString()); + session.setChecksum(calculateChecksum(finalPath)); + session.setStatus(UploadStatus.COMPLETED); + session.setCompletedAt(LocalDateTime.now()); + sessionRepository.save(session); + } + + private void cleanupSessionFiles(FileUploadSession session) { + try { + Path chunkDir = getChunkDirectory(session.getUserId(), session.getUploadId()); + if (Files.exists(chunkDir)) { + deleteDirectoryRecursively(chunkDir); + log.info("Cleaned up temp directory for session: {}", + session.getUploadId()); + } + } catch (IOException e) { + log.warn("Failed to cleanup temp files for session {}: {}", + session.getUploadId(), e.getMessage()); + } + } + + private void deleteDirectoryRecursively(Path directory) throws IOException { + try (Stream walk = Files.walk(directory)) { + walk.sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.warn("Failed to delete: {}", path, e); + } + }); + } + } + + private void handleAssemblyFailure(FileUploadSession session, Exception e) { + session.setStatus(UploadStatus.FAILED); + session.setLastError(e.getMessage()); + sessionRepository.save(session); + + log.error("File assembly failed for session {}: {}", + session.getUploadId(), e.getMessage()); + } + + @Override + @Transactional + public void cancelUpload(String uploadId) { + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + if (session.getStatus() == UploadStatus.COMPLETED || session.getStatus() == UploadStatus.CANCELLED) { + throw new FileUploadException("Cannot cancel completed or cancelled upload"); + } + + session.setStatus(UploadStatus.CANCELLED); + sessionRepository.save(session); + + CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); + + log.info("Upload cancelled: {}", uploadId); + } + + @Override + @Transactional + public void retryFailedUpload(String uploadId) { + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + if (session.getStatus() != UploadStatus.FAILED) { + throw new FileUploadException("Only failed uploads can be retried"); + } + + if (session.getRetryCount() >= maxRetryAttempts) { + throw new FileUploadException("Max retry attempts exceeded"); + } + + session.setStatus(UploadStatus.UPLOADING); + session.setRetryCount(session.getRetryCount() + 1); + session.setLastError(null); + + sessionRepository.save(session); + + if (session.getChunksUploaded().equals(session.getTotalChunks())) { + log.info("Retrying file assembly for session: {}", uploadId); + assembleFileAsync(session); + } + + log.info("Upload retry initiated for session: {} (attempt {})", + uploadId, session.getRetryCount()); + } + + @Override + public UploadProgressResponse getUploadProgress(String uploadId) { + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + return UploadProgressResponse.fromSession(session); + } + + @Override + @Transactional + public void cleanupExpiredSessions() { + log.info("Starting cleanup of expired upload sessions"); + + LocalDateTime expiryThreshold = LocalDateTime.now().minusHours(sessionExpiryHours); + + try { + var expiredSessions = sessionRepository.findByStatusInAndCreatedAtBefore( + Set.of(UploadStatus.INITIATED, UploadStatus.UPLOADING, UploadStatus.FAILED), + expiryThreshold + ); + + int cleanedCount = 0; + for (FileUploadSession session : expiredSessions) { + session.setStatus(UploadStatus.FAILED); + session.setLastError("Session expired during cleanup"); + sessionRepository.save(session); + + CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); + cleanedCount++; + } + + log.info("Cleaned up {} expired upload sessions", cleanedCount); + + } catch (Exception e) { + log.error("Error during session cleanup", e); + } + } + + private Path getChunkDirectory(Long userId, String uploadId) { + return storageRoot.resolve("temp") + .resolve("user") + .resolve(String.valueOf(userId)) + .resolve(uploadId); + } + + private String extractCategory(String fileType) { + if (fileType == null) { + return "other"; + } + + String lowerType = fileType.toLowerCase(); + if (lowerType.startsWith("image")) { + return "images"; + } else if (lowerType.startsWith("video")) { + return "videos"; + } else if (lowerType.startsWith("audio")) { + return "audio"; + } else if (lowerType.contains("document")) { + return "documents"; + } else { + return "other"; + } + } + + private String calculateChecksum(Path filePath) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + + try (InputStream inputStream = Files.newInputStream(filePath)) { + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + md.update(buffer, 0, bytesRead); + } + } + + byte[] digest = md.digest(); + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + + } catch (NoSuchAlgorithmException | IOException e) { + log.warn("Failed to calculate checksum for file: {}", filePath, e); + return "N/A"; + } + } + + private void createDirectoryIfNotExists(Path directory) throws IOException { + if (!Files.exists(directory)) { + Files.createDirectories(directory); + log.debug("Created directory: {}", directory); + } + } + + private void setDirectoryPermissions(Path directory) throws IOException { + try { + Set permissions = new HashSet<>(); + permissions.add(PosixFilePermission.OWNER_READ); + permissions.add(PosixFilePermission.OWNER_WRITE); + permissions.add(PosixFilePermission.OWNER_EXECUTE); + permissions.add(PosixFilePermission.GROUP_READ); + permissions.add(PosixFilePermission.GROUP_EXECUTE); + permissions.add(PosixFilePermission.OTHERS_READ); + + Files.setPosixFilePermissions(directory, permissions); + log.debug("Set permissions for directory: {}", directory); + + } catch (UnsupportedOperationException e) { + log.debug("Posix permissions not supported on this system"); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e1e88fd..08326e1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,10 +9,44 @@ spring: hibernate: ddl-auto: update show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + + servlet: + multipart: + max-file-size: 10MB + max-request-size: 100MB + enabled: true + resolve-lazily: false + +file: + storage: + base-path: ${FILE_STORAGE_PATH:/data/uploads} + chunk-size: ${FILE_CHUNK_SIZE:5242880} # 5MB + max-file-size: ${MAX_FILE_SIZE:10737418240} # 10GB + max-retry-attempts: ${MAX_RETRY_ATTEMPTS:3} + chunk-timeout-ms: ${CHUNK_TIMEOUT_MS:300000} # 5 минут + temp-ttl-hours: ${TEMP_TTL_HOURS:72} # 3 дня + session-expiry-hours: ${SESSION_EXPIRY_HOURS:24} + +security: + cors: + allowed-origins: "*" + allowed-methods: GET,POST,PUT,DELETE,OPTIONS + allowed-headers: "*" + allow-credentials: true server: port: ${SERVER_PORT:8080} logging: level: - root: INFO \ No newline at end of file + com.example.fileupload: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + file: + name: logs/application.log + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" \ No newline at end of file