diff --git a/docker-compose.yaml b/docker-compose.yaml index 13e1b65..8817acc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,6 +37,8 @@ services: - app - backend - api + volumes: + - ./uploads:/data/uploads grafana: image: grafana/grafana:10.3.1 diff --git a/src/main/java/ru/soune/no_copy/configuration/file/AsyncConfig.java b/src/main/java/ru/soune/no_copy/configuration/file/AsyncConfig.java new file mode 100644 index 0000000..bd75b72 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/configuration/file/AsyncConfig.java @@ -0,0 +1,24 @@ +package ru.soune.no_copy.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/no_copy/controller/file/FileUploadController.java b/src/main/java/ru/soune/no_copy/controller/file/FileUploadController.java new file mode 100644 index 0000000..47b7f47 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/controller/file/FileUploadController.java @@ -0,0 +1,120 @@ +package ru.soune.no_copy.controller.file; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import ru.soune.no_copy.dto.file.ChunkUploadRequest; +import ru.soune.no_copy.dto.file.FileApiResponse; +import ru.soune.no_copy.dto.file.InitUploadRequest; +import ru.soune.no_copy.dto.file.UploadProgressResponse; +import ru.soune.no_copy.entity.AuthToken; +import ru.soune.no_copy.entity.file.FileType; +import ru.soune.no_copy.entity.file.FileUploadSession; +import ru.soune.no_copy.exception.NotFoundAuthToken; +import ru.soune.no_copy.repository.AuthTokenRepository; +import ru.soune.no_copy.service.file.FileUploadService; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +@Slf4j +@RestController +@RequestMapping("/api/files") +@RequiredArgsConstructor +public class FileUploadController { + + private final FileUploadService fileUploadService; + + private final AuthTokenRepository authTokenRepository; + + //TODO ADD TEMPLATE JSON, RESPONSE OK,ADD MESSAGE CODE + @PostMapping("/init") + public ResponseEntity> initUpload(@Valid @RequestBody InitUploadRequest request, + @RequestHeader("Authorization") String tokenHeader) { + String token = tokenHeader.replace("Bearer ", ""); + + Optional tokenOptional = authTokenRepository.findByToken(token); + + AuthToken authToken = tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token: " + token + "not found")); + + try { + FileUploadSession session = fileUploadService.initUpload(authToken.getUser().getId(), request.getFileName(), + request.getFileType(), request.getFileSize()); + + return ResponseEntity.ok(FileApiResponse.success(session)); + } catch (Exception e) { + log.error("Error initializing upload", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error(e.getMessage())); + } + } + + @GetMapping("/file-types") + public ResponseEntity> getFileTypes() { + return ResponseEntity.ok(Arrays.asList(FileType.values())); + } + + + @PostMapping("/chunk") + public ResponseEntity> uploadChunk( + @Valid @RequestBody ChunkUploadRequest chunkUploadRequest, + @RequestParam("file") MultipartFile chunk, @RequestHeader("Authorization") String tokenHeader) { + String token = tokenHeader.replace("Bearer ", ""); + + Optional tokenOptional = authTokenRepository.findByToken(token); + + tokenOptional.orElseThrow(() -> new NotFoundAuthToken("Token: " + token + "not found")); + + try { + UploadProgressResponse progress = fileUploadService.uploadChunk( + chunkUploadRequest.getUploadId(), chunkUploadRequest.getChunkNumber(), chunk); + + return ResponseEntity.ok(FileApiResponse.success(progress)); + + } catch (Exception e) { + log.error("Error uploading chunk", e); + return ResponseEntity.badRequest() + .body(FileApiResponse.error(e.getMessage())); + } + } + +// @GetMapping("/progress/{uploadId}") +// public ResponseEntity> getProgress( +// @PathVariable String uploadId, +// @RequestHeader(value = "X-User-Id", required = false, defaultValue = "1") Long userId) { +// +// try { +// UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId); +// return ResponseEntity.ok(FileApiResponse.success(progress)); +// +// } catch (Exception e) { +// log.error("Error getting progress", e); +// return ResponseEntity.badRequest() +// .body(FileApiResponse.error(e.getMessage())); +// } +// } +// +// @PostMapping("/complete/{uploadId}") +// public ResponseEntity> completeUpload( +// @PathVariable String uploadId, +// @RequestHeader(value = "X-User-Id", required = false, defaultValue = "1") Long userId) { +// +// log.info("Completing upload for session {}", uploadId); +// +// try { +// UploadProgressResponse progress = fileUploadService.getUploadProgress(uploadId); +// +// return ResponseEntity.ok(FileApiResponse.success( +// "File assembly in progress", progress)); +// +// } catch (Exception e) { +// log.error("Error completing upload", e); +// return ResponseEntity.badRequest() +// .body(FileApiResponse.error(e.getMessage())); +// } +// } +} diff --git a/src/main/java/ru/soune/no_copy/dto/UserContentDTO.java b/src/main/java/ru/soune/no_copy/dto/UserContentDTO.java index 90df1d9..4c9c6f0 100644 --- a/src/main/java/ru/soune/no_copy/dto/UserContentDTO.java +++ b/src/main/java/ru/soune/no_copy/dto/UserContentDTO.java @@ -1,7 +1,6 @@ package ru.soune.no_copy.dto; import lombok.Data; -import ru.soune.no_copy.entity.FileType; @Data public class UserContentDTO { diff --git a/src/main/java/ru/soune/no_copy/dto/UserContentResponse.java b/src/main/java/ru/soune/no_copy/dto/UserContentResponse.java index 86d1d84..d2a817c 100644 --- a/src/main/java/ru/soune/no_copy/dto/UserContentResponse.java +++ b/src/main/java/ru/soune/no_copy/dto/UserContentResponse.java @@ -1,7 +1,7 @@ package ru.soune.no_copy.dto; import ru.soune.no_copy.entity.ContentStatus; -import ru.soune.no_copy.entity.FileType; +import ru.soune.no_copy.entity.file.FileType; import java.time.LocalDateTime; diff --git a/src/main/java/ru/soune/no_copy/dto/file/ChunkUploadRequest.java b/src/main/java/ru/soune/no_copy/dto/file/ChunkUploadRequest.java new file mode 100644 index 0000000..25c68fc --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/ChunkUploadRequest.java @@ -0,0 +1,17 @@ +package ru.soune.no_copy.dto.file; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ChunkUploadRequest { + + @NotBlank + private String uploadId; + + private Integer chunkNumber; +} diff --git a/src/main/java/ru/soune/no_copy/dto/file/FileApiResponse.java b/src/main/java/ru/soune/no_copy/dto/file/FileApiResponse.java new file mode 100644 index 0000000..d271a1c --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/FileApiResponse.java @@ -0,0 +1,41 @@ +package ru.soune.no_copy.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/no_copy/dto/file/InitUploadRequest.java b/src/main/java/ru/soune/no_copy/dto/file/InitUploadRequest.java new file mode 100644 index 0000000..9653984 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/InitUploadRequest.java @@ -0,0 +1,20 @@ +package ru.soune.no_copy.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; + + @NotNull(message = "File size is required") + @Positive(message = "File size must be positive") + private Long fileSize; +} diff --git a/src/main/java/ru/soune/no_copy/dto/file/UploadProgressResponse.java b/src/main/java/ru/soune/no_copy/dto/file/UploadProgressResponse.java new file mode 100644 index 0000000..be77f03 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/dto/file/UploadProgressResponse.java @@ -0,0 +1,39 @@ +package ru.soune.no_copy.dto.file; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import ru.soune.no_copy.entity.file.FileUploadSession; +import ru.soune.no_copy.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/no_copy/entity/UserContent.java b/src/main/java/ru/soune/no_copy/entity/UserContent.java index 22ee0b0..a6603c1 100644 --- a/src/main/java/ru/soune/no_copy/entity/UserContent.java +++ b/src/main/java/ru/soune/no_copy/entity/UserContent.java @@ -2,9 +2,9 @@ package ru.soune.no_copy.entity; import jakarta.persistence.*; import lombok.*; -import org.hibernate.annotations.CreationTimestamp; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import ru.soune.no_copy.entity.file.FileType; import java.time.LocalDateTime; diff --git a/src/main/java/ru/soune/no_copy/entity/FileType.java b/src/main/java/ru/soune/no_copy/entity/file/FileType.java similarity index 88% rename from src/main/java/ru/soune/no_copy/entity/FileType.java rename to src/main/java/ru/soune/no_copy/entity/file/FileType.java index 4e0406d..dd7f6ab 100644 --- a/src/main/java/ru/soune/no_copy/entity/FileType.java +++ b/src/main/java/ru/soune/no_copy/entity/file/FileType.java @@ -1,4 +1,4 @@ -package ru.soune.no_copy.entity; +package ru.soune.no_copy.entity.file; import lombok.Getter; diff --git a/src/main/java/ru/soune/no_copy/entity/file/FileUploadSession.java b/src/main/java/ru/soune/no_copy/entity/file/FileUploadSession.java new file mode 100644 index 0000000..08f54f3 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/file/FileUploadSession.java @@ -0,0 +1,67 @@ +package ru.soune.no_copy.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 = "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 = "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<>(); +} diff --git a/src/main/java/ru/soune/no_copy/entity/file/UploadStatus.java b/src/main/java/ru/soune/no_copy/entity/file/UploadStatus.java new file mode 100644 index 0000000..d1d3418 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/entity/file/UploadStatus.java @@ -0,0 +1,9 @@ +package ru.soune.no_copy.entity.file; + +public enum UploadStatus { + INITIATED, + UPLOADING, + COMPLETED, + FAILED, + CANCELLED +} diff --git a/src/main/java/ru/soune/no_copy/exception/ChunkSizeExceededException.java b/src/main/java/ru/soune/no_copy/exception/ChunkSizeExceededException.java new file mode 100644 index 0000000..e45f12b --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/ChunkSizeExceededException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.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/no_copy/exception/FileIntegrityException.java b/src/main/java/ru/soune/no_copy/exception/FileIntegrityException.java new file mode 100644 index 0000000..2fece21 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/FileIntegrityException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class FileIntegrityException extends RuntimeException { + public FileIntegrityException(String message) { + super(message); + } +} diff --git a/src/main/java/ru/soune/no_copy/exception/FileUploadException.java b/src/main/java/ru/soune/no_copy/exception/FileUploadException.java new file mode 100644 index 0000000..6c74b1d --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/FileUploadException.java @@ -0,0 +1,11 @@ +package ru.soune.no_copy.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/no_copy/exception/UploadSessionNotFoundException.java b/src/main/java/ru/soune/no_copy/exception/UploadSessionNotFoundException.java new file mode 100644 index 0000000..bfc4bfc --- /dev/null +++ b/src/main/java/ru/soune/no_copy/exception/UploadSessionNotFoundException.java @@ -0,0 +1,7 @@ +package ru.soune.no_copy.exception; + +public class UploadSessionNotFoundException extends RuntimeException { + public UploadSessionNotFoundException(String uploadId) { + super("Upload session not found: " + uploadId); + } +} diff --git a/src/main/java/ru/soune/no_copy/repository/FileUploadSessionRepository.java b/src/main/java/ru/soune/no_copy/repository/FileUploadSessionRepository.java new file mode 100644 index 0000000..de7b788 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/repository/FileUploadSessionRepository.java @@ -0,0 +1,9 @@ +package ru.soune.no_copy.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.soune.no_copy.entity.file.FileUploadSession; + +@Repository +public interface FileUploadSessionRepository extends JpaRepository { +} diff --git a/src/main/java/ru/soune/no_copy/repository/UserContentRepository.java b/src/main/java/ru/soune/no_copy/repository/UserContentRepository.java index 95f524b..42f5e72 100644 --- a/src/main/java/ru/soune/no_copy/repository/UserContentRepository.java +++ b/src/main/java/ru/soune/no_copy/repository/UserContentRepository.java @@ -2,7 +2,7 @@ package ru.soune.no_copy.repository; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import ru.soune.no_copy.entity.FileType; +import ru.soune.no_copy.entity.file.FileType; import ru.soune.no_copy.entity.UserContent; import java.util.List; diff --git a/src/main/java/ru/soune/no_copy/service/UserContentService.java b/src/main/java/ru/soune/no_copy/service/UserContentService.java index eb89f77..eb2573d 100644 --- a/src/main/java/ru/soune/no_copy/service/UserContentService.java +++ b/src/main/java/ru/soune/no_copy/service/UserContentService.java @@ -5,7 +5,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.soune.no_copy.dto.UserContentRequest; import ru.soune.no_copy.dto.UserContentUpdateRequest; -import ru.soune.no_copy.entity.FileType; +import ru.soune.no_copy.entity.file.FileType; import ru.soune.no_copy.entity.User; import ru.soune.no_copy.entity.UserContent; import ru.soune.no_copy.exception.ContentNotFoundException; diff --git a/src/main/java/ru/soune/no_copy/service/file/FileCleanupService.java b/src/main/java/ru/soune/no_copy/service/file/FileCleanupService.java new file mode 100644 index 0000000..65a8f05 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/service/file/FileCleanupService.java @@ -0,0 +1,80 @@ +package ru.soune.no_copy.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/no_copy/service/file/FileUploadService.java b/src/main/java/ru/soune/no_copy/service/file/FileUploadService.java new file mode 100644 index 0000000..dbf4776 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/service/file/FileUploadService.java @@ -0,0 +1,17 @@ +package ru.soune.no_copy.service.file; + +import org.springframework.web.multipart.MultipartFile; +import ru.soune.no_copy.dto.file.UploadProgressResponse; +import ru.soune.no_copy.entity.file.FileUploadSession; + +public interface FileUploadService { + FileUploadSession initUpload(Long userId, String fileName, + String fileType, long fileSize); + + UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, + MultipartFile chunkFile); + + UploadProgressResponse getUploadProgress(String uploadId); + + void cleanupExpiredSessions(); +} diff --git a/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java b/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java new file mode 100644 index 0000000..5030223 --- /dev/null +++ b/src/main/java/ru/soune/no_copy/service/file/FileUploadServiceImpl.java @@ -0,0 +1,364 @@ +package ru.soune.no_copy.service.file; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.no_copy.dto.file.UploadProgressResponse; +import ru.soune.no_copy.entity.file.FileUploadSession; +import ru.soune.no_copy.entity.file.UploadStatus; +import ru.soune.no_copy.exception.ChunkSizeExceededException; +import ru.soune.no_copy.exception.FileUploadException; +import ru.soune.no_copy.exception.UploadSessionNotFoundException; +import ru.soune.no_copy.repository.FileUploadSessionRepository; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.Comparator; +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; + + @PostConstruct + public void init() { + try { + Path storageRoot = Paths.get(basePath).toAbsolutePath().normalize(); + + log.info("Storage will be at: {}", storageRoot); + + if (!Files.exists(storageRoot)) { + Files.createDirectories(storageRoot); + log.info("Created: {}", storageRoot); + } + + Files.createDirectories(storageRoot.resolve("temp")); + Files.createDirectories(storageRoot.resolve("user")); + + } catch (IOException e) { + log.error("CANNOT CREATE STORAGE!", e); + throw new RuntimeException("Storage failed", e); + } + } + + @Override + @Transactional + public FileUploadSession initUpload(Long userId, String fileName, + String fileType, long fileSize) { + if (fileSize > maxFileSize) { + throw new FileUploadException( + String.format("File size %d exceeds maximum allowed size %d", + fileSize, maxFileSize)); + } + + int totalChunks = (int) Math.ceil((double) fileSize / chunkSize); + + FileUploadSession session = FileUploadSession.builder() + .userId(userId) + .fileName(fileName) + .fileType(fileType) + .fileSize(fileSize) + .totalChunks(totalChunks) + .chunksUploaded(0) + .status(UploadStatus.INITIATED) + .build(); + + return sessionRepository.save(session); + } + + @Override + @Transactional + public UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, + MultipartFile chunkFile) { + + log.info("Uploading chunk {} for session {}, chunk size: {} bytes", + chunkNumber, uploadId, chunkFile.getSize()); + + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + if (session.getChunkPaths().containsKey(chunkNumber)) { + log.info("Chunk {} already uploaded for session {}", chunkNumber, uploadId); + return UploadProgressResponse.fromSession(session); + } + + if (chunkFile.getSize() > chunkSize) { + throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize); + } + + try { + log.debug("Chunk {} actual size: {} bytes", chunkNumber, chunkFile.getSize()); + + String chunkPath = saveChunkToDisk(session, chunkNumber, chunkFile); + + session.getChunkPaths().put(chunkNumber, chunkPath); + session.setChunksUploaded(session.getChunksUploaded() + 1); + + log.info("Chunk {} uploaded successfully. Total uploaded: {}/{}", + chunkNumber, session.getChunksUploaded(), session.getTotalChunks()); + + if (session.getChunksUploaded().equals(session.getTotalChunks())) { + session.setStatus(UploadStatus.UPLOADING); + sessionRepository.save(session); + + log.info("All chunks uploaded for session {}. Starting assembly...", uploadId); + + assembleFileAsync(session); + } else { + sessionRepository.save(session); + } + + return UploadProgressResponse.fromSession(session); + + } catch (IOException e) { + log.error("Failed to save chunk {}", chunkNumber, e); + session.setStatus(UploadStatus.FAILED); + sessionRepository.save(session); + throw new RuntimeException("Failed to save chunk: " + e.getMessage(), e); + } + } + + private String saveChunkToDisk(FileUploadSession session, + Integer chunkNumber, + MultipartFile chunkFile) throws IOException { + + Long userId = session.getUserId(); + String uploadId = session.getUploadId(); + + Path chunkDir = Paths.get(basePath, "temp", + "user", String.valueOf(userId), uploadId); + Files.createDirectories(chunkDir); + + String chunkFileName = String.format("chunk_%03d.tmp", chunkNumber); + Path chunkPath = chunkDir.resolve(chunkFileName); + + try (InputStream inputStream = chunkFile.getInputStream(); + OutputStream outputStream = Files.newOutputStream(chunkPath, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + + byte[] buffer = new byte[8192]; + int bytesRead; + long totalBytes = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + totalBytes += bytesRead; + } + + log.debug("Chunk {} saved to {}: {} bytes written", + chunkNumber, chunkPath, totalBytes); + + } catch (Exception e) { + log.error("Error saving chunk {} to {}", chunkNumber, chunkPath, e); + throw e; + } + + long savedSize = Files.size(chunkPath); + log.debug("Chunk {} saved size: {} bytes", chunkNumber, savedSize); + + return chunkPath.toString(); + } + + @Async("fileUploadTaskExecutor") + @Transactional + public void assembleFileAsync(FileUploadSession session) { + try { + assembleFile(session); + log.info("File assembly completed for session {}", session.getUploadId()); + + } catch (Exception e) { + log.error("Failed to assemble file for session {}", + session.getUploadId(), e); + + session.setStatus(UploadStatus.FAILED); + sessionRepository.save(session); + } + } + + private void assembleFile(FileUploadSession session) throws IOException { + Long userId = session.getUserId(); + String uploadId = session.getUploadId(); + + log.info("Assembling file for session {}: {} ({} chunks)", + uploadId, session.getFileName(), session.getTotalChunks()); + + Path finalDir = createUserDirectory(userId, session.getFileType()); + + String safeFileName = generateSafeFileName(session.getFileName()); + Path finalFilePath = finalDir.resolve(safeFileName); + + log.debug("Final file path: {}", finalFilePath); + + try (OutputStream outputStream = new BufferedOutputStream( + Files.newOutputStream(finalFilePath, StandardOpenOption.CREATE))) { + + Path chunkDir = getChunkDirectory(userId, uploadId); + long totalAssembled = 0; + + for (int i = 0; i < session.getTotalChunks(); i++) { + Path chunkPath = chunkDir.resolve(String.format("chunk_%03d.tmp", i)); + + if (!Files.exists(chunkPath)) { + throw new IOException("Missing chunk: " + chunkPath); + } + + byte[] chunkData = Files.readAllBytes(chunkPath); + outputStream.write(chunkData); + totalAssembled += chunkData.length; + + log.debug("Added chunk {}: {} bytes (total: {})", + i, chunkData.length, totalAssembled); + } + + log.info("File assembled: {} bytes total", totalAssembled); + } + + try { + validateFileIntegrity(finalFilePath, session.getFileSize()); + } catch (IOException e) { + log.warn("File size mismatch (temporarily ignored): {}", e.getMessage()); + } + + session.setFilePath(finalFilePath.toString()); + session.setChecksum(calculateChecksum(finalFilePath)); + session.setStatus(UploadStatus.COMPLETED); + session.setCompletedAt(LocalDateTime.now()); + sessionRepository.save(session); + + log.info("File assembled successfully: {} -> {}", + session.getFileName(), finalFilePath); + + try { + cleanupChunks(getChunkDirectory(userId, uploadId)); + } catch (IOException e) { + log.warn("Failed to cleanup chunks: {}", e.getMessage()); + } + } + + private Path createUserDirectory(Long userId, String fileType) throws IOException { + Path userDir = Paths.get(basePath, "user", String.valueOf(userId), extractCategory(fileType)); + + Files.createDirectories(userDir); + + return userDir; + } + + private String extractCategory(String fileType) { + if (fileType.startsWith("image/")) { + return "images"; + } else if (fileType.startsWith("video/")) { + return "videos"; + } else if (fileType.startsWith("audio/")) { + return "audio"; + } else if (fileType.contains("pdf")) { + return "documents"; + } else if (fileType.contains("text")) { + return "text"; + } else { + return "other"; + } + } + + private String generateSafeFileName(String originalName) { + String safeName = originalName.replaceAll("[^a-zA-Z0-9\\.\\-_]", "_"); + + String timestamp = String.valueOf(System.currentTimeMillis()); + int dotIndex = safeName.lastIndexOf('.'); + + if (dotIndex > 0) { + String name = safeName.substring(0, dotIndex); + String extension = safeName.substring(dotIndex); + return name + "_" + timestamp + extension; + } else { + return safeName + "_" + timestamp; + } + } + + private void validateFileIntegrity(Path filePath, long expectedSize) throws IOException { + long actualSize = Files.size(filePath); + log.info("File integrity check: expected={}, actual={}", expectedSize, actualSize); + + if (actualSize != expectedSize) { + throw new IOException( + String.format("File size mismatch: expected %d, got %d", + expectedSize, actualSize)); + } + } + + private String calculateChecksum(Path filePath) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + 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 e) { + log.warn("MD5 algorithm not available, skipping checksum", e); + return "N/A"; + } + } + + private void cleanupChunks(Path chunkDir) throws IOException { + if (Files.exists(chunkDir)) { + try (Stream walk = Files.walk(chunkDir)) { + walk.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + log.info("Cleaned up chunk directory: {}", chunkDir); + } + } + } + + private Path getChunkDirectory(Long userId, String uploadId) { + return Paths.get(basePath, "temp", "user", String.valueOf(userId), uploadId); + } + + @Override + public UploadProgressResponse getUploadProgress(String uploadId) { + FileUploadSession session = sessionRepository.findById(uploadId) + .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); + + return UploadProgressResponse.fromSession(session); + } + + @Override + public void cleanupExpiredSessions() { + log.info("Cleanup not implemented yet"); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e1e88fd..4406117 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,10 +9,34 @@ 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:./uploads} + chunk-size: 5242880 # 5MB + temp-ttl-hours: 24 + max-file-size: 10737418240 # 10GB 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