Merge branch 'dev' into NCBACK-35

# Conflicts:
#	README.md
#	src/main/java/ru/soune/nocopy/controller/ApiController.java
#	src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java
#	src/main/java/ru/soune/nocopy/service/file/FileCleanupService.java
#	src/main/java/ru/soune/nocopy/service/file/FileEntityService.java
#	src/main/java/ru/soune/nocopy/service/file/impl/FileUploadServiceImpl.java
#	src/main/java/ru/soune/nocopy/service/file/impl/ProtectionFileProviderImpl.java
This commit is contained in:
vladp
2026-03-31 14:24:03 +07:00
229 changed files with 10416 additions and 757 deletions
@@ -38,7 +38,6 @@ public class FileCleanupService {
public void cleanupExpiredFiles() {
log.info("Starting cleanup of expired temporary files");
cleanupOldPeriods();
cleanupTempFilesNightly();
Path tempDir = Paths.get(basePath, "temp");
@@ -91,19 +90,6 @@ public class FileCleanupService {
}
}
private void cleanupOldPeriods() {
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
List<ProtectedFileCheck> checks = protectedFileCheckRepository.findByPeriodStartDateBefore(thirtyDaysAgo);
for (ProtectedFileCheck check : checks) {
check.cleanupOldData();
}
protectedFileCheckRepository.saveAll(checks);
log.info("Cleaned up {} old protection usage records", checks.size());
}
private void cleanupTempFilesNightly() {
log.info("Starting nightly cleanup of temporary files");
@@ -2,19 +2,20 @@ package ru.soune.nocopy.service.file;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
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.entity.file.ProtectionStatus;
import ru.soune.nocopy.entity.monitoring.FileMonitoringEntity;
import ru.soune.nocopy.entity.monitoring.MonitoringType;
import ru.soune.nocopy.entity.user.User;
import ru.soune.nocopy.exception.DuplicateImageException;
import ru.soune.nocopy.exception.FileEntityNotFoundException;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.repository.FileMonitoringRepository;
import ru.soune.nocopy.repository.UserRepository;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
@@ -27,7 +28,7 @@ import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
@Slf4j
@@ -37,70 +38,14 @@ public class FileEntityService {
private final FileEntityRepository fileEntityRepository;
private final ImageHashService imageHashService;
private final FileSimilarityService fileSimilarityService;
private final UserRepository userRepository;
private final ImageResizeService imageResizeService;
private final CloudStorageService cloudStorageService;
@Transactional(noRollbackFor = DuplicateImageException.class)
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);
}
Map<String, Long> imageHash = Map.of();
if (!session.getFileType().startsWith("video")) {
imageHash = imageHashService.calculateHash(filePath);
List<SimilarImageProjection> duplicatedByHash = fileSimilarityService.findDuplicatedByHash(
imageHash.get("hi"), imageHash.get("low"));
if (!duplicatedByHash.isEmpty()) {
SimilarImageProjection similarImageProjection = duplicatedByHash.get(0);
throw new DuplicateImageException("Duplicate", similarImageProjection.getId(),
similarImageProjection.getUserId());
}
}
long fileSize = Files.size(filePath);
String originalName = session.getFileName();
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(session.getExtension())
.checksum(checksum)
.uploadSessionId(session.getUploadId())
.status(FileStatus.ACTIVE)
.build();
FileEntity saved = fileEntityRepository.save(fileEntity);
if (!imageHash.isEmpty()) {
imageHashService.create(saved, imageHash);
}
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);
}
}
@Value("${server.baseurl}")
private String baseUrl;
@Transactional(readOnly = true)
public FileEntityResponse getById(String fileId, int version) {
@@ -119,6 +64,34 @@ public class FileEntityService {
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 List<FileEntity> getAllUserFiles(Long userId) {
User user = userRepository.findById(userId).orElseThrow();
List<FileEntity> allFiles = new ArrayList<>();
if (user.getCompany() != null) {
for (Long uId : userRepository.findByCompanyId(user.getCompany().getId()).stream()
.map(User::getId)
.toList()) {
allFiles.addAll(fileEntityRepository.findByUserIdAndStatusIn(uId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED)));
}
} else {
allFiles = fileEntityRepository.findByUserIdAndStatusIn(userId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED));
}
return allFiles;
}
@Transactional(readOnly = true)
public FileResponse getUserFiles(Long userId, int page, int pageSize, int version) {
User user = userRepository.findById(userId).orElseThrow();
@@ -128,12 +101,13 @@ public class FileEntityService {
for (Long uId : userRepository.findByCompanyId(user.getCompany().getId()).stream()
.map(User::getId)
.toList()) {
allFiles.addAll(fileEntityRepository.findByUserIdAndStatus(uId, FileStatus.ACTIVE));
allFiles.addAll(fileEntityRepository.findByUserIdAndStatusIn(uId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED)));
}
} else {
allFiles = fileEntityRepository.findByUserIdAndStatus(
userId, FileStatus.ACTIVE);
allFiles = fileEntityRepository.findByUserIdAndStatusIn(userId,
List.of(FileStatus.ACTIVE, FileStatus.BLOCKED));
}
int start = (page - 1) * pageSize;
@@ -175,13 +149,13 @@ public class FileEntityService {
fileEntity.setImageHash(null);
}
fileEntity.setStatus(FileStatus.DELETED);
fileEntity.setStatus(FileStatus.REMOVED);
fileEntity.setUpdatedAt(LocalDateTime.now());
fileEntity.setProtectionStatus(ProtectionStatus.NOT_PROTECTED);
// fileEntity.setProtectionStatus(ProtectionStatus.NOT_PROTECTED);
Path path = Paths.get(fileEntity.getProtectedFilePath());
Files.deleteIfExists(path);
fileEntity.setProtectedFilePath("");
// Path path = Paths.get(fileEntity.getProtectedFilePath());
// Files.deleteIfExists(path);
// fileEntity.setProtectedFilePath("");
fileEntityRepository.save(fileEntity);
}
@@ -196,7 +170,7 @@ public class FileEntityService {
return totalSize != null ? totalSize : 0L;
}
public void changeStatus(ProtectionStatus newStatus, String fileId) {
public void changeProtectionStatus(ProtectionStatus newStatus, String fileId) {
FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
fileEntity.setProtectionStatus(newStatus);
@@ -204,6 +178,14 @@ public class FileEntityService {
fileEntityRepository.save(fileEntity);
}
public void changeFileStatus(FileStatus newStatus, String fileId) {
FileEntity fileEntity = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
fileEntity.setStatus(newStatus);
fileEntityRepository.save(fileEntity);
}
@Transactional
public FileEntity writeProtectedFile(String id, byte[] data, String fileExt) throws IOException {
FileEntity fileEntity = fileEntityRepository.findById(id)
@@ -218,6 +200,10 @@ public class FileEntityService {
Files.write(protectedFilePath, data);
if (imageResizeService.isImage(extension)) {
imageResizeService.generateSizes(fileEntity, data);
}
fileEntity.setProtectedFilePath(protectedFilePath.toString());
fileEntity.setProtectedAt(LocalDateTime.now());
fileEntity.setUpdatedAt(LocalDateTime.now());
@@ -308,14 +294,29 @@ public class FileEntityService {
}
}
private final FileMonitoringRepository fileMonitoringRepository;
private FileEntityResponse convertToResponse(FileEntity fileEntity, int version) {
boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath());
User user = userRepository.findById(fileEntity.getUserId()).get();
String fileName = fileEntity.getOriginalFileName();
String name = fileName;
Optional<FileMonitoringEntity> fileMonitoringRepositoryByFileId =
fileMonitoringRepository.findByFileId(fileEntity.getId());
String monitoring = fileMonitoringRepositoryByFileId.map(fileMonitoringEntity ->
fileMonitoringEntity.getMonitoringType().name())
.orElseGet(MonitoringType.NONE::name);
int lastDotIndex = fileName.lastIndexOf(".");
if (lastDotIndex > 0) {
name = fileName.substring(0, lastDotIndex);
}
return FileEntityResponse.builder()
.id(fileEntity.getId())
.userId(fileEntity.getUserId())
.originalFileName(fileEntity.getOriginalFileName())
.originalFileName(name + "." + fileEntity.getFileExtension())
.storedFileName(fileEntity.getStoredFileName())
.filePath(fileEntity.getProtectedFilePath())
.fileSize(fileEntity.getFileSize())
@@ -331,16 +332,16 @@ public class FileEntityService {
.existsOnDisk(existsOnDisk)
.supportId(fileEntity.getSupportId())
.protectStatus(fileEntity.getProtectionStatus().toString())
.fileName(fileEntity.getOriginalFileName().replace("." +
fileEntity.getFileExtension(), "") + "_nocopy_protected" +
"." + fileEntity.getFileExtension())
.fileName(name + "_nocopy_protected" + "." + fileEntity.getFileExtension())
.ownerName(user.getFullName())
.ownerEmail(user.getEmail())
.ownerCompany(user.getCompanyName())
.monitoring(monitoring)
//TODO fix after add logic for check for protect file
.checksCount(0)
.fileUploadDate(fileEntity.getCreatedAt())
.protectedFilePath(fileEntity.getProtectedFilePath())
.thumbnailFileUrl(baseUrl + "/api/files/protected/" + fileEntity.getId() + "/thumbnail")
.build();
}
}
@@ -1,53 +0,0 @@
package ru.soune.nocopy.service.file;
import com.vrt.NoCopyFileService;
import com.vrt.fileprotection.FileProtector;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.entity.file.ProtectionStatus;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.util.FileUtil;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class FileProcessingOrchestrator {
private final NoCopyFileService noCopyFileService;
private final FileEntityRepository fileRepository;
private final FileUtil fileUtil;
public void initializeProcessingQueue() {
List<FileEntity> filesToProtect = fileRepository.findByProtectionStatus(ProtectionStatus.NOT_PROTECTED);
for (FileEntity fileEntity : filesToProtect) {
try {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
log.info("Add to query: {}", fileEntity.getOriginalFileName());
} catch (Exception e) {
log.error("Fail add to query: {}", fileEntity.getId(), e);
}
}
}
@Scheduled(fixedDelay = 120000)
public void checkNewFilesForProtection() {
List<FileEntity> newFiles = fileRepository.findAllActiveFilesAndNotProtected();
for (FileEntity fileEntity : newFiles) {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
}
}
}
@@ -1,11 +1,10 @@
package ru.soune.nocopy.service.file;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import ru.soune.nocopy.configuration.file.FileStorageConfig;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -16,11 +15,11 @@ import java.nio.file.Paths;
@Slf4j
public class FileStorageService {
@Autowired
private FileStorageConfig storageConfig;
@Value("${file.storage.base-path}")
private String basePath;
public Resource loadFileAsResource(String filePath) throws IOException {
Path path = Paths.get(storageConfig.getBasePath()).resolve(filePath).normalize();
Path path = Paths.get(basePath).resolve(filePath).normalize();
Resource resource = new UrlResource(path.toUri());
if (!resource.exists()) {
@@ -10,7 +10,7 @@ import java.io.IOException;
public interface FileUploadService {
FileUploadSession initUpload(Long userId, String fileName,
String fileType, String extension, long fileSize);
String fileType, String extension, String convertTo, long fileSize);
void handleExpiredSession(FileUploadSession session);
@@ -0,0 +1,92 @@
package ru.soune.nocopy.service.file;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.entity.file.FileEntity;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
@Service
@Slf4j
public class ImageResizeService {
private static final int THUMBNAIL_SIZE = 150;
private static final int MEDIUM_SIZE = 600;
@Value("${file.storage.base-path}")
private String storagePath;
public boolean isImage(String extension) {
return extension != null && List.of("jpg", "jpeg", "png", "webp")
.contains(extension.toLowerCase());
}
@Transactional
public void generateSizes(FileEntity file, byte[] data) throws IOException {
if (!isImage(file.getFileExtension())) {
return;
}
try (ByteArrayInputStream bis = new ByteArrayInputStream(data)) {
BufferedImage original = ImageIO.read(bis);
if (original == null) {
log.warn("Cannot read image: {}", file.getId());
return;
}
String baseName = file.getId().replace(".", "_");
if (original.getWidth() > THUMBNAIL_SIZE) {
String thumbPath = resizeAndSave(original, baseName + "_thumb", THUMBNAIL_SIZE);
file.setThumbnailPath(thumbPath);
}
if (original.getWidth() > MEDIUM_SIZE) {
String mediumPath = resizeAndSave(original, baseName + "_medium", MEDIUM_SIZE);
file.setMediumPath(mediumPath);
}
}
}
private String resizeAndSave(BufferedImage original, String fileName, int targetWidth) throws IOException {
int targetHeight = (int) ((double) original.getHeight() / original.getWidth() * targetWidth);
BufferedImage resized = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = resized.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.drawImage(original, 0, 0, targetWidth, targetHeight, null);
g.dispose();
Path dirPath = Paths.get(storagePath, "resized");
Files.createDirectories(dirPath);
Path filePath = dirPath.resolve(fileName + ".jpg");
ImageIO.write(resized, "jpg", filePath.toFile());
return filePath.toString();
}
public String getPath(FileEntity file, String size) {
if ("thumbnail".equals(size) && file.getThumbnailPath() != null) {
return file.getThumbnailPath();
}
if ("medium".equals(size) && file.getMediumPath() != null) {
return file.getMediumPath();
}
return file.getProtectedFilePath();
}
}
@@ -26,7 +26,9 @@ public class AudioLocalSearchImpl implements AudioLocalSearch {
public @Nullable FileProtector.FileInfo findBySignature(@NotNull String signature) {
FileEntity fileEntity = fileEntityService.findBySignature(signature);
return fileUtil.createFileInfo(fileEntity);
if (fileEntity == null) return null;
return fileUtil.createFileInfo(fileEntity, null);
}
@Override
@@ -23,7 +23,7 @@ public class DocumentLocalSearchImpl implements DocumentLocalSearch {
@Override
public @Nullable FileProtector.FileInfo findByIds(@NotNull String ownerId, @NotNull String fileId) {
FileEntity fileEntity = fileEntityRepository.findByIdAndUserId(fileId, Long.valueOf(ownerId));
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity, null);
log.info("fileInfo: {}", fileInfo);
return fileInfo;
@@ -2,27 +2,29 @@ package ru.soune.nocopy.service.file.impl;
import com.vrt.NoCopyFileService;
import com.vrt.fileprotection.FileProtector;
import com.vrt.fileprotection.NoCopyCheckResult;
import com.vrt.fileprotection.audio.AudioCheckResult;
import com.vrt.fileprotection.documents.DocumentCheckResult;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
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.context.annotation.Lazy;
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.*;
import ru.soune.nocopy.exception.ChunkSizeExceededException;
import ru.soune.nocopy.exception.DuplicateImageException;
import ru.soune.nocopy.exception.FileUploadException;
import ru.soune.nocopy.exception.UploadSessionNotFoundException;
import ru.soune.nocopy.exception.*;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.repository.SimilarImageProjection;
import ru.soune.nocopy.service.FileSimilarityService;
import ru.soune.nocopy.service.ImageHashService;
import ru.soune.nocopy.service.cost.CostService;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
@@ -59,9 +61,6 @@ public class FileUploadServiceImpl implements FileUploadService {
@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;
@@ -79,6 +78,9 @@ public class FileUploadServiceImpl implements FileUploadService {
@Autowired
private FileEntityService fileEntityService;
private final CostService costService;
@Lazy
private final NoCopyFileService noCopyFileService;
private final FileUtil fileUtil;
@@ -112,7 +114,7 @@ public class FileUploadServiceImpl implements FileUploadService {
@Override
@Transactional
public FileUploadSession initUpload(Long userId, String fileName,
String fileType, String extension, long fileSize) {
String fileType, String extension, String convertTo, long fileSize) {
log.info("Initializing upload for user {}: {} ({} bytes, type: {})",
userId, fileName, fileSize, fileType);
@@ -143,6 +145,7 @@ public class FileUploadServiceImpl implements FileUploadService {
.status(UploadStatus.INITIATED)
.expiresAt(LocalDateTime.now().plusHours(sessionExpiryHours))
.retryCount(0)
.convertTo(convertTo)
.build();
FileUploadSession savedSession = sessionRepository.save(session);
@@ -252,6 +255,12 @@ public class FileUploadServiceImpl implements FileUploadService {
cleanupSessionFiles(session);
if (status != FileStatus.TEMP) {
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity, session.getConvertTo());
noCopyFileService.addFile(fileInfo);
}
log.info("File processing completed for session: {}", session.getUploadId());
} catch (Exception e) {
log.error("Failed to complete file processing for session {}: {}",
@@ -313,21 +322,58 @@ public class FileUploadServiceImpl implements FileUploadService {
chunkNumber, session.getChunksUploaded(), session.getTotalChunks());
boolean isLastChunk = session.getChunksUploaded().equals(session.getTotalChunks());
//TODO CHECK
if (isLastChunk) {
log.info("All chunks uploaded for session {}. Starting assembly...",
session.getUploadId());
String finalFilePath = assembleFileSynchronously(session);
if (session.getFileType().startsWith("image") && findSimilar == 0) {
checkForDuplicatesSynchronously(finalFilePath);
if (findSimilar == 0) {
if (session.getFileType().startsWith("image")) {
checkForDuplicatesSynchronously(finalFilePath);
} else if (session.getFileType().startsWith("audio") || session.getFileType().startsWith("document")) {
fileSimilarityService.hasDuplicatesByHash(finalFilePath ,session.getFileType(),
session.getUserId());
File file = new File(finalFilePath);
if (session.getFileType().startsWith("audio") && !isWavFile(file)) {
session.setStatus(UploadStatus.FAILED);
session.setLastError("wav file not have RIFF header");
sessionRepository.save(session);
throw new FileFormatException("Failed to upload chunk: .wav file not have RIFF header or " +
"another problems with .wav file");
}
FileProtector.Type type = "document".equals(session.getFileType()) ?
FileProtector.Type.DOC : FileProtector.Type.valueOf(session.getFileType().toUpperCase());
NoCopyCheckResult checkResult = noCopyFileService.checkFile(file, type);
switch (checkResult) {
case DocumentCheckResult.Success success -> {
FileProtector.FileInfo info = success.getInfo();
throw new DuplicateImageException("Duplicate", info.getId(),
Long.valueOf(info.getOwnerId()));
}
case AudioCheckResult.Success success -> {
FileProtector.FileInfo info = success.getInfo();
throw new DuplicateImageException("Duplicate", info.getId(),
Long.valueOf(info.getOwnerId()));
}
case DocumentCheckResult.Failed failed ->
log.warn("Document check failed: {}", failed.getMessage());
case AudioCheckResult.Failed failed ->
log.warn("Audio check failed: {}", failed.getMessage());
default -> log.warn("Unexpected result type: {}", checkResult.getClass().getSimpleName());
}
}
}
session.setStatus(UploadStatus.COMPLETED);
session.setFilePath(finalFilePath);
FileStatus status = findSimilar == 0 ? FileStatus.ACTIVE: FileStatus.TEMP;
FileStatus status = findSimilar == 0 ? FileStatus.MODERATION: FileStatus.TEMP;
if (status == FileStatus.TEMP) {
fileEntityService.clearTempFiles(session.getUserId());
@@ -351,7 +397,7 @@ public class FileUploadServiceImpl implements FileUploadService {
if (status != FileStatus.TEMP) {
String fileType = session.getFileType();
tariffInfoService.writeOffTokens(session.getUserId(), protectCost(fileType));
tariffInfoService.writeOffTokens(session.getUserId(), costService.protectFileCost(fileType));
}
} else {
sessionRepository.save(session);
@@ -388,21 +434,87 @@ public class FileUploadServiceImpl implements FileUploadService {
}
}
private int protectCost(String fileType) {
int cost = 0;
switch (fileType) {
case "video":
return TariffConstants.VIDEO_TOKEN_VALUE;
case "image":
return TariffConstants.IMAGE_TOKEN_VALUE;
case "audio":
return TariffConstants.AUDIO_TOKEN_VALUE;
case "document":
return TariffConstants.PDF_TOKEN_VALUE;
//TODO убрать когда научимся защищать другие типы
public boolean isWavFile(File file) {
if (file == null || !file.exists() || file.length() < 12) {
return false;
}
return cost;
try (FileInputStream fis = new FileInputStream(file)) {
byte[] header = new byte[4];
int bytesRead = fis.read(header);
if (bytesRead < 4) {
return false;
}
return header[0] == 82 && header[1] == 73 &&
header[2] == 70 && header[3] == 70;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
public static boolean isValidWav(File file) {
if (file == null || !file.exists() || file.length() < 44) {
return false;
}
try (FileInputStream fis = new FileInputStream(file)) {
byte[] header = new byte[44];
if (fis.read(header) < 44) {
return false;
}
if (header[0] != 'R' || header[1] != 'I' ||
header[2] != 'F' || header[3] != 'F') {
return false;
}
if (header[8] != 'W' || header[9] != 'A' ||
header[10] != 'V' || header[11] != 'E') {
return false;
}
if (header[12] != 'f' || header[13] != 'm' ||
header[14] != 't' || header[15] != ' ') {
return false;
}
if (header[36] != 'd' || header[37] != 'a' ||
header[38] != 't' || header[39] != 'a') {
return false;
}
int audioFormat = (header[20] & 0xFF) | (header[21] & 0xFF) << 8;
if (audioFormat != 1 && audioFormat != 3) {
return false;
}
int numChannels = (header[22] & 0xFF) | (header[23] & 0xFF) << 8;
if (numChannels < 1 || numChannels > 8) {
return false;
}
int sampleRate = (header[24] & 0xFF) | (header[25] & 0xFF) << 8 |
(header[26] & 0xFF) << 16 | (header[27] & 0xFF) << 24;
if (sampleRate < 8000 || sampleRate > 192000) {
return false;
}
int bitsPerSample = (header[34] & 0xFF) | (header[35] & 0xFF) << 8;
if (bitsPerSample < 8 || bitsPerSample > 32) {
return false;
}
return true;
} catch (IOException e) {
e.printStackTrace();
return false;
}
}
private String assembleFileSynchronously(FileUploadSession session) throws IOException {
@@ -510,76 +622,6 @@ public class FileUploadServiceImpl implements FileUploadService {
}
}
private void assembleFile(FileUploadSession session) throws IOException {
log.info("Starting file assembly for session: {} ({})",
session.getUploadId(), session.getFileName());
Path finalFilePath = null;
String checksum;
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 {
FileEntity fileEntity = fileEntityService.createFromUploadSession(session, checksum);
FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity);
noCopyFileService.addFile(fileInfo);
log.info("FileEntity successfully created for session: {}",
session.getUploadId());
} catch (DuplicateImageException e) {
throw new DuplicateImageException("Duplicate", e.duplicateFileId(), e.userId());
} 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()))
@@ -44,11 +44,13 @@ public class ImageLocalSearchImpl implements ImageLocalSearch {
@Override
public @Nullable FileProtector.FileInfo findByPHash(@NotNull PHash pHash) {
ImageHashEntity imageHashEntity =
imageHashRepository.findByHash64HiAndHash64Lo(pHash.getFirstPart(),
pHash.getSecondPart());
imageHashRepository.findByHash64HiAndHash64Lo(pHash.getFirstPart(), pHash.getSecondPart());
if (imageHashEntity == null) return null;
FileEntity file = fileEntityRepository.findByFileId(imageHashEntity.getFileId());
return new FileProtector.FileInfo(FileProtector.Type.IMAGE, imageHashEntity.getFileId(),
String.valueOf(file.getUserId()));
String.valueOf(file.getUserId()), null);
}
}
@@ -16,21 +16,21 @@ public class NoCopyProcessingListenerImpl implements FileProtector.ProcessingLis
@Override
public void onStartProcessing(FileProtector.FileInfo fileInfo) {
fileEntityService.changeStatus(ProtectionStatus.PROCESSING, fileInfo.getId());
fileEntityService.changeProtectionStatus(ProtectionStatus.PROCESSING, fileInfo.getId());
}
@Override
public void onProcessingFailed(FileProtector.FileInfo fileInfo) {
fileEntityService.changeStatus(ProtectionStatus.FAILED, fileInfo.getId());
fileEntityService.changeProtectionStatus(ProtectionStatus.FAILED, fileInfo.getId());
}
@Override
public void onSavingFailed(FileProtector.FileInfo fileInfo) {
fileEntityService.changeStatus(ProtectionStatus.FAILED_SAVE, fileInfo.getId());
fileEntityService.changeProtectionStatus(ProtectionStatus.FAILED_SAVE, fileInfo.getId());
}
@Override
public void onFinish(FileProtector.FileInfo fileInfo) {
fileEntityService.changeStatus(ProtectionStatus.PROTECTED, fileInfo.getId());;
fileEntityService.changeProtectionStatus(ProtectionStatus.PROTECTED, fileInfo.getId());;
}
}
@@ -9,7 +9,11 @@ import org.jetbrains.annotations.Nullable;
import org.springframework.stereotype.Component;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.FileUploadSessionRepository;
import ru.soune.nocopy.entity.file.FileEntity;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import ru.soune.nocopy.service.file.FileUploadService;
import ru.soune.nocopy.service.file.cloud.CloudStorageService;
import java.io.File;
@@ -27,6 +31,10 @@ public class ProtectionFileProviderImpl implements FileProtector.FileProvider {
private final FileEntityService fileEntityService;
private final FileUploadSessionRepository fileUploadSessionRepository;
private final FileEntityRepository fileEntityRepository;
private final CloudStorageService cloudStorageService;
private final FileEntityRepository fileEntityRepository;
@@ -0,0 +1,238 @@
package ru.soune.nocopy.service.file.moderation;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.soune.nocopy.dto.file.*;
import ru.soune.nocopy.entity.file.*;
import ru.soune.nocopy.entity.file.moderation.ModerationLog;
import ru.soune.nocopy.exception.FileEntityNotFoundException;
import ru.soune.nocopy.exception.InvalidAppealException;
import ru.soune.nocopy.repository.FileAppealRepository;
import ru.soune.nocopy.repository.FileEntityRepository;
import ru.soune.nocopy.repository.ModerationLogRepository;
import ru.soune.nocopy.service.file.FileEntityService;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ModerationService {
private final FileEntityRepository fileEntityRepository;
private final FileAppealRepository fileAppealRepository;
private final ModerationLogRepository moderationLogRepository;
private final FileEntityService fileEntityService;
private static final List<FileStatus> MODERATION_NEEDED_STATUSES = Arrays.asList(
FileStatus.MODERATION,
FileStatus.BLOCKED
);
/**
* Модерация файла администратором
*/
@Transactional
public void moderateFile(ModerationActionRequest request, Long moderatorId) {
FileEntity file = fileEntityRepository.findById(request.getFileId())
.orElseThrow(() -> new FileEntityNotFoundException(request.getFileId()));
if (!MODERATION_NEEDED_STATUSES.contains(file.getStatus())) {
throw new IllegalStateException("File is not in moderation state. Current status: " + file.getStatus());
}
ModerationLog log = ModerationLog.builder()
.fileId(file.getId())
.moderatorId(moderatorId)
.oldStatus(file.getStatus())
.newStatus(request.getNewStatus())
.reason(request.getComment())
.comment(request.getComment())
.build();
moderationLogRepository.save(log);
file.setStatus(request.getNewStatus());
fileEntityRepository.save(file);
if (request.getNewStatus() == FileStatus.BLOCKED ||
request.getNewStatus() == FileStatus.VIOLATION) {
fileAppealRepository.findByFileIdAndStatus(file.getId(), AppealStatus.PENDING)
.ifPresent(appeal -> {
appeal.setStatus(AppealStatus.REJECTED);
appeal.setAdminComment(request.getComment());
appeal.setResolvedAt(LocalDateTime.now());
fileAppealRepository.save(appeal);
});
} else if (request.getNewStatus() == FileStatus.REMOVED) {
try {
fileEntityService.softDeleteFileWithHash(file);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
/**
* Подача апелляции пользователем
*/
@Transactional
public AppealResponse submitAppeal(AppealRequest request, Long userId) {
FileEntity file = fileEntityRepository.findById(request.getFileId())
.orElseThrow(() -> new FileEntityNotFoundException(request.getFileId()));
if (!file.getUserId().equals(userId)) {
throw new SecurityException("User is not the owner of this file");
}
if (!canAppeal(file)) {
throw new InvalidAppealException("Cannot appeal this file. Current status: " + file.getStatus());
}
if (hasActiveAppeal(file.getId())) {
throw new InvalidAppealException("Active appeal already exists for this file");
}
FileAppeal appeal = FileAppeal.builder()
.fileId(file.getId())
.userId(userId)
.appealReason(request.getAppealReason())
.additionalInfo(request.getAdditionalInfo())
.status(AppealStatus.PENDING)
.moderationBeforeStatus(file.getStatus())
.build();
FileAppeal saved = fileAppealRepository.save(appeal);
return convertToResponse(saved);
}
/**
* Рассмотрение апелляции администратором
*/
@Transactional
public void reviewAppeal(String appealId, boolean approve, String comment, Long moderatorId) {
FileAppeal appeal = fileAppealRepository.findById(appealId)
.orElseThrow(() -> new RuntimeException("Appeal not found: " + appealId));
if (appeal.getStatus() != AppealStatus.PENDING) {
throw new IllegalStateException("Appeal already processed");
}
FileEntity file = fileEntityRepository.findById(appeal.getFileId())
.orElseThrow(() -> new FileEntityNotFoundException(appeal.getFileId()));
appeal.setStatus(approve ? AppealStatus.APPROVED : AppealStatus.REJECTED);
appeal.setAdminComment(comment);
appeal.setModeratorId(moderatorId);
appeal.setResolvedAt(LocalDateTime.now());
if (approve) {
file.setStatus(FileStatus.ACTIVE);
}
ModerationLog log = ModerationLog.builder()
.fileId(file.getId())
.moderatorId(moderatorId)
.oldStatus(file.getStatus())
.newStatus(file.getStatus())
.comment("Appeal review: " + comment)
.build();
moderationLogRepository.save(log);
fileEntityRepository.save(file);
fileAppealRepository.save(appeal);
}
/**
* Проверка, можно ли подать апелляцию на файл
*/
public boolean canAppeal(FileEntity file) {
return file.getStatus() == FileStatus.BLOCKED;
}
/**
* Проверка наличия активной апелляции
*/
public boolean hasActiveAppeal(String fileId) {
return fileAppealRepository.existsByFileIdAndStatusIn(
fileId,
Arrays.asList(AppealStatus.PENDING, AppealStatus.IN_REVIEW)
);
}
/**
* Получение информации о файле для модерации
*/
@Transactional(readOnly = true)
public FileModerationInfo getFileModerationInfo(String fileId) {
FileEntity file = fileEntityRepository.findById(fileId)
.orElseThrow(() -> new FileEntityNotFoundException(fileId));
FileAppeal activeAppeal = fileAppealRepository.findByFileIdAndStatus(
fileId, AppealStatus.PENDING).orElse(null);
return FileModerationInfo.builder()
.fileId(file.getId())
.fileName(file.getOriginalFileName())
.currentStatus(file.getStatus())
.uploadDate(file.getCreatedAt())
.hasActiveAppeal(activeAppeal != null)
.activeAppeal(activeAppeal != null ?
AppealInfo.builder()
.appealId(activeAppeal.getId())
.status(activeAppeal.getStatus().name())
.createdAt(activeAppeal.getCreatedAt())
.build() : null)
.build();
}
/**
* Получение списка файлов на модерации
*/
@Transactional(readOnly = true)
public Page<FileEntity> getFilesForModeration(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return fileEntityRepository.findByStatusIn(
Arrays.asList(FileStatus.MODERATION, FileStatus.BLOCKED),
pageable
);
}
/**
* Получение апелляций пользователя
*/
@Transactional(readOnly = true)
public Page<AppealResponse> getUserAppeals(Long userId, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return fileAppealRepository.findByUserId(userId, pageable)
.map(this::convertToResponse);
}
private AppealResponse convertToResponse(FileAppeal appeal) {
return AppealResponse.builder()
.appealId(appeal.getId())
.fileId(appeal.getFileId())
.appealReason(appeal.getAppealReason())
.additionalInfo(appeal.getAdditionalInfo())
.status(appeal.getStatus().name())
.adminComment(appeal.getAdminComment())
.createdAt(appeal.getCreatedAt())
.resolvedAt(appeal.getResolvedAt())
.build();
}
}