2026-01-13 22:40:52 +07:00
|
|
|
package ru.soune.nocopy.service;
|
|
|
|
|
|
2026-02-17 12:46:54 +07:00
|
|
|
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;
|
2026-01-27 20:27:39 +07:00
|
|
|
import com.vrt.fileprotection.image.phash.PHash;
|
|
|
|
|
import com.vrt.fileprotection.image.phash.PerceptualHashHelper;
|
2026-01-13 22:40:52 +07:00
|
|
|
import lombok.RequiredArgsConstructor;
|
2026-01-25 20:45:29 +07:00
|
|
|
import lombok.extern.slf4j.Slf4j;
|
2026-02-17 13:00:32 +07:00
|
|
|
import org.hibernate.validator.internal.util.stereotypes.Lazy;
|
2026-02-17 12:55:25 +07:00
|
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
2026-01-27 20:27:39 +07:00
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
2026-01-16 14:30:45 +07:00
|
|
|
import org.springframework.data.domain.Page;
|
|
|
|
|
import org.springframework.data.domain.PageImpl;
|
|
|
|
|
import org.springframework.data.domain.Pageable;
|
2026-01-13 22:40:52 +07:00
|
|
|
import org.springframework.stereotype.Service;
|
2026-01-22 19:55:09 +07:00
|
|
|
import ru.soune.nocopy.dto.file.SimilarFileDTO;
|
2026-01-16 14:30:45 +07:00
|
|
|
import ru.soune.nocopy.dto.file.SimilarityFilter;
|
2026-02-16 16:41:29 +07:00
|
|
|
import ru.soune.nocopy.entity.file.FileEntity;
|
2026-01-25 22:05:19 +07:00
|
|
|
import ru.soune.nocopy.entity.file.ImageHashEntity;
|
2026-02-16 20:16:50 +07:00
|
|
|
import ru.soune.nocopy.exception.DuplicateImageException;
|
2026-02-16 16:41:29 +07:00
|
|
|
import ru.soune.nocopy.repository.*;
|
2026-01-22 19:55:09 +07:00
|
|
|
import ru.soune.nocopy.util.FileUtil;
|
2026-01-13 22:40:52 +07:00
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
import java.io.FileInputStream;
|
|
|
|
|
import java.io.InputStream;
|
2026-02-17 12:46:54 +07:00
|
|
|
import java.nio.file.Path;
|
|
|
|
|
import java.nio.file.Paths;
|
2026-02-16 16:41:29 +07:00
|
|
|
import java.security.MessageDigest;
|
2026-01-25 22:05:19 +07:00
|
|
|
import java.util.*;
|
2026-01-16 14:30:45 +07:00
|
|
|
import java.util.stream.Collectors;
|
2026-01-13 22:40:52 +07:00
|
|
|
|
|
|
|
|
@Service
|
|
|
|
|
@RequiredArgsConstructor
|
2026-01-25 20:45:29 +07:00
|
|
|
@Slf4j
|
2026-01-13 22:40:52 +07:00
|
|
|
public class FileSimilarityService {
|
2026-01-22 19:55:09 +07:00
|
|
|
|
2026-01-13 22:40:52 +07:00
|
|
|
private final ImageSimilarityRepository repository;
|
2026-01-16 14:30:45 +07:00
|
|
|
|
2026-01-13 22:40:52 +07:00
|
|
|
private final ImageHashRepository hashRepository;
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
private final FileUtil fileUtil;
|
|
|
|
|
|
2026-01-26 21:03:45 +07:00
|
|
|
private final AuthTokenRepository authTokenRepository;
|
|
|
|
|
|
2026-02-16 20:11:06 +07:00
|
|
|
private final FileEntityRepository fileEntityRepository;
|
|
|
|
|
|
2026-01-27 20:27:39 +07:00
|
|
|
@Value("${server.baseurl}")
|
|
|
|
|
private String baseUrl;
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
public List<SimilarFileDTO> findSimilarFiles(String fileId) {
|
2026-01-13 22:40:52 +07:00
|
|
|
var imageHashEntity = hashRepository.findById(fileId)
|
|
|
|
|
.orElseThrow(() -> new RuntimeException("Hash not found"));
|
|
|
|
|
|
2026-01-23 13:22:02 +07:00
|
|
|
Long hash64Hi = imageHashEntity.getHash64Hi();
|
|
|
|
|
Long hash64Lo = imageHashEntity.getHash64Lo();
|
2026-01-13 22:40:52 +07:00
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
|
2026-01-13 22:40:52 +07:00
|
|
|
|
|
|
|
|
return candidates.stream()
|
|
|
|
|
.map(c -> {
|
2026-01-23 13:22:02 +07:00
|
|
|
Long cHi = c.getHash64Hi();
|
|
|
|
|
Long cLo = c.getHash64Lo();
|
2026-01-13 22:40:52 +07:00
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
int hamming = fileUtil.hamming64(hash64Hi, hash64Lo, cHi, cLo);
|
2026-01-13 22:40:52 +07:00
|
|
|
|
|
|
|
|
String level;
|
|
|
|
|
if (hamming <= 5) {
|
|
|
|
|
level = "DUPLICATE";
|
|
|
|
|
} else if (hamming <= 12) {
|
|
|
|
|
level = "SIMILAR";
|
|
|
|
|
} else {
|
|
|
|
|
level = "DIFFERENT";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
return SimilarFileDTO.builder()
|
2026-01-24 10:59:02 +07:00
|
|
|
.fileId(c.getId())
|
2026-01-25 20:45:29 +07:00
|
|
|
.ownerId(c.getUserId())
|
2026-01-13 22:40:52 +07:00
|
|
|
.originalFileName(c.getOriginalFileName())
|
|
|
|
|
.fileSize(c.getFileSize())
|
|
|
|
|
.hammingDistance(hamming)
|
|
|
|
|
.similarityLevel(level)
|
|
|
|
|
.build();
|
|
|
|
|
})
|
|
|
|
|
.sorted((a, b) ->
|
|
|
|
|
Integer.compare(a.getHammingDistance(), b.getHammingDistance()))
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
public List<SimilarFileDTO> findDuplicateByHammingDistance(String fileId, int hammingDistance,
|
|
|
|
|
int duplicate, int similar) {
|
|
|
|
|
List<SimilarImageProjection> candidates = repository.findCandidates(fileId);
|
|
|
|
|
|
2026-01-25 22:05:19 +07:00
|
|
|
Optional<ImageHashEntity> hashOptional = hashRepository.findById(fileId);
|
|
|
|
|
|
|
|
|
|
if (hashOptional.isEmpty()) {
|
|
|
|
|
return Collections.emptyList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var imageHashEntity = hashOptional.get();
|
2026-01-22 19:55:09 +07:00
|
|
|
|
2026-01-23 13:22:02 +07:00
|
|
|
Long hash64Hi = imageHashEntity.getHash64Hi();
|
|
|
|
|
Long hash64Lo = imageHashEntity.getHash64Lo();
|
2026-01-22 19:55:09 +07:00
|
|
|
|
|
|
|
|
return candidates.stream()
|
|
|
|
|
.map(c -> {
|
2026-01-23 13:22:02 +07:00
|
|
|
Long cHi = c.getHash64Hi();
|
|
|
|
|
Long cLo = c.getHash64Lo();
|
2026-01-22 19:55:09 +07:00
|
|
|
|
|
|
|
|
int hamming = fileUtil.hamming64(hash64Hi, hash64Lo, cHi, cLo);
|
|
|
|
|
|
|
|
|
|
return new AbstractMap.SimpleEntry<>(c, hamming);
|
|
|
|
|
})
|
|
|
|
|
.filter(entry -> entry.getValue() <= hammingDistance)
|
|
|
|
|
.sorted((a, b) -> Integer.compare(a.getValue(), b.getValue()))
|
|
|
|
|
.map(entry -> {
|
|
|
|
|
SimilarImageProjection similarImageProjection = entry.getKey();
|
|
|
|
|
int hamming = entry.getValue();
|
|
|
|
|
|
|
|
|
|
String level;
|
|
|
|
|
if (hamming <= duplicate) {
|
|
|
|
|
level = "DUPLICATE";
|
|
|
|
|
} else if (hamming <= similar) {
|
|
|
|
|
level = "SIMILAR";
|
|
|
|
|
} else {
|
|
|
|
|
level = "DIFFERENT";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return SimilarFileDTO.builder()
|
2026-01-25 18:59:55 +07:00
|
|
|
.fileId(similarImageProjection.getId())
|
2026-01-22 19:55:09 +07:00
|
|
|
.originalFileName(similarImageProjection.getOriginalFileName())
|
|
|
|
|
.fileSize(similarImageProjection.getFileSize())
|
|
|
|
|
.hammingDistance(hamming)
|
|
|
|
|
.similarityLevel(level)
|
|
|
|
|
.ownerId(similarImageProjection.getUserId())
|
|
|
|
|
.build();
|
|
|
|
|
})
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-23 13:22:02 +07:00
|
|
|
public List<SimilarImageProjection> findDuplicatedByHash(Long hash64Hi, Long hash64Lo) {
|
2026-01-19 15:49:27 +07:00
|
|
|
List<SimilarImageProjection> duplicates = repository.findExactDuplicates(hash64Hi,hash64Lo);
|
|
|
|
|
|
|
|
|
|
if (duplicates.isEmpty()) {
|
|
|
|
|
return new ArrayList<>();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return duplicates;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 20:17:57 +07:00
|
|
|
public void hasDuplicatesByHash(String path, Long userId, String mimeType) throws Exception {
|
2026-02-16 20:11:06 +07:00
|
|
|
String hash = calculateFileHash(path);
|
|
|
|
|
List<FileEntity> fileEntityList = fileEntityRepository.findByUserIdAndMimeType(userId, mimeType);
|
|
|
|
|
|
|
|
|
|
for (FileEntity file : fileEntityList) {
|
|
|
|
|
if (calculateFileHash(file.getFilePath()).equals(hash)) {
|
2026-02-16 20:16:50 +07:00
|
|
|
throw new DuplicateImageException("Duplicate", file.getId(),
|
|
|
|
|
file.getUserId());
|
2026-02-16 20:11:06 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 14:30:45 +07:00
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
public Page<SimilarFileDTO> findSimilarFiles(String fileId, SimilarityFilter filter, Pageable pageable,
|
|
|
|
|
String authToken) throws Exception {
|
|
|
|
|
FileEntity fileEntity = fileEntityRepository.findByFileId(fileId);
|
|
|
|
|
String mimeType = fileEntity.getMimeType();
|
|
|
|
|
List<SimilarFileDTO> allResults = new ArrayList<>();
|
2026-01-16 14:30:45 +07:00
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
if (mimeType.equals("image")) {
|
|
|
|
|
var imageHashEntity = hashRepository.findById(fileId)
|
|
|
|
|
.orElseThrow(() -> new RuntimeException("Hash not found"));
|
2026-01-16 14:30:45 +07:00
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
Long hash64Hi = imageHashEntity.getHash64Hi();
|
|
|
|
|
Long hash64Lo = imageHashEntity.getHash64Lo();
|
|
|
|
|
List<SimilarImageProjection> candidates = authToken.equals("all") ? repository.findCandidates(fileId):
|
|
|
|
|
repository.findCandidatesFromUserFiles(fileId, authTokenRepository.findUserIdByToken(authToken));
|
2026-01-16 14:30:45 +07:00
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
List<String> similarityLevels = (filter != null && filter.getSimilarityLevels() != null)
|
|
|
|
|
? filter.getSimilarityLevels()
|
|
|
|
|
: List.of("DUPLICATE", "SIMILAR", "DIFFERENT");
|
|
|
|
|
|
|
|
|
|
allResults = candidates.stream()
|
|
|
|
|
.map(c -> createSimilarFileResponse(c, hash64Hi, hash64Lo))
|
|
|
|
|
.filter(response -> similarityLevels.contains(response.getSimilarityLevel()))
|
|
|
|
|
.sorted(Comparator.comparingInt(SimilarFileDTO::getHammingDistance))
|
|
|
|
|
.collect(Collectors.toList());
|
|
|
|
|
} else {
|
2026-02-17 12:46:54 +07:00
|
|
|
// Long userId = fileEntity.getUserId();
|
|
|
|
|
// List<FileEntity> fileEntityList = fileEntityRepository.findByUserIdAndMimeType(userId, mimeType);
|
|
|
|
|
// NoCopyCheckResult noCopyCheckResult;
|
|
|
|
|
//
|
|
|
|
|
// for (FileEntity file : fileEntityList) {
|
|
|
|
|
// Path path = Paths.get(file.getFilePath());
|
|
|
|
|
// if (mimeType.equals("document")) {
|
|
|
|
|
// noCopyCheckResult = (DocumentCheckResult) noCopyFileService.checkFile(
|
|
|
|
|
// path.toFile(), FileProtector.Type.valueOf(file.getMimeType().toUpperCase()));
|
|
|
|
|
// } else {
|
|
|
|
|
// noCopyCheckResult = (AudioCheckResult) noCopyFileService.checkFile(
|
|
|
|
|
// path.toFile(), FileProtector.Type.valueOf(file.getMimeType().toUpperCase()));
|
|
|
|
|
// }
|
|
|
|
|
//
|
|
|
|
|
// switch (noCopyCheckResult) {
|
|
|
|
|
// case DocumentCheckResult.Success success -> {
|
|
|
|
|
// FileProtector.FileInfo info = success.getInfo();
|
|
|
|
|
// allResults.add(buildDTO(file));
|
|
|
|
|
// }
|
|
|
|
|
// case DocumentCheckResult.Failed failed -> {
|
|
|
|
|
// String message = failed.getMessage();
|
|
|
|
|
// }
|
|
|
|
|
// default -> throw new IllegalStateException("Unexpected result");
|
|
|
|
|
// }
|
|
|
|
|
// }
|
|
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
Long userId = fileEntity.getUserId();
|
|
|
|
|
List<FileEntity> fileEntityList = fileEntityRepository.findByUserIdAndMimeType(userId, mimeType);
|
2026-02-17 12:46:54 +07:00
|
|
|
List<String> errors = new ArrayList<>();
|
2026-02-16 16:41:29 +07:00
|
|
|
|
|
|
|
|
for (FileEntity file : fileEntityList) {
|
2026-02-17 12:46:54 +07:00
|
|
|
try {
|
|
|
|
|
Path path = Paths.get(file.getFilePath());
|
2026-02-17 12:55:25 +07:00
|
|
|
|
|
|
|
|
FileProtector.Type fileType = file.getMimeType().equals("document") ? FileProtector.Type.DOC:
|
|
|
|
|
FileProtector.Type.AUDIO;
|
2026-02-17 12:46:54 +07:00
|
|
|
|
2026-02-17 13:03:47 +07:00
|
|
|
NoCopyCheckResult noCopyCheckResult = fileUtil.checkResult(path, fileType);
|
2026-02-17 12:46:54 +07:00
|
|
|
|
|
|
|
|
switch (noCopyCheckResult) {
|
|
|
|
|
case DocumentCheckResult.Success success -> {
|
|
|
|
|
FileProtector.FileInfo info = success.getInfo();
|
|
|
|
|
allResults.add(buildDTO(file));
|
|
|
|
|
log.debug("File processed successfully: {}", file.getFilePath());
|
|
|
|
|
}
|
|
|
|
|
case AudioCheckResult.Success success -> {
|
|
|
|
|
FileProtector.FileInfo info = success.getInfo();
|
|
|
|
|
allResults.add(buildDTO(file));
|
|
|
|
|
log.debug("File processed successfully: {}", file.getFilePath());
|
|
|
|
|
}
|
|
|
|
|
case DocumentCheckResult.Failed failed -> {
|
|
|
|
|
String errorMessage = String.format("Document check failed for %s: %s",
|
|
|
|
|
file.getFilePath(), failed.getMessage());
|
|
|
|
|
log.warn(errorMessage);
|
|
|
|
|
errors.add(errorMessage);
|
|
|
|
|
}
|
|
|
|
|
case AudioCheckResult.Failed failed -> {
|
|
|
|
|
String errorMessage = String.format("Audio check failed for %s: %s",
|
|
|
|
|
file.getFilePath(), failed.getMessage());
|
|
|
|
|
log.warn(errorMessage);
|
|
|
|
|
errors.add(errorMessage);
|
|
|
|
|
}
|
|
|
|
|
default -> throw new IllegalStateException("Unexpected result type: " + noCopyCheckResult.getClass().getSimpleName());
|
|
|
|
|
}
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
String errorMessage = String.format("Error processing file %s: %s",
|
|
|
|
|
file.getFilePath(), e.getMessage());
|
|
|
|
|
log.error(errorMessage, e);
|
|
|
|
|
errors.add(errorMessage);
|
2026-02-16 16:41:29 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-16 14:30:45 +07:00
|
|
|
|
|
|
|
|
int total = allResults.size();
|
|
|
|
|
int page = (pageable != null) ? pageable.getPageNumber() : 0;
|
|
|
|
|
int size = (pageable != null) ? pageable.getPageSize() : 20;
|
|
|
|
|
|
|
|
|
|
int fromIndex = Math.min(page * size, total);
|
|
|
|
|
int toIndex = Math.min(fromIndex + size, total);
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
List<SimilarFileDTO> pageContent = allResults.subList(fromIndex, toIndex);
|
2026-01-16 14:30:45 +07:00
|
|
|
|
|
|
|
|
return new PageImpl<>(pageContent, pageable, total);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 16:41:29 +07:00
|
|
|
|
2026-02-16 20:11:06 +07:00
|
|
|
private String calculateFileHash(String path) throws Exception {
|
2026-02-16 16:41:29 +07:00
|
|
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
|
|
|
|
2026-02-16 20:11:06 +07:00
|
|
|
try (InputStream is = new FileInputStream(path)) {
|
|
|
|
|
byte[] buffer = new byte[8192];
|
|
|
|
|
int read;
|
|
|
|
|
while ((read = is.read(buffer)) > 0) {
|
|
|
|
|
digest.update(buffer, 0, read);
|
|
|
|
|
}
|
2026-02-16 16:41:29 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
byte[] hashBytes = digest.digest();
|
|
|
|
|
return Base64.getEncoder().encodeToString(hashBytes);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
private SimilarFileDTO createSimilarFileResponse(SimilarImageProjection similarImageProjection,
|
2026-01-23 13:22:02 +07:00
|
|
|
Long hash64Hi, Long hash64Lo) {
|
|
|
|
|
Long imageProjectionHash64Hi = similarImageProjection.getHash64Hi();
|
|
|
|
|
Long similarImageProjectionHash64Lo = similarImageProjection.getHash64Lo();
|
2026-01-27 20:27:39 +07:00
|
|
|
int hamming = PerceptualHashHelper.INSTANCE.hammingDistance(new PHash(hash64Hi, hash64Lo),
|
|
|
|
|
new PHash(imageProjectionHash64Hi, similarImageProjectionHash64Lo));
|
2026-01-16 14:30:45 +07:00
|
|
|
|
|
|
|
|
String level;
|
2026-01-27 20:27:39 +07:00
|
|
|
if (hamming <= 6) {
|
2026-01-16 14:30:45 +07:00
|
|
|
level = "DUPLICATE";
|
|
|
|
|
} else if (hamming <= 12) {
|
|
|
|
|
level = "SIMILAR";
|
|
|
|
|
} else {
|
|
|
|
|
level = "DIFFERENT";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-22 19:55:09 +07:00
|
|
|
return SimilarFileDTO.builder()
|
2026-01-24 10:59:02 +07:00
|
|
|
.fileId(similarImageProjection.getId())
|
2026-01-25 20:45:29 +07:00
|
|
|
.ownerId(similarImageProjection.getUserId())
|
2026-02-17 11:11:51 +07:00
|
|
|
.originalFileName(similarImageProjection.getOriginalFileName().replace("." +
|
|
|
|
|
similarImageProjection.getExtension(), "") + "_nocopy_protected" +
|
|
|
|
|
"." + similarImageProjection.getExtension())
|
2026-01-16 14:30:45 +07:00
|
|
|
.fileSize(similarImageProjection.getFileSize())
|
|
|
|
|
.hammingDistance(hamming)
|
|
|
|
|
.similarityLevel(level)
|
2026-01-27 20:27:39 +07:00
|
|
|
.supportId(similarImageProjection.getSupportId())
|
|
|
|
|
.uploadDate(similarImageProjection.getUploadDate())
|
|
|
|
|
.status(similarImageProjection.getProtectionStatus())
|
2026-02-05 10:47:40 +07:00
|
|
|
.url(baseUrl + "/api/files/protected/" + similarImageProjection.getId())
|
2026-01-16 14:30:45 +07:00
|
|
|
.build();
|
|
|
|
|
}
|
2026-02-16 16:41:29 +07:00
|
|
|
|
|
|
|
|
private SimilarFileDTO buildDTO(FileEntity fileEntity) {
|
|
|
|
|
return SimilarFileDTO.builder()
|
|
|
|
|
.fileId(fileEntity.getId())
|
|
|
|
|
.ownerId(fileEntity.getUserId())
|
2026-02-17 11:11:51 +07:00
|
|
|
.originalFileName(fileEntity.getOriginalFileName().replace("." +
|
|
|
|
|
fileEntity.getFileExtension(), "") + "_nocopy_protected" +
|
|
|
|
|
"." + fileEntity.getFileExtension())
|
2026-02-16 16:41:29 +07:00
|
|
|
.fileSize(fileEntity.getFileSize())
|
|
|
|
|
.uploadDate(fileEntity.getCreatedAt())
|
|
|
|
|
.status(fileEntity.getProtectionStatus())
|
|
|
|
|
.build();
|
|
|
|
|
}
|
2026-01-13 22:40:52 +07:00
|
|
|
}
|