diff --git a/build.gradle b/build.gradle index 09c1b01..8b9437b 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,9 @@ configurations { repositories { mavenCentral() + flatDir { + dirs 'libs' + } } dependencies { @@ -32,6 +35,9 @@ dependencies { implementation 'commons-validator:commons-validator:1.7' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '4.0.1' + implementation group: 'com.google.cloud', name: 'google-cloud-vision', version: '3.55.0' + implementation group: 'com.google.api-client', name: 'google-api-client', version: '2.7.2' + implementation 'org.flywaydb:flyway-core:9.22.0' implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.17.0' @@ -52,6 +58,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.mockito:mockito-core:5.3.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + implementation name: 'testlib-fat-0.2.1-all' + } tasks.named('test') { diff --git a/docker-compose.yaml b/docker-compose.yaml index a7824c4..b16a68a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -77,11 +77,12 @@ services: resources: limits: cpus: '1.5' - memory: 1G + memory: 3G reservations: cpus: '0.5' - memory: 512M + memory: 2G environment: + JAVA_TOOL_OPTIONS: "-Xmx2g -Xms1g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:+UseContainerSupport -XX:InitialRAMPercentage=50 -XX:MaxRAMPercentage=75" FILE_STORAGE_PATH: /data/uploads MAX_FILE_SIZE: 10737418240 # FILE_CHUNK_SIZE: 1048576 diff --git a/infrastructure/jenkins/deploy.groovy b/infrastructure/jenkins/deploy.groovy index dc09c2e..5bc63da 100644 --- a/infrastructure/jenkins/deploy.groovy +++ b/infrastructure/jenkins/deploy.groovy @@ -17,6 +17,7 @@ pipeline { stages { stage('Git pull') { steps { + cleanWs() script { checkout([ $class: 'GitSCM', @@ -30,7 +31,6 @@ pipeline { } } - stage('Deploy with docker-compose') { steps { script { @@ -44,68 +44,76 @@ pipeline { string(credentialsId: 'DB_PASSWORD', variable: 'DB_PASSWORD') ]) { sh """ - echo "Deploying branch: ${params.BRANCH}" - - echo "Copying files to server..." - sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}" - sshpass -p '$SSH_PASS' scp -r -o StrictHostKeyChecking=no ./* $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/ - - sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER " - cd /opt/deployments/${params.BRANCH} - - echo '1. Остановка старого приложения...' - docker stop app-backend 2>/dev/null || echo 'Контейнер не найден' - docker rm app-backend 2>/dev/null || echo 'Контейнер не найден' - - echo '2. Удаление старых образов...' - docker images --filter 'reference=*app*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'Нет образов для удаления' - - echo '3. Создание сети если нужно...' - docker network create app-network 2>/dev/null || echo 'Сеть уже существует' - - echo '4. Запуск инфраструктуры...' - docker-compose up -d db storage - - echo '5. Ожидание PostgreSQL...' - sleep 10 - - echo '6. Сборка нового образа приложения...' - docker build --no-cache -t app-backend:latest . - - echo '7. Запуск приложения...' - docker run -d \\ - --name app-backend \\ - --network app-network \\ - --network-alias app \\ - -p 80:8080 \\ - -v uploads_data:/data/uploads:rw \\ - -e POSTGRES_DB=no_copy_ \\ - -e POSTGRES_USER=$DB_USER \\ - -e POSTGRES_PASSWORD=$DB_PASSWORD \\ - -e POSTGRES_PORT=5432 \\ - -e POSTGRES_HOST=db \\ - --restart unless-stopped \\ - app-backend:latest - - echo '8. Запуск мониторинга...' - docker-compose up -d grafana prometheus loki tempo alloy - - echo '9. Проверка...' - sleep 5 - - echo 'Статус контейнеров:' - docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' - - echo '10. Проверка health...' - if curl -s -f http://localhost:80/health > /dev/null 2>&1; then - echo 'Приложение работает' - echo 'URL: http://${params.SERVER}:80' - else - echo 'Проверка health не удалась' - docker logs app-backend --tail=20 - fi - " - """ + echo "Deploying branch: ${params.BRANCH}" + + echo "Copying files to server..." + sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER "mkdir -p /opt/deployments/${params.BRANCH}" + + sshpass -p '$SSH_PASS' rsync -av --delete \\ + --exclude=.git \\ + --exclude=.gradle \\ + --exclude=build \\ + --exclude=**/build \\ + --exclude=.idea \\ + --exclude=out \\ + . $SSH_USER@$SERVER:/opt/deployments/${params.BRANCH}/ + + sshpass -p '$SSH_PASS' ssh -o StrictHostKeyChecking=no $SSH_USER@$SERVER " + cd /opt/deployments/${params.BRANCH} + + echo '1. Остановка старого приложения...' + docker stop app-backend 2>/dev/null || echo 'Контейнер не найден' + docker rm app-backend 2>/dev/null || echo 'Контейнер не найден' + + echo '2. Удаление старых образов...' + docker images --filter 'reference=*app*' -q | xargs -r docker rmi -f 2>/dev/null || echo 'Нет образов для удаления' + + echo '3. Создание сети если нужно...' + docker network create app-network 2>/dev/null || echo 'Сеть уже существует' + + echo '4. Запуск инфраструктуры...' + docker-compose up -d db storage + + echo '5. Ожидание PostgreSQL...' + sleep 10 + + echo '6. Сборка нового образа приложения...' + docker build --no-cache -t app-backend:latest . + + echo '7. Запуск приложения...' + docker run -d \\ + --name app-backend \\ + --network app-network \\ + --network-alias app \\ + -p 80:8080 \\ + -v uploads_data:/data/uploads:rw \\ + -e POSTGRES_DB=no_copy_ \\ + -e POSTGRES_USER=$DB_USER \\ + -e POSTGRES_PASSWORD=$DB_PASSWORD \\ + -e POSTGRES_PORT=5432 \\ + -e POSTGRES_HOST=db \\ + --restart unless-stopped \\ + app-backend:latest + + echo '8. Запуск мониторинга...' + docker-compose up -d grafana prometheus loki tempo alloy + + echo '9. Проверка...' + sleep 5 + + echo 'Статус контейнеров:' + docker ps --format 'table {{.Names}}\\t{{.Image}}\\t{{.Status}}' + + echo '10. Проверка health...' + if curl -s -f http://localhost:80/health > /dev/null 2>&1; then + echo 'Приложение работает' + echo 'URL: http://${params.SERVER}:80' + else + echo 'Проверка health не удалась' + docker logs app-backend --tail=20 + fi + " + """ } } } @@ -124,4 +132,4 @@ pipeline { echo "Deployment process finished" } } -} \ No newline at end of file +} diff --git a/libs/testlib-fat-0.2.1-all.jar b/libs/testlib-fat-0.2.1-all.jar new file mode 100644 index 0000000..c911c08 Binary files /dev/null and b/libs/testlib-fat-0.2.1-all.jar differ diff --git a/referral/build.gradle.kts b/referral/build.gradle.kts index 7a4f37d..c0cc540 100644 --- a/referral/build.gradle.kts +++ b/referral/build.gradle.kts @@ -16,8 +16,6 @@ dependencies { implementation("io.insert-koin:koin-core:4.1.1") implementation("io.insert-koin:koin-core-jvm:4.1.1") - - testImplementation(kotlin("test")) } tasks.test { diff --git a/src/main/java/ru/soune/nocopy/configuration/ApplicationConfig.java b/src/main/java/ru/soune/nocopy/configuration/ApplicationConfig.java index 85ae0b8..cce0f0d 100644 --- a/src/main/java/ru/soune/nocopy/configuration/ApplicationConfig.java +++ b/src/main/java/ru/soune/nocopy/configuration/ApplicationConfig.java @@ -1,17 +1,49 @@ package ru.soune.nocopy.configuration; +import com.vrt.AudioFilePathProvider; +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.audio.AudioLocalSearch; +import com.vrt.fileprotection.image.ImageLocalSearch; +import com.vrt.fileprotection.image.ImageUniqueCheck; +import com.vrt.fileprotection.image.phash.PerceptualHashHelper; +import lombok.AllArgsConstructor; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.Collections; + @Configuration @EnableAutoConfiguration +@AllArgsConstructor public class ApplicationConfig { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public com.vrt. + NoCopyFileService noCopyFileService( + FileProtector.FileProvider fileProvider, + FileProtector.ProcessingListener processingListener, + ImageUniqueCheck imageUniqueCheck, + ImageLocalSearch imageLocalSearch, + AudioLocalSearch audioLocalSearch, + AudioFilePathProvider audioFilePathProvider) { + + return new com.vrt.NoCopyFileService( + Collections.emptyList(), + fileProvider, + processingListener, + imageUniqueCheck, + imageLocalSearch, + audioLocalSearch, + audioFilePathProvider + ); + } } + diff --git a/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java b/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java new file mode 100644 index 0000000..ab07422 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/configuration/RestTemplateConfig.java @@ -0,0 +1,22 @@ +package ru.soune.nocopy.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(30000); + requestFactory.setReadTimeout(60000); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } +} diff --git a/src/main/java/ru/soune/nocopy/configuration/file/NoCopyInitializer.java b/src/main/java/ru/soune/nocopy/configuration/file/NoCopyInitializer.java new file mode 100644 index 0000000..39eef84 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/configuration/file/NoCopyInitializer.java @@ -0,0 +1,19 @@ +package ru.soune.nocopy.configuration.file; + +import lombok.AllArgsConstructor; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import ru.soune.nocopy.service.file.FileProcessingOrchestrator; + +@Component +@AllArgsConstructor +public class NoCopyInitializer { + + private final FileProcessingOrchestrator orchestrator; + + @EventListener(ApplicationReadyEvent.class) + public void initializeOnStartup() { + orchestrator.initializeProcessingQueue(); + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/controller/ApiController.java b/src/main/java/ru/soune/nocopy/controller/ApiController.java index 854b77a..d1799fc 100644 --- a/src/main/java/ru/soune/nocopy/controller/ApiController.java +++ b/src/main/java/ru/soune/nocopy/controller/ApiController.java @@ -1,5 +1,8 @@ package ru.soune.nocopy.controller; +import com.vrt.NoCopyFileService; +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.NoCopyCheckResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.Resource; @@ -21,6 +24,7 @@ import ru.soune.nocopy.dto.file.*; import ru.soune.nocopy.dto.register.RegAnswer; import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileStatus; +import ru.soune.nocopy.entity.file.ProtectionStatus; import ru.soune.nocopy.entity.file.UploadStatus; import ru.soune.nocopy.exception.*; import ru.soune.nocopy.handler.*; @@ -29,7 +33,9 @@ import ru.soune.nocopy.service.FileSimilarityService; import ru.soune.nocopy.service.register.AuthService; import ru.soune.nocopy.service.file.FileEntityService; import ru.soune.nocopy.service.file.FileUploadService; +import ru.soune.nocopy.util.FileUtil; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -57,6 +63,10 @@ public class ApiController { private final FileEntityRepository fileEntityRepository; + private final NoCopyFileService noCopyFileService; + + private final FileUtil fileUtil; + @PostMapping("/v{version}/data") public ResponseEntity handlePostRequest(@RequestBody BaseRequest request, @PathVariable("version") int version) { @@ -103,7 +113,8 @@ public class ApiController { @PathVariable("version") int version, @RequestParam(value = "upload_id", required = false) String uploadId, @RequestParam(value = "chunk_number", required = false) Integer chunkNumber, - @RequestParam(value = "chunk", required = false) MultipartFile chunk) { + @RequestParam(value = "chunk", required = false) MultipartFile chunk, + @RequestParam(value = "findSimilar", required = false) Integer findSimilar) { try { if (chunk == null || chunk.isEmpty()) { return buildErrorResponse(uploadId, chunkNumber, "Chunk file null or empty"); @@ -115,39 +126,54 @@ public class ApiController { return buildErrorResponse(uploadId, chunkNumber, "Valid chunk number is required"); } - fileUploadService.uploadChunk(uploadId, chunkNumber, chunk); + UploadProgressResponse uploadProgressResponse = fileUploadService.uploadChunk(uploadId, chunkNumber, + chunk, findSimilar); - return buildSuccessResponse(uploadId, chunkNumber, chunk); + return buildSuccessResponse(uploadId, chunkNumber, chunk, + fileEntityService.findFileIdByPath(uploadProgressResponse.getFilePath())); } catch (DuplicateImageException e) { - return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.DUPLICATE_FILE_UPLOAD.getCode(), + Map duplicateData = new HashMap<>(); + duplicateData.put("duplicateFileId", e.duplicateFileId()); + duplicateData.put("userId", e.userId()); + duplicateData.put("message", e.getMessage()); + + if (uploadId != null) { + duplicateData.put("uploadId", uploadId); + } + + return ResponseEntity.ok().body(new BaseResponse( + 20004, + MessageCode.DUPLICATE_FILE_UPLOAD.getCode(), MessageCode.DUPLICATE_FILE_UPLOAD.getDescription(), - Map.of("duplicateOwnerId", e.userId(), "duplicateFileId", e.duplicateFileId()))); + duplicateData + )); } catch (Exception e) { log.error("Error uploading chunk", e); return buildErrorResponse(uploadId, chunkNumber, "Failed to upload chunk: " + e.getMessage()); } } - @GetMapping("/v{version}/files/{fileId}/similar") + @GetMapping("/v{version}/files/{fileId}/similar/{authToken}") public ResponseEntity findSimilarFiles( @PathVariable("version") int version, @PathVariable String fileId, + @PathVariable(required = false) String authToken, @RequestParam(required = false) List similarityLevels, @PageableDefault(size = 20, sort = "hammingDistance") Pageable pageable) { SimilarityFilter filter = SimilarityFilter.builder() .similarityLevels(similarityLevels) .build(); - Page similarFiles = fileSimilarityService.findSimilarFiles(fileId, filter, pageable); + Page similarFiles = fileSimilarityService.findSimilarFiles(fileId, filter, pageable, authToken); String messageDesc; MessageCode success; if (similarFiles.isEmpty()) { messageDesc = MessageCode.FILE_NOT_FOUND.getDescription(); - success = MessageCode.FILE_NOT_FOUND; + success = MessageCode.SUCCESS; } else { messageDesc = MessageCode.SIMILAR_FILES_FOUND.getDescription(); - success = MessageCode.SIMILAR_FILES_FOUND; + success = MessageCode.SUCCESS; } Map responseData = new HashMap<>(); @@ -193,7 +219,8 @@ public class ApiController { .uploadId(uploadId) .build(); - return ResponseEntity.ok().body(new BaseResponse(20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), + return ResponseEntity.ok().body(new BaseResponse( + 20004, MessageCode.FILE_UPLOAD_ERROR.getCode(), "Failed to get upload progress: " + e.getMessage(), responseBody)); } } @@ -352,6 +379,45 @@ public class ApiController { } } + @GetMapping("/protect/{fileId}") + public ResponseEntity protect( @PathVariable(required = false) String fileId) { + Optional optionalFileEntity = fileEntityRepository.findById(fileId); + + if (!optionalFileEntity.isPresent()) { + return ResponseEntity.notFound().build(); + } + FileEntity fileEntity = optionalFileEntity.get(); + FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity); + + noCopyFileService.addFile(fileInfo); + + fileEntity.setProtectionStatus(ProtectionStatus.PROCESSING); + fileEntityRepository.save(fileEntity); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/check/{fileId}/{type}") + public ResponseEntity check(@PathVariable(required = false) String fileId, + @PathVariable(required = false) String type) { + Optional optionalFileEntity = fileEntityRepository.findById(fileId); + if (!optionalFileEntity.isPresent()) { + return ResponseEntity.notFound().build(); + } + + FileEntity fileEntity = optionalFileEntity.get(); + + Path path = type.toLowerCase().equals("image") ? Paths.get(fileEntity.getFilePath()) : + Paths.get(fileEntity.getProtectedFilePath()); + + File file = path.toFile(); + + NoCopyCheckResult noCopyCheckResult = noCopyFileService.checkFile(file, + FileProtector.Type.valueOf(type.toUpperCase())); + + return ResponseEntity.ok().body(noCopyCheckResult); + } + private ResponseEntity checkForDuplicates(String uploadId) throws IOException { Optional uploadedFile = fileEntityRepository.findByUploadSessionId(uploadId); @@ -360,7 +426,7 @@ public class ApiController { } FileEntity fileEntity = uploadedFile.get(); - List similarFiles = fileSimilarityService.findSimilarFiles(fileEntity.getId()); + List similarFiles = fileSimilarityService.findSimilarFiles(fileEntity.getId()); if (hasDuplicate(similarFiles)) { return handleDuplicate(fileEntity, similarFiles); @@ -369,11 +435,11 @@ public class ApiController { return null; } - private boolean hasDuplicate(List similarFiles) { + private boolean hasDuplicate(List similarFiles) { return similarFiles.stream().anyMatch(f -> f.getHammingDistance() <= 5); } - private ResponseEntity handleDuplicate(FileEntity fileEntity, List similarFiles) + private ResponseEntity handleDuplicate(FileEntity fileEntity, List similarFiles) throws IOException { fileEntityService.deleteFromDisk(fileEntity); @@ -396,11 +462,13 @@ public class ApiController { return null; } - private ResponseEntity buildSuccessResponse(String uploadId, Integer chunkNumber, MultipartFile chunk) { + private ResponseEntity buildSuccessResponse(String uploadId, Integer chunkNumber, MultipartFile chunk, + String fileId) { ChunkUploadResponse responseBody = ChunkUploadResponse.builder() .uploadId(uploadId) .chunkNumber(chunkNumber) .chunkSize(chunk.getSize()) + .fileId(fileId) .message("Chunk uploaded successfully") .build(); @@ -459,16 +527,4 @@ public class ApiController { } return contentType; } - - private boolean isPreviewSupported(String mimeType) { - if (mimeType == null) { - return false; - } - - return mimeType.startsWith("image") || - mimeType.startsWith("text") || - mimeType.equals("pdf") || - mimeType.startsWith("video") || - mimeType.startsWith("audio"); - } } \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/controller/HealtCheckController.java b/src/main/java/ru/soune/nocopy/controller/HealtCheckController.java index 57de3e8..6138303 100644 --- a/src/main/java/ru/soune/nocopy/controller/HealtCheckController.java +++ b/src/main/java/ru/soune/nocopy/controller/HealtCheckController.java @@ -3,6 +3,9 @@ package ru.soune.nocopy.controller; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.Map; + @RestController @RequestMapping("check/api") public class HealtCheckController { @@ -11,4 +14,22 @@ public class HealtCheckController { public HttpStatus healtCheck() { return HttpStatus.OK; } + + @GetMapping("/api/debug/memory") + public Map getMemoryInfo() { + Runtime runtime = Runtime.getRuntime(); + long mb = 1024 * 1024; + + Map info = new HashMap<>(); + info.put("maxMemory (MB)", String.valueOf(runtime.maxMemory() / mb)); + info.put("totalMemory (MB)", String.valueOf(runtime.totalMemory() / mb)); + info.put("freeMemory (MB)", String.valueOf(runtime.freeMemory() / mb)); + info.put("usedMemory (MB)", String.valueOf((runtime.totalMemory() - runtime.freeMemory()) / mb)); + info.put("availableProcessors", String.valueOf(runtime.availableProcessors())); + + info.put("JAVA_TOOL_OPTIONS", System.getenv("JAVA_TOOL_OPTIONS")); + info.put("JAVA_OPTS", System.getenv("JAVA_OPTS")); + + return info; + } } diff --git a/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java index 09bce18..f2a8f38 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java +++ b/src/main/java/ru/soune/nocopy/dto/file/ChunkUploadResponse.java @@ -20,6 +20,9 @@ public class ChunkUploadResponse { @JsonProperty("chunk_size") private Long chunkSize; + @JsonProperty("file_id") + private String fileId; + @JsonProperty("message") private String message; } diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java index 4682dac..560fec3 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityRequest.java @@ -17,6 +17,9 @@ public class FileEntityRequest { @JsonProperty("file_id") private String fileId; + @JsonProperty("full_delete") + private Integer fullDelete; + @JsonProperty("upload_session_id") private String uploadSessionId; diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java index cbfae88..debabc6 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java +++ b/src/main/java/ru/soune/nocopy/dto/file/FileEntityResponse.java @@ -31,4 +31,5 @@ public class FileEntityResponse { private String downloadUrl; private boolean existsOnDisk; private Integer supportId; + private String protectStatus; } diff --git a/src/main/java/ru/soune/nocopy/dto/file/FileInfoUserResponse.java b/src/main/java/ru/soune/nocopy/dto/file/FileInfoUserResponse.java index 8b2f932..9304af2 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/FileInfoUserResponse.java +++ b/src/main/java/ru/soune/nocopy/dto/file/FileInfoUserResponse.java @@ -58,4 +58,16 @@ public class FileInfoUserResponse { @JsonProperty("audios_violations") private Integer audiosViolations; + + @JsonProperty("protected_files_count") + private Long protectedFilesCount; + + @JsonProperty("protected_audio_files_count") + private Long protectedAudioFilesCount; + + @JsonProperty("protected_video_files_count") + private Long protectedVideoFilesCount; + + @JsonProperty("protected_image_files_count") + private Long protectedImageFilesCount; } diff --git a/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java b/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java new file mode 100644 index 0000000..a2fe685 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/dto/file/GoogleVisionSearchResponse.java @@ -0,0 +1,68 @@ +package ru.soune.nocopy.dto.file; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleVisionSearchResponse { + + @JsonProperty("bestGuessLabels") + private List bestGuessLabels; + + @JsonProperty("fullMatchingImages") + private List fullMatchingImages; + + @JsonProperty("visuallySimilarImages") + private List visuallySimilarImages; + + @JsonProperty("pagesWithMatchingImages") + private List pagesWithMatchingImages; + + @JsonProperty("partialMatchingImages") + private List partialMatchingImages; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class BestGuessLabel { + @JsonProperty("label") + private String label; + + @JsonProperty("languageCode") + private String languageCode; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ImageResult { + @JsonProperty("url") + private String url; + + @JsonProperty("score") + private Float score; + + @JsonProperty("height") + private Integer height; + + @JsonProperty("width") + private Integer width; + } + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class PageResult { + @JsonProperty("url") + private String url; + + @JsonProperty("pageTitle") + private String pageTitle; + + @JsonProperty("fullMatchingImages") + private List fullMatchingImages; + + @JsonProperty("partialMatchingImages") + private List partialMatchingImages; + } +} diff --git a/src/main/java/ru/soune/nocopy/dto/file/SimilarFileResponse.java b/src/main/java/ru/soune/nocopy/dto/file/SimilarFileDTO.java similarity index 82% rename from src/main/java/ru/soune/nocopy/dto/file/SimilarFileResponse.java rename to src/main/java/ru/soune/nocopy/dto/file/SimilarFileDTO.java index f7aae6e..45fb75c 100644 --- a/src/main/java/ru/soune/nocopy/dto/file/SimilarFileResponse.java +++ b/src/main/java/ru/soune/nocopy/dto/file/SimilarFileDTO.java @@ -5,10 +5,11 @@ import lombok.Value; @Value @Builder -public class SimilarFileResponse { +public class SimilarFileDTO { String fileId; String originalFileName; Long fileSize; Integer hammingDistance; String similarityLevel; + Long ownerId; } diff --git a/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java b/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java index 16e25a4..dd193af 100644 --- a/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java +++ b/src/main/java/ru/soune/nocopy/entity/file/FileEntity.java @@ -66,6 +66,20 @@ public class FileEntity { @Column(name = "updated_at") private LocalDateTime updatedAt; + @Column(name = "protected_file_path") + private String protectedFilePath; + + @Column(name = "protection_status") + @Enumerated(EnumType.STRING) + private ProtectionStatus protectionStatus; + + @Column(name = "protected_at") + private LocalDateTime protectedAt; + + @Column(name = "signature") + @JsonIgnore + private String signature; + @OneToOne(mappedBy = "file", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore private ImageHashEntity imageHash; @@ -75,9 +89,19 @@ public class FileEntity { if (this.status == null) { this.status = FileStatus.ACTIVE; } + + if (this.protectionStatus == null) { + this.protectionStatus = ProtectionStatus.NOT_PROTECTED; + } + 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/FileType.java b/src/main/java/ru/soune/nocopy/entity/file/FileType.java index 8844013..29d61a2 100644 --- a/src/main/java/ru/soune/nocopy/entity/file/FileType.java +++ b/src/main/java/ru/soune/nocopy/entity/file/FileType.java @@ -7,10 +7,11 @@ import java.util.List; @Getter public enum FileType { - IMAGE("image", Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp", "jfif")), +// IMAGE("image", Arrays.asList("jpg", "jpeg", "png", "gif", "bmp", "webp", "jfif")), + IMAGE("image", Arrays.asList("jpg", "jpeg", "png", "gif", "bmp")), VIDEO("video", Arrays.asList("mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v", "mpg", "mpeg", "3gp", "3g2", "f4v", "m2ts", "mts", "vob", "ogv", "divx")), - AUDIO("audio", Arrays.asList("mp3", "wav", "flac")); + AUDIO("audio", List.of("wav")); private final String displayName; private final List allowedExtensions; diff --git a/src/main/java/ru/soune/nocopy/entity/file/ImageHashEntity.java b/src/main/java/ru/soune/nocopy/entity/file/ImageHashEntity.java index 37f1b5a..f6e47e3 100644 --- a/src/main/java/ru/soune/nocopy/entity/file/ImageHashEntity.java +++ b/src/main/java/ru/soune/nocopy/entity/file/ImageHashEntity.java @@ -9,7 +9,11 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; @Entity -@Table(name = "image_hashes") +@Table(name = "image_hashes", indexes = { + @Index(name = "idx_image_hashes_hash64", columnList = "hash64_hi, hash64_lo"), + @Index(name = "idx_image_hashes_hash64_hi", columnList = "hash64_hi"), + @Index(name = "idx_image_hashes_hash64_lo", columnList = "hash64_lo") +}) @Data @NoArgsConstructor @AllArgsConstructor @@ -26,10 +30,10 @@ public class ImageHashEntity { private FileEntity file; @Column(name = "hash64_hi") - private Integer hash64Hi; + private Long hash64Hi; @Column(name = "hash64_lo") - private Integer hash64Lo; + private Long hash64Lo; @Column(name = "hash_algorithm", nullable = false) private String hashAlgorithm; diff --git a/src/main/java/ru/soune/nocopy/entity/file/ImageProtection.java b/src/main/java/ru/soune/nocopy/entity/file/ImageProtection.java index 52d7c8c..e69de29 100644 --- a/src/main/java/ru/soune/nocopy/entity/file/ImageProtection.java +++ b/src/main/java/ru/soune/nocopy/entity/file/ImageProtection.java @@ -1,51 +0,0 @@ -package ru.soune.nocopy.entity.file; - -import jakarta.persistence.*; -import lombok.*; -import org.springframework.data.annotation.CreatedDate; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import ru.soune.nocopy.entity.user.User; -import ru.soune.nocopy.entity.user.UserContent; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "image_protection") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@ToString -@EntityListeners(AuditingEntityListener.class) -public class ImageProtection { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long protectionId; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - @ToString.Exclude - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "content_id", nullable = false) - @ToString.Exclude - private UserContent content; - - @Column(name = "protection_method", nullable = false, length = 50) - private String protectionMethod; - - @Column(name = "protection_level", nullable = false) - private Integer protectionLevel; - - @Column(name = "is_active", nullable = false) - private Boolean isActive = true; - - @CreatedDate - @Column(name = "applied_at", nullable = false, updatable = false) - private LocalDateTime appliedAt; - - @Column(name = "metadata", columnDefinition = "JSON") - private String metadata; -} diff --git a/src/main/java/ru/soune/nocopy/entity/file/ProtectionStatus.java b/src/main/java/ru/soune/nocopy/entity/file/ProtectionStatus.java new file mode 100644 index 0000000..18fc726 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/entity/file/ProtectionStatus.java @@ -0,0 +1,9 @@ +package ru.soune.nocopy.entity.file; + +public enum ProtectionStatus { + NOT_PROTECTED, + PROCESSING, + PROTECTED, + FAILED, + FAILED_SAVE +} diff --git a/src/main/java/ru/soune/nocopy/entity/user/User.java b/src/main/java/ru/soune/nocopy/entity/user/User.java index c61d15d..d6a1867 100644 --- a/src/main/java/ru/soune/nocopy/entity/user/User.java +++ b/src/main/java/ru/soune/nocopy/entity/user/User.java @@ -84,10 +84,4 @@ public class User { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) @JsonIgnore @ToString.Exclude - private List violations = new ArrayList<>(); - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) - @JsonIgnore - @ToString.Exclude - private List imageProtections = new ArrayList<>(); -} + private List violations = new ArrayList<>();} diff --git a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java index 807f21e..5603581 100644 --- a/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/FileEntityHandler.java @@ -274,7 +274,7 @@ public class FileEntityHandler implements RequestHandler { FileEntity fileEntity = fileEntityRepository.findById(fileId) .orElseThrow(() -> new FileEntityNotFoundException(fileId)); - if (fileEntity.getStatus().equals(FileStatus.DELETED)) { + if (fileEntity.getStatus().equals(FileStatus.DELETED) || fileRequest.getFullDelete() == 1) { fileEntityService.deleteFromDisk(fileEntity); response = DeleteFileResponse.builder() .fileId(fileRequest.getFileId()) diff --git a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java index 13e005e..79d6ac6 100644 --- a/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java +++ b/src/main/java/ru/soune/nocopy/handler/ImageFoundRequestHandler.java @@ -7,9 +7,12 @@ import org.springframework.stereotype.Component; import ru.soune.nocopy.dto.BaseRequest; import ru.soune.nocopy.dto.BaseResponse; import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse; import ru.soune.nocopy.dto.file.ImageSearchRequest; import ru.soune.nocopy.dto.file.YandexSearchResponse; import ru.soune.nocopy.service.search.YandexSearchService; +import ru.soune.nocopy.service.GoogleVisionSearchService; +import ru.soune.nocopy.service.YandexSearchService; @Slf4j @Component @@ -20,6 +23,8 @@ public class ImageFoundRequestHandler implements RequestHandler { private final YandexSearchService yandexSearchService; + private final GoogleVisionSearchService googleVisionSearchService; + @Override public BaseResponse handle(BaseRequest request) throws Exception { ImageSearchRequest imageSearchRequest = objectMapper.convertValue(request.getMessageBody(), @@ -29,6 +34,9 @@ public class ImageFoundRequestHandler implements RequestHandler { YandexSearchResponse response = yandexSearchService.searchByFileEntity(fileId); + //TODO uncommited when add billing +// GoogleVisionSearchResponse googleVisionSearchResponse = googleVisionSearchService.searchByFileEntity(fileId); + return new BaseResponse(request.getMsgId(), MessageCode.SUCCESS.getCode(), MessageCode.SUCCESS.getDescription(), response); } diff --git a/src/main/java/ru/soune/nocopy/handler/validator/RegRequestValidator.java b/src/main/java/ru/soune/nocopy/handler/validator/RegRequestValidator.java index 71fb43b..c2b2496 100644 --- a/src/main/java/ru/soune/nocopy/handler/validator/RegRequestValidator.java +++ b/src/main/java/ru/soune/nocopy/handler/validator/RegRequestValidator.java @@ -138,15 +138,6 @@ public class RegRequestValidator implements Validator { errors.rejectValue("password", "password.contains.spaces", "Password cannot contain spaces"); } - -// recomment if need complexity -// checkPasswordComplexity(password, errors); - -// reccoment if need check simply standart password -// checkCommonPasswords(password, errors); - -// reccoment if need check simply standart password -// checkForSequences(password, errors); } private void checkPasswordComplexity(String password, Errors errors) { diff --git a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java index 08b2ee5..c1b65e2 100644 --- a/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/AuthTokenRepository.java @@ -1,6 +1,7 @@ package ru.soune.nocopy.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import ru.soune.nocopy.entity.user.AuthToken; @@ -13,6 +14,11 @@ public interface AuthTokenRepository extends JpaRepository { List findByExpiresAtBefore(LocalDateTime expiresAtBefore); Optional findByLastUsedAtBefore(LocalDateTime lastUsedAt); Optional findByToken(String token); + @Query(value = """ + SELECT a.user_id FROM auth_tokens a WHERE a.token = :token + """, + nativeQuery = true) + Long findUserIdByToken(String token); Optional findByUserIdAndIsActive(Long userId, boolean isActive); Optional findByUserId(Long userId); } diff --git a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java index e35ef2f..7ceb303 100644 --- a/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/FileEntityRepository.java @@ -6,14 +6,15 @@ 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 ru.soune.nocopy.entity.file.ProtectionStatus; import java.util.List; import java.util.Optional; @Repository public interface FileEntityRepository extends JpaRepository { - List findByUserId(Long userId); + Optional findByUserIdAndChecksum(Long userId, String imageHash); List findByUserIdAndStatus(Long userId, FileStatus status); @@ -22,16 +23,31 @@ public interface FileEntityRepository extends JpaRepository Optional findByUploadSessionId(String uploadSessionId); + List findByProtectionStatus(ProtectionStatus protectionStatus); + 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.protectionStatus = null AND f.status = 'ACTIVE' OR " + + "f.protectionStatus = 'NOT_PROTECTED' AND f.status = 'ACTIVE'") + List findAllActiveFilesAndNotProtected(); + @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); @Query("SELECT f FROM FileEntity f WHERE f.status = :status") List searchFileEntityByStatus(@Param("status") FileStatus status); + @Query("SELECT f FROM FileEntity f WHERE f.id = :fileId") + FileEntity findByFileId(@Param("fileId") String fileId); + + @Query("SELECT f FROM FileEntity f WHERE f.signature = :signature") + FileEntity findBySignature(@Param("signature") String signature); + + @Query("SELECT f.id FROM FileEntity f WHERE f.filePath = :filePath") + String findFileIdByFilePath(@Param("filePath") String filePath); + long countByUserId(Long userId); } diff --git a/src/main/java/ru/soune/nocopy/repository/ImageHashRepository.java b/src/main/java/ru/soune/nocopy/repository/ImageHashRepository.java index ee0aac2..48b4378 100644 --- a/src/main/java/ru/soune/nocopy/repository/ImageHashRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/ImageHashRepository.java @@ -4,7 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import ru.soune.nocopy.entity.file.ImageHashEntity; +import java.util.List; + @Repository public interface ImageHashRepository extends JpaRepository { void deleteByFileId(String fileId); + List findByFileId(String fileId); + ImageHashEntity findByHash64HiAndHash64Lo(Long hash64Hi, Long hash64Lo); } diff --git a/src/main/java/ru/soune/nocopy/repository/ImageSimilarityRepository.java b/src/main/java/ru/soune/nocopy/repository/ImageSimilarityRepository.java index a6ef973..345cd43 100644 --- a/src/main/java/ru/soune/nocopy/repository/ImageSimilarityRepository.java +++ b/src/main/java/ru/soune/nocopy/repository/ImageSimilarityRepository.java @@ -11,13 +11,12 @@ import java.util.List; @Repository public interface ImageSimilarityRepository extends JpaRepository { - @Query(value = """ - - SELECT - f.id AS similarFileId, + SELECT + f.id AS id, f.original_file_name AS originalFileName, f.file_size AS fileSize, + f.user_id AS userId, h.hash64_hi AS hash64Hi, h.hash64_lo AS hash64Lo FROM image_hashes ref @@ -32,6 +31,28 @@ public interface ImageSimilarityRepository @Param("fileId") String fileId ); + @Query(value = """ + SELECT + f.id AS id, + f.original_file_name AS originalFileName, + f.file_size AS fileSize, + f.user_id AS userId, + h.hash64_hi AS hash64Hi, + h.hash64_lo AS hash64Lo + FROM image_hashes ref + JOIN image_hashes h + ON ref.file_id <> h.file_id + JOIN file_entities f + ON f.id = h.file_id + WHERE ref.file_id = :fileId AND f.user_id = :userId + """, + nativeQuery = true) + List findCandidatesFromUserFiles( + @Param("fileId") String fileId, + @Param("userId") Long userId + ); + + @Query(value = """ SELECT h.hash64_hi AS hash64Hi, @@ -48,7 +69,7 @@ public interface ImageSimilarityRepository """, nativeQuery = true) List findExactDuplicates( - @Param("hash64Hi") Integer hash64_hi, - @Param("hash64Lo") Integer hash64_lo); + @Param("hash64Hi") Long hash64_hi, + @Param("hash64Lo") Long hash64_lo); } diff --git a/src/main/java/ru/soune/nocopy/repository/SimilarImageProjection.java b/src/main/java/ru/soune/nocopy/repository/SimilarImageProjection.java index cac73fb..4bd444e 100644 --- a/src/main/java/ru/soune/nocopy/repository/SimilarImageProjection.java +++ b/src/main/java/ru/soune/nocopy/repository/SimilarImageProjection.java @@ -1,8 +1,8 @@ package ru.soune.nocopy.repository; public interface SimilarImageProjection { - Integer getHash64Hi(); - Integer getHash64Lo(); + Long getHash64Hi(); + Long getHash64Lo(); String getId(); Long getUserId(); String getOriginalFileName(); diff --git a/src/main/java/ru/soune/nocopy/service/FileSimilarityService.java b/src/main/java/ru/soune/nocopy/service/FileSimilarityService.java index 9ec4051..adae2bd 100644 --- a/src/main/java/ru/soune/nocopy/service/FileSimilarityService.java +++ b/src/main/java/ru/soune/nocopy/service/FileSimilarityService.java @@ -1,44 +1,51 @@ package ru.soune.nocopy.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; -import ru.soune.nocopy.dto.file.SimilarFileResponse; +import ru.soune.nocopy.dto.file.SimilarFileDTO; import ru.soune.nocopy.dto.file.SimilarityFilter; +import ru.soune.nocopy.entity.file.ImageHashEntity; +import ru.soune.nocopy.repository.AuthTokenRepository; import ru.soune.nocopy.repository.ImageHashRepository; import ru.soune.nocopy.repository.ImageSimilarityRepository; import ru.soune.nocopy.repository.SimilarImageProjection; +import ru.soune.nocopy.util.FileUtil; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; @Service @RequiredArgsConstructor +@Slf4j public class FileSimilarityService { + private final ImageSimilarityRepository repository; private final ImageHashRepository hashRepository; - public List findSimilarFiles(String fileId) { + private final FileUtil fileUtil; + + private final AuthTokenRepository authTokenRepository; + + public List findSimilarFiles(String fileId) { var imageHashEntity = hashRepository.findById(fileId) .orElseThrow(() -> new RuntimeException("Hash not found")); - Integer hash64Hi = imageHashEntity.getHash64Hi(); - Integer hash64Lo = imageHashEntity.getHash64Lo(); + Long hash64Hi = imageHashEntity.getHash64Hi(); + Long hash64Lo = imageHashEntity.getHash64Lo(); - List candidates = - repository.findCandidates(fileId); + List candidates = repository.findCandidates(fileId); return candidates.stream() .map(c -> { - Integer cHi = c.getHash64Hi(); - Integer cLo = c.getHash64Lo(); + Long cHi = c.getHash64Hi(); + Long cLo = c.getHash64Lo(); - int hamming = hamming64(hash64Hi, hash64Lo, cHi, cLo); + int hamming = fileUtil.hamming64(hash64Hi, hash64Lo, cHi, cLo); String level; if (hamming <= 5) { @@ -49,8 +56,9 @@ public class FileSimilarityService { level = "DIFFERENT"; } - return SimilarFileResponse.builder() + return SimilarFileDTO.builder() .fileId(c.getId()) + .ownerId(c.getUserId()) .originalFileName(c.getOriginalFileName()) .fileSize(c.getFileSize()) .hammingDistance(hamming) @@ -62,7 +70,58 @@ public class FileSimilarityService { .toList(); } - public List findDuplicatedByHash(Integer hash64Hi, Integer hash64Lo) { + public List findDuplicateByHammingDistance(String fileId, int hammingDistance, + int duplicate, int similar) { + List candidates = repository.findCandidates(fileId); + + Optional hashOptional = hashRepository.findById(fileId); + + if (hashOptional.isEmpty()) { + return Collections.emptyList(); + } + + var imageHashEntity = hashOptional.get(); + + Long hash64Hi = imageHashEntity.getHash64Hi(); + Long hash64Lo = imageHashEntity.getHash64Lo(); + + return candidates.stream() + .map(c -> { + Long cHi = c.getHash64Hi(); + Long cLo = c.getHash64Lo(); + + 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() + .fileId(similarImageProjection.getId()) + .originalFileName(similarImageProjection.getOriginalFileName()) + .fileSize(similarImageProjection.getFileSize()) + .hammingDistance(hamming) + .similarityLevel(level) + .ownerId(similarImageProjection.getUserId()) + .build(); + }) + .toList(); + } + + public List findDuplicatedByHash(Long hash64Hi, Long hash64Lo) { List duplicates = repository.findExactDuplicates(hash64Hi,hash64Lo); if (duplicates.isEmpty()) { @@ -72,23 +131,24 @@ public class FileSimilarityService { return duplicates; } - public Page findSimilarFiles(String fileId, SimilarityFilter filter, Pageable pageable) { + public Page findSimilarFiles(String fileId, SimilarityFilter filter, Pageable pageable, String authToken) { var imageHashEntity = hashRepository.findById(fileId) .orElseThrow(() -> new RuntimeException("Hash not found")); - Integer hash64Hi = imageHashEntity.getHash64Hi(); - Integer hash64Lo = imageHashEntity.getHash64Lo(); + Long hash64Hi = imageHashEntity.getHash64Hi(); + Long hash64Lo = imageHashEntity.getHash64Lo(); - List candidates = repository.findCandidates(fileId); + List candidates = authToken.equals("all") ? repository.findCandidates(fileId): + repository.findCandidatesFromUserFiles(fileId, authTokenRepository.findUserIdByToken(authToken)); List similarityLevels = (filter != null && filter.getSimilarityLevels() != null) ? filter.getSimilarityLevels() : List.of("DUPLICATE", "SIMILAR", "DIFFERENT"); - List allResults = candidates.stream() + List allResults = candidates.stream() .map(c -> createSimilarFileResponse(c, hash64Hi, hash64Lo)) .filter(response -> similarityLevels.contains(response.getSimilarityLevel())) - .sorted(Comparator.comparingInt(SimilarFileResponse::getHammingDistance)) + .sorted(Comparator.comparingInt(SimilarFileDTO::getHammingDistance)) .collect(Collectors.toList()); int total = allResults.size(); @@ -98,17 +158,17 @@ public class FileSimilarityService { int fromIndex = Math.min(page * size, total); int toIndex = Math.min(fromIndex + size, total); - List pageContent = allResults.subList(fromIndex, toIndex); + List pageContent = allResults.subList(fromIndex, toIndex); return new PageImpl<>(pageContent, pageable, total); } - private SimilarFileResponse createSimilarFileResponse(SimilarImageProjection similarImageProjection, - Integer hash64Hi, Integer hash64Lo) { - Integer imageProjectionHash64Hi = similarImageProjection.getHash64Hi(); - Integer similarImageProjectionHash64Lo = similarImageProjection.getHash64Lo(); + private SimilarFileDTO createSimilarFileResponse(SimilarImageProjection similarImageProjection, + Long hash64Hi, Long hash64Lo) { + Long imageProjectionHash64Hi = similarImageProjection.getHash64Hi(); + Long similarImageProjectionHash64Lo = similarImageProjection.getHash64Lo(); - int hamming = hamming64(hash64Hi, hash64Lo, imageProjectionHash64Hi, similarImageProjectionHash64Lo); + int hamming = fileUtil.hamming64(hash64Hi, hash64Lo, imageProjectionHash64Hi, similarImageProjectionHash64Lo); String level; if (hamming <= 5) { @@ -119,17 +179,13 @@ public class FileSimilarityService { level = "DIFFERENT"; } - return SimilarFileResponse.builder() + return SimilarFileDTO.builder() .fileId(similarImageProjection.getId()) + .ownerId(similarImageProjection.getUserId()) .originalFileName(similarImageProjection.getOriginalFileName()) .fileSize(similarImageProjection.getFileSize()) .hammingDistance(hamming) .similarityLevel(level) .build(); } - - private int hamming64(int aHi, int aLo, int bHi, int bLo) { - return Integer.bitCount(aHi ^ bHi) - + Integer.bitCount(aLo ^ bLo); - } } diff --git a/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java b/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java new file mode 100644 index 0000000..f0829a1 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/GoogleVisionSearchService.java @@ -0,0 +1,205 @@ +package ru.soune.nocopy.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.vision.v1.*; +import com.google.protobuf.ByteString; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.dto.BaseResponse; +import ru.soune.nocopy.dto.MessageCode; +import ru.soune.nocopy.dto.file.GoogleVisionSearchResponse; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.exception.NotValidFieldException; +import ru.soune.nocopy.repository.FileEntityRepository; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GoogleVisionSearchService { + private final FileEntityRepository fileEntityRepository; + + private final ObjectMapper objectMapper; + + private ImageAnnotatorClient visionClient; + + @PostConstruct + public void init() throws IOException { + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true); + + initVisionClient(); + } + + private void initVisionClient() throws IOException { + try (InputStream credentialsStream = new ClassPathResource("config/google-service-account.json").getInputStream()) { + GoogleCredentials credentials = GoogleCredentials.fromStream(credentialsStream); + + ImageAnnotatorSettings settings = ImageAnnotatorSettings.newBuilder() + .setCredentialsProvider(() -> credentials) + .build(); + + this.visionClient = ImageAnnotatorClient.create(settings); + } + } + + public GoogleVisionSearchResponse searchByFileEntity(String fileId) throws IOException { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> { + throw new NotValidFieldException("File not found", new BaseResponse(20007, + MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), + Map.of("fileId", fileId))); + }); + + if (!isImageFile(fileEntity)) { + throw new NotValidFieldException("File not image", new BaseResponse(20007, + MessageCode.INVALID_FIELD.getCode(), MessageCode.INVALID_FIELD.getDescription(), + Map.of("file_type", fileEntity.getMimeType()))); + } + + byte[] fileBytes; + try { + fileBytes = readFileFromDisk(fileEntity); + } catch (IOException e) { + throw new NotValidFieldException("File not found or cannot read file", new BaseResponse(20007, + MessageCode.FILE_NOT_FOUND.getCode(), MessageCode.FILE_NOT_FOUND.getDescription(), + Map.of("fileId", fileId, + "filePath", fileEntity.getFilePath()))); + } + + return callGoogleVisionApi(fileBytes); + } + + private boolean isImageFile(FileEntity fileEntity) { + String mimeType = fileEntity.getMimeType(); + return mimeType != null && mimeType.startsWith("image"); + } + + private byte[] readFileFromDisk(FileEntity fileEntity) throws IOException { + Path filePath = Path.of(fileEntity.getFilePath()); + + if (!Files.exists(filePath) || !Files.isReadable(filePath)) { + throw new IOException("Cannot read file: " + fileEntity.getFilePath()); + } + + return Files.readAllBytes(filePath); + } + + private GoogleVisionSearchResponse callGoogleVisionApi(byte[] imageBytes) throws IOException { + ByteString imgBytes = ByteString.copyFrom(imageBytes); + Image image = Image.newBuilder() + .setContent(imgBytes) + .build(); + + Feature feature = Feature.newBuilder() + .setType(Feature.Type.WEB_DETECTION) + .setMaxResults(20) + .build(); + + AnnotateImageRequest request = AnnotateImageRequest.newBuilder() + .addFeatures(feature) + .setImage(image) + .build(); + + BatchAnnotateImagesResponse response = visionClient.batchAnnotateImages(List.of(request)); + + if (response.getResponsesCount() == 0) { + throw new IOException("Empty response from Google Vision API"); + } + + AnnotateImageResponse singleResponse = response.getResponses(0); + + if (singleResponse.hasError()) { + throw new IOException("Google Vision API error: " + singleResponse.getError().getMessage()); + } + + return convertToResponse(singleResponse.getWebDetection()); + } + + private GoogleVisionSearchResponse convertToResponse(WebDetection webDetection) { + GoogleVisionSearchResponse response = new GoogleVisionSearchResponse(); + + if (webDetection.getBestGuessLabelsCount() > 0) { + response.setBestGuessLabels( + webDetection.getBestGuessLabelsList().stream() + .map(label -> { + GoogleVisionSearchResponse.BestGuessLabel dtoLabel = + new GoogleVisionSearchResponse.BestGuessLabel(); + dtoLabel.setLabel(label.getLabel()); + dtoLabel.setLanguageCode(label.getLanguageCode()); + return dtoLabel; + }) + .toList() + ); + } + + if (webDetection.getFullMatchingImagesCount() > 0) { + response.setFullMatchingImages( + webDetection.getFullMatchingImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + if (webDetection.getVisuallySimilarImagesCount() > 0) { + response.setVisuallySimilarImages( + webDetection.getVisuallySimilarImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + if (webDetection.getPagesWithMatchingImagesCount() > 0) { + response.setPagesWithMatchingImages( + webDetection.getPagesWithMatchingImagesList().stream() + .map(page -> { + GoogleVisionSearchResponse.PageResult dtoPage = + new GoogleVisionSearchResponse.PageResult(); + dtoPage.setUrl(page.getUrl()); + dtoPage.setPageTitle(page.getPageTitle()); + return dtoPage; + }) + .toList() + ); + } + + if (webDetection.getPartialMatchingImagesCount() > 0) { + response.setPartialMatchingImages( + webDetection.getPartialMatchingImagesList().stream() + .map(img -> { + GoogleVisionSearchResponse.ImageResult dtoImg = + new GoogleVisionSearchResponse.ImageResult(); + dtoImg.setUrl(img.getUrl()); + dtoImg.setScore(img.getScore()); + return dtoImg; + }) + .toList() + ); + } + + return response; + } +} \ No newline at end of file diff --git a/src/main/java/ru/soune/nocopy/service/ImageHashService.java b/src/main/java/ru/soune/nocopy/service/ImageHashService.java index b7b6882..672bf78 100644 --- a/src/main/java/ru/soune/nocopy/service/ImageHashService.java +++ b/src/main/java/ru/soune/nocopy/service/ImageHashService.java @@ -1,5 +1,7 @@ package ru.soune.nocopy.service; +import com.vrt.fileprotection.image.phash.PHash; +import com.vrt.fileprotection.image.phash.PerceptualHashHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -7,15 +9,10 @@ import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.ImageHashEntity; import ru.soune.nocopy.repository.ImageHashRepository; -import javax.imageio.ImageIO; -import java.awt.*; -import java.awt.image.BufferedImage; +import java.io.File; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDateTime; -import java.util.Arrays; import java.util.Map; @Service @@ -25,15 +22,19 @@ public class ImageHashService { private final ImageHashRepository repository; - public Map calculateHash(Path imagePath) throws IOException { - long hash64 = computePhash64(imagePath); + public Map calculateHash(Path imagePath) throws IOException { + File file = imagePath.toFile(); + PHash pHash = PerceptualHashHelper.INSTANCE.generateDCTPerceptualHash(file); + Long firstPart = pHash.getFirstPart(); + Long secondPart = pHash.getSecondPart(); + Map hash = Map.of( + "hi", firstPart, + "low", secondPart); - return Map.of( - "hi", high32(hash64), - "low", low32(hash64)); + return hash; } - public void create(FileEntity file, Map stringIntegerMap) { + public void create(FileEntity file, Map stringIntegerMap) { ImageHashEntity entity = ImageHashEntity.builder() .file(file) .hash64Hi(stringIntegerMap.get("hi")) @@ -44,111 +45,4 @@ public class ImageHashService { repository.save(entity); } - - private long computePhash64(Path path) throws IOException { - try (InputStream is = Files.newInputStream(path)) { - BufferedImage image = ImageIO.read(is); - if (image == null) throw new IOException("Cannot read image file: " + path); - - BufferedImage gray = toGrayScale(image); - BufferedImage resized = resizeImage(gray, 32, 32); - - double[][] dct = applyDCT(resized); - double[][] topLeft = extractTopLeft8x8(dct); - double median = calculateMedian(topLeft, true); - - return create64BitHashLong(topLeft, median); - } - } - - private BufferedImage toGrayScale(BufferedImage image) { - BufferedImage gray = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY); - Graphics2D g = gray.createGraphics(); - g.drawImage(image, 0, 0, null); - g.dispose(); - return gray; - } - - private BufferedImage resizeImage(BufferedImage image, int width, int height) { - BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_GRAY); - Graphics2D g = resized.createGraphics(); - g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); - g.drawImage(image, 0, 0, width, height, null); - g.dispose(); - return resized; - } - - private double[][] applyDCT(BufferedImage image) { - int w = image.getWidth(); - int h = image.getHeight(); - double[][] pixels = new double[h][w]; - - for (int y = 0; y < h; y++) { - for (int x = 0; x < w; x++) { - int gray = image.getRGB(x, y) & 0xFF; - pixels[y][x] = gray; - } - } - - double[][] dct = new double[h][w]; - for (int u = 0; u < h; u++) { - double au = u == 0 ? 1.0 / Math.sqrt(h) : Math.sqrt(2.0 / h); - for (int v = 0; v < w; v++) { - double av = v == 0 ? 1.0 / Math.sqrt(w) : Math.sqrt(2.0 / w); - double sum = 0.0; - for (int x = 0; x < h; x++) { - for (int y = 0; y < w; y++) { - sum += pixels[x][y] * - Math.cos((2 * x + 1) * u * Math.PI / (2.0 * h)) * - Math.cos((2 * y + 1) * v * Math.PI / (2.0 * w)); - } - } - dct[u][v] = au * av * sum; - } - } - return dct; - } - - private double[][] extractTopLeft8x8(double[][] dct) { - double[][] block = new double[8][8]; - for (int i = 0; i < 8; i++) System.arraycopy(dct[i], 0, block[i], 0, 8); - return block; - } - - private double calculateMedian(double[][] block, boolean excludeDC) { - double[] vals = new double[63]; - int idx = 0; - for (int i = 0; i < 8; i++) { - for (int j = 0; j < 8; j++) { - if (i == 0 && j == 0 && excludeDC) continue; - vals[idx++] = block[i][j]; - } - } - Arrays.sort(vals); - return (vals.length % 2 == 0) ? - (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2.0 : - vals[vals.length / 2]; - } - - private long create64BitHashLong(double[][] block, double median) { - long hash = 0L; - - for (int i = 0; i < 8; i++) { - for (int j = 0; j < 8; j++) { - if (i == 0 && j == 0) continue; - hash <<= 1; - if (block[i][j] > median) hash |= 1; - } - } - - return hash; - } - - private int high32(long hash64) { - return (int) (hash64 >>> 32); - } - - private int low32(long hash64) { - return (int) hash64; - } } diff --git a/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java index bfc8123..fd4c8bc 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java +++ b/src/main/java/ru/soune/nocopy/service/file/FileEntityService.java @@ -9,6 +9,7 @@ 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.exception.DuplicateImageException; import ru.soune.nocopy.exception.FileEntityNotFoundException; import ru.soune.nocopy.repository.FileEntityRepository; @@ -16,6 +17,7 @@ import ru.soune.nocopy.repository.SimilarImageProjection; import ru.soune.nocopy.service.FileSimilarityService; import ru.soune.nocopy.service.ImageHashService; +import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -47,7 +49,7 @@ public class FileEntityService { throw new IOException("File not found on disk: " + filePath); } - Map imageHash = Map.of(); + Map imageHash = Map.of(); if (session.getFileType().startsWith("image")) { imageHash = imageHashService.calculateHash(filePath); @@ -110,6 +112,39 @@ 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 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( @@ -167,7 +202,8 @@ public class FileEntityService { Files.delete(path); - markAsDeleted(fileEntity); +// markAsDeleted(fileEntity); + fileEntityRepository.delete(fileEntity); return true; } @@ -178,6 +214,99 @@ public class FileEntityService { return totalSize != null ? totalSize : 0L; } + public void changeStatus(ProtectionStatus newStatus, String fileId) { + FileEntity fileEntity = fileEntityRepository.findById(fileId) + .orElseThrow(() -> new FileEntityNotFoundException(fileId)); + fileEntity.setProtectionStatus(newStatus); + + fileEntityRepository.save(fileEntity); + } + + @Transactional + public void writeProtectedFile(String id, byte[] data, String fileExt) throws IOException { + FileEntity fileEntity = fileEntityRepository.findById(id) + .orElseThrow(() -> new RuntimeException("File not found: " + id)); + + String extension = determineFileExtension(fileExt, fileEntity); + Path protectedFilePath = prepareProtectedPath(fileEntity, extension); + + if (Files.exists(protectedFilePath)) { + Files.delete(protectedFilePath); + } + + Files.write(protectedFilePath, data); + + fileEntity.setProtectedFilePath(protectedFilePath.toString()); + fileEntity.setProtectedAt(LocalDateTime.now()); + fileEntity.setUpdatedAt(LocalDateTime.now()); + fileEntity.setFileExtension(extension); + fileEntity.setProtectionStatus(ProtectionStatus.PROTECTED); + + fileEntityRepository.save(fileEntity); + } + + public FileEntity findBySignature(String signature) { + return fileEntityRepository.findBySignature(signature); + } + + public String findFileIdByPath(String filePath) { + return fileEntityRepository.findFileIdByFilePath(filePath); + } + + public File getFileById(String id) { + try { + FileEntity fileEntity = fileEntityRepository.findById(id).orElseThrow(() -> + new RuntimeException("File not found: " + id)); + + File file = new File(fileEntity.getFilePath()); + + if (!file.exists()) { + throw new RuntimeException("File not found on disk: " + fileEntity.getFilePath()); + } + + return file; + } catch (Exception e) { + log.error("Error getting file: {}", id, e); + return null; + } + } + + public Path prepareProtectedPath(FileEntity fileEntity, String extension) throws IOException { + Path originalPath = Paths.get(fileEntity.getFilePath()); + + String pathStr = originalPath.toString(); + pathStr = pathStr.replaceFirst("/uploads/uploads/", "/uploads/protected/"); + + Path protectedPath = Paths.get(pathStr); + String fileName = protectedPath.getFileName().toString(); + + if (extension != null) { + String nameWithoutExt = fileName; + int lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex > 0) { + nameWithoutExt = fileName.substring(0, lastDotIndex); + } + fileName = nameWithoutExt + "." + extension; + + protectedPath = protectedPath.getParent().resolve(fileName); + } + + Files.createDirectories(protectedPath.getParent()); + + return protectedPath; + } + + public void updateSignature(String signature, String fileId) throws IOException { + FileEntity fileEntity = fileEntityRepository.findByFileId(fileId); + fileEntity.setSignature(signature); + fileEntity.setProtectionStatus(ProtectionStatus.PROTECTED); + fileEntity.setUpdatedAt(LocalDateTime.now()); + fileEntity.setProtectedAt(LocalDateTime.now()); + fileEntity.setProtectedFilePath(prepareProtectedPath(fileEntity, fileEntity.getFileExtension()).toString()); + + fileEntityRepository.save(fileEntity); + } + private boolean checkFileExistsOnDisk(String filePath) { try { return Files.exists(Paths.get(filePath)); @@ -187,6 +316,14 @@ public class FileEntityService { } } + private String determineFileExtension(String fileExt, FileEntity fileEntity) { + if (fileExt != null && !fileExt.trim().isEmpty()) { + return fileExt; + } else { + return fileEntity.getFileExtension(); + } + } + private FileEntityResponse convertToResponse(FileEntity fileEntity, int version) { boolean existsOnDisk = checkFileExistsOnDisk(fileEntity.getFilePath()); @@ -208,6 +345,7 @@ public class FileEntityService { .downloadUrl("/api/v" + version + "/files/download/" + fileEntity.getId()) .existsOnDisk(existsOnDisk) .supportId(fileEntity.getSupportId()) + .protectStatus(fileEntity.getProtectionStatus().toString()) .build(); } diff --git a/src/main/java/ru/soune/nocopy/service/file/FileProcessingOrchestrator.java b/src/main/java/ru/soune/nocopy/service/file/FileProcessingOrchestrator.java new file mode 100644 index 0000000..5c4df44 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/FileProcessingOrchestrator.java @@ -0,0 +1,53 @@ +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 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 newFiles = fileRepository.findAllActiveFilesAndNotProtected(); + + for (FileEntity fileEntity : newFiles) { + FileProtector.FileInfo fileInfo = fileUtil.createFileInfo(fileEntity); + + noCopyFileService.addFile(fileInfo); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/FileStatsService.java b/src/main/java/ru/soune/nocopy/service/file/FileStatsService.java index a32f04d..8e98f13 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileStatsService.java +++ b/src/main/java/ru/soune/nocopy/service/file/FileStatsService.java @@ -6,6 +6,7 @@ import ru.soune.nocopy.dto.file.FileInfoUserResponse; import ru.soune.nocopy.entity.file.FileEntity; import ru.soune.nocopy.entity.file.FileStatus; import ru.soune.nocopy.entity.file.FileType; +import ru.soune.nocopy.entity.file.ProtectionStatus; import ru.soune.nocopy.repository.FileEntityRepository; import java.util.List; @@ -26,23 +27,37 @@ public class FileStatsService { .fileCount(calculateTotalCount(files)) .filesCheck(calculateByStatus(files, FileStatus.CHECKED)) .filesViolation(calculateByStatus(files, FileStatus.VIOLATION)) + .protectedFilesCount(protectedUserFiles(files)) .imagesSize(calculateMediaSize(files, FileType.IMAGE)) .imagesCount(calculateMediaCount(files, FileType.IMAGE)) .imagesCheck(calculateMediaByStatus(files, FileType.IMAGE, FileStatus.CHECKED)) .imagesViolations(calculateMediaByStatus(files, FileType.IMAGE, FileStatus.VIOLATION)) + .protectedImageFilesCount(protectedUserFiles(files, FileType.IMAGE)) .videosSize(calculateMediaSize(files, FileType.VIDEO)) .videosCount(calculateMediaCount(files, FileType.VIDEO)) .videosCheck(calculateMediaByStatus(files, FileType.VIDEO, FileStatus.CHECKED)) .videosViolations(calculateMediaByStatus(files, FileType.VIDEO, FileStatus.VIOLATION)) + .protectedVideoFilesCount(protectedUserFiles(files, FileType.VIDEO)) .audiosSize(calculateMediaSize(files, FileType.AUDIO)) .audiosCount(calculateMediaCount(files, FileType.AUDIO)) .audiosCheck(calculateMediaByStatus(files, FileType.AUDIO, FileStatus.CHECKED)) .audiosViolations(calculateMediaByStatus(files, FileType.AUDIO, FileStatus.VIOLATION)) + .protectedAudioFilesCount(protectedUserFiles(files, FileType.AUDIO)) .build(); } + + private Long protectedUserFiles(List files) { + return files.stream().filter(file -> file.getProtectionStatus() == ProtectionStatus.PROTECTED).count(); + } + + private Long protectedUserFiles(List files, FileType type) { + return files.stream().filter(file -> file.getProtectionStatus() == ProtectionStatus.PROTECTED && + file.getMimeType().equals(type.getDisplayName())).count(); + } + private Long calculateTotalSize(List files) { return files.stream() .filter(file -> file.getStatus() != FileStatus.DELETED) diff --git a/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java b/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java index 714ef21..de8d09d 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java +++ b/src/main/java/ru/soune/nocopy/service/file/FileUploadService.java @@ -10,12 +10,13 @@ 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; + void handleExpiredSession(FileUploadSession session); + + void cancelUpload(String uploadId); + + UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, MultipartFile chunkFile, Integer findSimilar); UploadProgressResponse getUploadProgress(String uploadId); - void handleExpiredSession(FileUploadSession session); - - public void cancelUpload(String uploadId); + void completeFileProcessingAsync(FileUploadSession session); } diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/AudioFilePathProviderImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/AudioFilePathProviderImpl.java new file mode 100644 index 0000000..45b8058 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/AudioFilePathProviderImpl.java @@ -0,0 +1,44 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.AudioFilePathProvider; +import com.vrt.fileprotection.FileProtector; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.service.file.FileEntityService; + +import java.io.IOException; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AudioFilePathProviderImpl implements AudioFilePathProvider { + + private final FileEntityService fileEntityService; + + private final FileEntityRepository fileEntityRepository; + + @Override + public @NotNull String providePath(@NotNull FileProtector.FileInfo fileInfo) { + String filePath; + Optional optionalFileEntity = fileEntityRepository.findById(fileInfo.getId()); + + if (optionalFileEntity.isEmpty()) { + throw new NullPointerException(); + } + + FileEntity fileEntity = optionalFileEntity.get(); + + try { + filePath = fileEntityService.prepareProtectedPath(fileEntity, fileEntity.getFileExtension()).toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + return filePath; + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/AudioLocalSearchImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/AudioLocalSearchImpl.java new file mode 100644 index 0000000..6ac96bb --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/AudioLocalSearchImpl.java @@ -0,0 +1,42 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.audio.AudioLocalSearch; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.service.file.FileEntityService; +import ru.soune.nocopy.util.FileUtil; + +import java.io.IOException; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AudioLocalSearchImpl implements AudioLocalSearch { + + private final FileEntityService fileEntityService; + + private final FileUtil fileUtil; + + @Override + public @Nullable FileProtector.FileInfo findBySignature(@NotNull String signature) { + FileEntity fileEntity = fileEntityService.findBySignature(signature); + + return fileUtil.createFileInfo(fileEntity); + } + + @Override + public void updateSignature(@NotNull String signature, @NotNull FileProtector.FileInfo fileInfo) { + try { + fileEntityService.updateSignature(signature, fileInfo.getId()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + +} diff --git a/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/FileUploadServiceImpl.java similarity index 93% rename from src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java rename to src/main/java/ru/soune/nocopy/service/file/impl/FileUploadServiceImpl.java index 05b5dcd..258e29c 100644 --- a/src/main/java/ru/soune/nocopy/service/file/FileUploadServiceImpl.java +++ b/src/main/java/ru/soune/nocopy/service/file/impl/FileUploadServiceImpl.java @@ -1,5 +1,7 @@ -package ru.soune.nocopy.service.file; +package ru.soune.nocopy.service.file.impl; +import com.vrt.NoCopyFileService; +import com.vrt.fileprotection.FileProtector; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,6 +25,9 @@ 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.file.FileEntityService; +import ru.soune.nocopy.service.file.FileUploadService; +import ru.soune.nocopy.util.FileUtil; import java.io.*; import java.nio.file.*; @@ -73,6 +78,10 @@ public class FileUploadServiceImpl implements FileUploadService { @Autowired private FileEntityService fileEntityService; + private final NoCopyFileService noCopyFileService; + + private final FileUtil fileUtil; + @PostConstruct public void init() { try { @@ -138,10 +147,38 @@ public class FileUploadServiceImpl implements FileUploadService { return savedSession; } + @Override + @Transactional + public void handleExpiredSession(FileUploadSession session) { + session.setStatus(UploadStatus.FAILED); + session.setLastError("Upload session expired"); + sessionRepository.save(session); + + CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); + } + + @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 UploadProgressResponse uploadChunk(String uploadId, Integer chunkNumber, - MultipartFile chunkFile) { + MultipartFile chunkFile, Integer findSimilar) { FileUploadSession session = sessionRepository.findById(uploadId) .orElseThrow(() -> new UploadSessionNotFoundException(uploadId)); @@ -167,52 +204,7 @@ public class FileUploadServiceImpl implements FileUploadService { throw new ChunkSizeExceededException(chunkFile.getSize(), chunkSize); } - return processChunk(session, chunkNumber, chunkFile); - } - - @Override - @Transactional - public void handleExpiredSession(FileUploadSession session) { - session.setStatus(UploadStatus.FAILED); - session.setLastError("Upload session expired"); - sessionRepository.save(session); - - CompletableFuture.runAsync(() -> cleanupSessionFiles(session)); - } - - @Async("fileUploadTaskExecutor") - @Transactional - public void assembleFileAsync(FileUploadSession session) throws DuplicateImageException { - try { - assembleFile(session); - log.info("File assembly completed successfully for session: {}", - session.getUploadId()); - } catch (DuplicateImageException e) { - throw new DuplicateImageException("DUBL", e.duplicateFileId(), e.userId()); - } catch (Exception e) { - log.error("Failed to assemble file for session {}: {}", - session.getUploadId(), e.getMessage(), e); - - handleAssemblyFailure(session, e); - } - } - - @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); + return processChunk(session, chunkNumber, chunkFile, findSimilar); } @Override @@ -223,6 +215,47 @@ public class FileUploadServiceImpl implements FileUploadService { return UploadProgressResponse.fromSession(session); } + @Async("fileUploadTaskExecutor") + @Transactional + @Override + public void completeFileProcessingAsync(FileUploadSession session) { + try { + Path filePath = Paths.get(session.getFilePath()); + String checksum = calculateChecksum(filePath); + + FileEntity fileEntity = FileEntity.builder() + .userId(session.getUserId()) + .originalFileName(session.getFileName()) + .storedFileName(filePath.getFileName().toString()) + .filePath(session.getFilePath()) + .fileSize(session.getFileSize()) + .mimeType(session.getFileType()) + .fileExtension(session.getExtension()) + .checksum(checksum) + .uploadSessionId(session.getUploadId()) + .status(FileStatus.ACTIVE) + .build(); + + FileEntity saved = fileEntityRepository.save(fileEntity); + + if (session.getFileType().equals("image")) { + Map hash = imageHashService.calculateHash(filePath); + + imageHashService.create(saved, hash); + } + + cleanupSessionFiles(session); + + noCopyFileService.addFile(fileUtil.createFileInfo(fileEntity)); + + log.info("File processing completed for session: {}", session.getUploadId()); + + } catch (Exception e) { + log.error("Failed to complete file processing for session {}: {}", + session.getUploadId(), e.getMessage(), e); + } + } + private void validateSession(FileUploadSession session) { UploadStatus status = session.getStatus(); @@ -252,47 +285,13 @@ public class FileUploadServiceImpl implements FileUploadService { } } - @Async("fileUploadTaskExecutor") - @Transactional - public void completeFileProcessingAsync(FileUploadSession session) { - try { - Path filePath = Paths.get(session.getFilePath()); - String checksum = calculateChecksum(filePath); - - FileEntity fileEntity = FileEntity.builder() - .userId(session.getUserId()) - .originalFileName(session.getFileName()) - .storedFileName(filePath.getFileName().toString()) - .filePath(session.getFilePath()) - .fileSize(session.getFileSize()) - .mimeType(session.getFileType()) - .fileExtension(session.getExtension()) - .checksum(checksum) - .uploadSessionId(session.getUploadId()) - .status(FileStatus.ACTIVE) - .build(); - - FileEntity saved = fileEntityRepository.save(fileEntity); - - Map hash = imageHashService.calculateHash(filePath); - imageHashService.create(saved, hash); - - cleanupSessionFiles(session); - - log.info("File processing completed for session: {}", session.getUploadId()); - - } catch (Exception e) { - log.error("Failed to complete file processing for session {}: {}", - session.getUploadId(), e.getMessage(), e); - } - } - - private UploadProgressResponse processChunk(FileUploadSession session, Integer chunkNumber, MultipartFile chunkFile) { + private UploadProgressResponse processChunk(FileUploadSession session, Integer chunkNumber, MultipartFile chunkFile, + Integer findSimilar) { String chunkPath = null; try { if (session.getChunkPaths().containsKey(chunkNumber)) { - return handleExistingChunk(session, chunkNumber, chunkFile); + return handleExistingChunk(session, chunkNumber, chunkFile, findSimilar); } chunkPath = saveChunkWithIntegrityCheck(session, chunkNumber, chunkFile); @@ -313,7 +312,7 @@ public class FileUploadServiceImpl implements FileUploadService { String finalFilePath = assembleFileSynchronously(session); - if (session.getFileType().startsWith("image")) { + if (session.getFileType().startsWith("image") && findSimilar == 0) { checkForDuplicatesSynchronously(finalFilePath); } @@ -368,21 +367,23 @@ public class FileUploadServiceImpl implements FileUploadService { } private void checkForDuplicatesSynchronously(String filePath) - throws IOException { + throws IOException , DuplicateImageException { Path path = Paths.get(filePath); - Map hash = imageHashService.calculateHash(path); + Map hash = imageHashService.calculateHash(path); + List duplicates = fileSimilarityService.findDuplicatedByHash( hash.get("hi"), hash.get("low")); if (!duplicates.isEmpty()) { - throw new DuplicateImageException("Duplicate", duplicates.get(0).getId(), - duplicates.get(0).getUserId()); + throw new DuplicateImageException("Duplicate", duplicates.getFirst().getId(), + duplicates.getFirst().getUserId()); } } private UploadProgressResponse handleExistingChunk(FileUploadSession session, Integer chunkNumber, - MultipartFile chunkFile) throws IOException { + MultipartFile chunkFile, + Integer findSimilar) throws IOException { String existingPath = session.getChunkPaths().get(chunkNumber); Path chunkPath = Paths.get(existingPath); @@ -391,7 +392,7 @@ public class FileUploadServiceImpl implements FileUploadService { session.setChunksUploaded(session.getChunksUploaded() - 1); sessionRepository.save(session); - return processChunk(session, chunkNumber, chunkFile); + return processChunk(session, chunkNumber, chunkFile, findSimilar); } long existingSize = Files.size(chunkPath); @@ -402,7 +403,7 @@ public class FileUploadServiceImpl implements FileUploadService { session.setChunksUploaded(session.getChunksUploaded() - 1); sessionRepository.save(session); - return processChunk(session, chunkNumber, chunkFile); + return processChunk(session, chunkNumber, chunkFile, findSimilar); } return UploadProgressResponse.fromSession(session); @@ -482,11 +483,17 @@ public class FileUploadServiceImpl implements FileUploadService { 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); + 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) { diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/ImageLocalSearchImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/ImageLocalSearchImpl.java new file mode 100644 index 0000000..79b8963 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/ImageLocalSearchImpl.java @@ -0,0 +1,54 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.image.ImageLocalSearch; +import com.vrt.fileprotection.image.phash.PHash; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.dto.file.SimilarFileDTO; +import ru.soune.nocopy.entity.file.FileEntity; +import ru.soune.nocopy.entity.file.ImageHashEntity; +import ru.soune.nocopy.repository.FileEntityRepository; +import ru.soune.nocopy.repository.ImageHashRepository; +import ru.soune.nocopy.service.FileSimilarityService; +import ru.soune.nocopy.util.FileUtil; + +import java.util.List; +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageLocalSearchImpl implements ImageLocalSearch { + + private final FileSimilarityService fileSimilarityService; + + private final ImageHashRepository imageHashRepository; + + private final FileUtil fileUtil; + + private final FileEntityRepository fileEntityRepository; + + @Override + public @NotNull List find(@NotNull FileProtector.FileInfo fileInfo) { + List duplicateByHammingDistance = + fileSimilarityService.findDuplicateByHammingDistance(fileInfo.getId(), 10, + 5, 10); + + return duplicateByHammingDistance.stream() + .map(fileUtil::convertToResult) + .toList(); + } + + @Override + public @Nullable FileProtector.FileInfo findByPHash(@NotNull PHash pHash) { + ImageHashEntity imageHashEntity = + imageHashRepository.findByHash64HiAndHash64Lo(pHash.getFirstPart(), + pHash.getSecondPart()); + FileEntity file = fileEntityRepository.findByFileId(imageHashEntity.getFileId()); + + return new FileProtector.FileInfo(FileProtector.Type.IMAGE, imageHashEntity.getFileId(), + String.valueOf(file.getUserId())); + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/ImageUniqueCheckImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/ImageUniqueCheckImpl.java new file mode 100644 index 0000000..c883a4d --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/ImageUniqueCheckImpl.java @@ -0,0 +1,34 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.OperationResult; +import com.vrt.fileprotection.image.ImageUniqueCheck; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.dto.file.SimilarFileDTO; +import ru.soune.nocopy.service.FileSimilarityService; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ImageUniqueCheckImpl implements ImageUniqueCheck { + + private final FileSimilarityService fileSimilarityService; + + @Override + public @NotNull OperationResult check(@NotNull FileProtector.FileInfo fileInfo) { + String fileId = fileInfo.getId(); + List duplicateByHammingDistance = + fileSimilarityService.findDuplicateByHammingDistance(fileId, 3, 3, 4); + + if (duplicateByHammingDistance == null || duplicateByHammingDistance.isEmpty()) { + return OperationResult.Companion.success(); + } else { + return OperationResult.Companion.failure("Duplicate file"); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java new file mode 100644 index 0000000..257cf65 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/NoCopyProcessingListenerImpl.java @@ -0,0 +1,36 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.fileprotection.FileProtector; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import ru.soune.nocopy.entity.file.ProtectionStatus; +import ru.soune.nocopy.service.file.FileEntityService; + +@Slf4j +@Component +@AllArgsConstructor +public class NoCopyProcessingListenerImpl implements FileProtector.ProcessingListener { + + private final FileEntityService fileEntityService; + + @Override + public void onStartProcessing(FileProtector.FileInfo fileInfo) { + fileEntityService.changeStatus(ProtectionStatus.PROCESSING, fileInfo.getId()); + } + + @Override + public void onProcessingFailed(FileProtector.FileInfo fileInfo) { + fileEntityService.changeStatus(ProtectionStatus.FAILED, fileInfo.getId()); + } + + @Override + public void onSavingFailed(FileProtector.FileInfo fileInfo) { + fileEntityService.changeStatus(ProtectionStatus.FAILED_SAVE, fileInfo.getId()); + } + + @Override + public void onFinish(FileProtector.FileInfo fileInfo) { + fileEntityService.changeStatus(ProtectionStatus.PROTECTED, fileInfo.getId());; + } +} diff --git a/src/main/java/ru/soune/nocopy/service/file/impl/ProtectionFileProviderImpl.java b/src/main/java/ru/soune/nocopy/service/file/impl/ProtectionFileProviderImpl.java new file mode 100644 index 0000000..ffb4883 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/service/file/impl/ProtectionFileProviderImpl.java @@ -0,0 +1,108 @@ +package ru.soune.nocopy.service.file.impl; + +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.OperationResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; +import ru.soune.nocopy.service.file.FileEntityService; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProtectionFileProviderImpl implements FileProtector.FileProvider { + + private static final Path SIGNATURE_FILE_PATH = Paths.get("/data/uploads/signature"); + + private final FileEntityService fileEntityService; + + @Nullable + @Override + public File getImageFile(@NotNull String id) { + return fileEntityService.getFileById(id); + } + + @Nullable + @Override + public File getVideoFile(@NotNull String id) { + return fileEntityService.getFileById(id); + } + + @Nullable + @Override + public File getAudioFile(@NotNull String id) { + return fileEntityService.getFileById(id); + } + + @Override + public @Nullable File getSignature() { + File signatureFile = SIGNATURE_FILE_PATH.toFile(); + + if (signatureFile.exists() && signatureFile.isFile() && signatureFile.length() > 0) { + return signatureFile; + } + + return null; + } + + @Override + public @NotNull OperationResult writeSignature(@NotNull byte[] bytes) { + try { + Path directory = SIGNATURE_FILE_PATH.getParent(); + + if (directory != null && !Files.exists(directory)) { + Files.createDirectories(directory); + } + + Files.write(SIGNATURE_FILE_PATH, bytes); + + return OperationResult.Companion.success(); + } catch (IOException e) { + return OperationResult.Companion.failure("Not saved signature file"); + } + } + + @NotNull + @Override + public com.vrt.fileprotection.OperationResult writeAudioFile(@NotNull String id, @NotNull byte[] data, @Nullable String fileExt) { + try { + fileEntityService.writeProtectedFile(id, data, fileExt); + return OperationResult.Companion.success(); + } catch (Exception e) { + log.error("Failed to create protected file: {}", id, e); + return OperationResult.Companion.failure("Failed to create protected file"); + } + } + + @NotNull + @Override + public com.vrt.fileprotection.OperationResult writeVideoFile(@NotNull String id, @NotNull byte[] data, @Nullable String fileExt) { + try { + fileEntityService.writeProtectedFile(id, data, fileExt); + return OperationResult.Companion.success(); + } catch (Exception e) { + log.error("Failed to create protected file: {}", id, e); + return OperationResult.Companion.failure("Failed to create protected file"); + } + } + + @NotNull + @Override + public com.vrt.fileprotection.OperationResult writeImageFile(@NotNull String id, @NotNull byte[] data, @Nullable String fileExt) { + try { + fileEntityService.writeProtectedFile(id, data, fileExt); + return OperationResult.Companion.success(); + } catch (Exception e) { + log.error("Failed to create protected file: {}", id, e); + return OperationResult.Companion.failure("Failed to create protected file"); + } + } +} diff --git a/src/main/java/ru/soune/nocopy/util/FileUtil.java b/src/main/java/ru/soune/nocopy/util/FileUtil.java new file mode 100644 index 0000000..192e1d3 --- /dev/null +++ b/src/main/java/ru/soune/nocopy/util/FileUtil.java @@ -0,0 +1,48 @@ +package ru.soune.nocopy.util; + +import com.vrt.fileprotection.FileProtector; +import com.vrt.fileprotection.image.ImageLocalSearch; +import com.vrt.fileprotection.image.ImageScore; +import org.springframework.stereotype.Service; +import ru.soune.nocopy.dto.file.SimilarFileDTO; +import ru.soune.nocopy.entity.file.FileEntity; + +@Service +public class FileUtil { + public ImageLocalSearch.Result convertToResult(SimilarFileDTO response) { + FileProtector.FileInfo fileInfo = + new FileProtector.FileInfo(FileProtector.Type.IMAGE, response.getFileId(), + String.valueOf(response.getOwnerId())); + ImageScore imageScore = new ImageScore(ImageScore.Rate.valueOf(response.getSimilarityLevel()), + response.getHammingDistance()); + + return new ImageLocalSearch.Result(fileInfo, imageScore); + } + + public FileProtector.FileInfo createFileInfo(FileEntity fileEntity) { + FileProtector.Type type = determineFileType(fileEntity.getMimeType()); + + return new FileProtector.FileInfo(type, fileEntity.getId(), String.valueOf(fileEntity.getUserId())); + } + + public int hamming64(long aHi, long aLo, long bHi, long bLo) { + return Long.bitCount(aHi ^ bHi) + + Long.bitCount(aLo ^ bLo); + } + + private FileProtector.Type determineFileType(String mimeType) { + if (mimeType == null) { + return FileProtector.Type.IMAGE; + } + + if (mimeType.startsWith("image")) { + return FileProtector.Type.IMAGE; + } else if (mimeType.startsWith("video")) { + return FileProtector.Type.VIDEO; + } else if (mimeType.startsWith("audio")) { + return FileProtector.Type.AUDIO; + } else { + return FileProtector.Type.IMAGE; + } + } +} diff --git a/src/main/resources/config/google-service-account.json b/src/main/resources/config/google-service-account.json new file mode 100644 index 0000000..2e50435 --- /dev/null +++ b/src/main/resources/config/google-service-account.json @@ -0,0 +1,13 @@ +{ + "type": "service_account", + "project_id": "ncp-1-483610", + "private_key_id": "94ea762d9a56a3362155ab33f8959bfec5ae8b42", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCwjY9sYQfeqHwr\nqKgr8sw3Qfao/vzTa9wnmbZvrM6UJ3ukMkj08w50rP0udKxTRWnH6rpSQnD696GS\nY0/qrjEO1LeMdPJ+R0v8B+Z3PlpuQtfmxXALF4QbGQxeWg9NK7o4JM0kANkV80me\nxo6T7WnywQm1SfBIPZjwLBhkn7pfcdTDJFiZkH073yB6/tPsgd6TRQDmlvGsrGmq\nCf8E/DhxaFIHUpoUEGpxeuFPmvAicEWdURCtCXeiAxRQv+Gy0mP7uGGY6C3SeYfJ\n5PNLopgnN2ATCeIp1EjJnH28sAEvE0e/K/9z4ZThgh1mB1CaLkaQweq3YQWoiyGF\nfcoPpJ8LAgMBAAECggEABluSppx35BS9I/VW2P5NTFAbycok4JgpvWNTpoHajos9\ncJQ+/FMkWh9AnsOk0PrW7FQPkZbC6sawEs1wu0q6iYYvdedgNBvtKI5ARlsAdqgB\njlXdywh1wWQNhfhIRMcmVY89s+Yz6w5vwD/2Mm2COzOoXdbjyDYvo7ZyymzWEUnA\nwaxg7aF5cGC+iqf+l2Ym8XdY2ZavvyZ8RwgKfQGfcPuhMogg+H6au4yvXPBNxv0u\nxDCbOm2ezA4cLyKyFqi6S5KiGmk2LnCOoLrkIalmTQhnptFZofGs4ezuOvhZ6ZTi\nQVDayHzbBygmFzJn1wzw83IQ7QgMmWYoEjC+JcRlgQKBgQDg6U3QPxhdvX1dCzBj\nef6yUrIeJhiILvKnAzi6MUhaWP0mU6kcIIn8DS78ojML+leKh2R7JQFFrt87kHjK\npcaTC7wkgNCa6e3d+U95EIVUvPVrvQ5O4zIM7evTiPEeZhN9Dhuh0o3eeYxfIqZO\nsN0y8F/xPWdGW5CcdL7dcTOUkwKBgQDI9Qzr7poRaHM8jzS0M4Q4cVsYdC2P6KgI\nWD9Qbbl1rVi14YLt1FSd4w7U4YJ/kaWOrdtKklqAvM3oq5J0oEQekpixsxFvXdW+\ngJTzOoOq2lgCThBagwVKOFkAH/CwPkhFPwuVBgfVNiajbrcGF6i5VWzIHCXT+IDe\naSw64dqOqQKBgDp9EYpNTjXaeEaBCWVlLVIMZVunxotrwhiiotbwyAMOz05vRTQW\nVivg9c4nFCVSRf+1c/D+T5Vig5UG3hK9B6Xn0Fah1R3kJcKq+frey/2cYipRcO4c\n8UAhg0lwfFvOadUEnTT4/4HSlCmNZjhikDOWBS1ELZ5DY5j8V0JZFPPXAoGAXi0B\nLjw2dbwGbTYLk/ukljMBZvdjNtLolGiO22lghbaEIVCa5Ewij4+OLtO0LYabGL9/\nSnZF9ZkFwmlNjFxjMBSxfG2X2SIXflyR8V7Vv6btob7lyRUn0H2RsA5H5MB7bAA8\ntE0MNK5Y8zR6j19dEeXnwev3ClymQBT3xmx72WkCgYAEhx+piCTvD+2SFBweM3J4\ng6LefBXFWHSlftQF1Er5mI+5kr4i3M5d6zh8lKcLpygSS1q5/03CMUxEiLTZMLmG\nVX/+UgEWzdEe1Dr/7YPYcOSPiBpTRyPiq6KQC/KtPPWHouGyaPO1SAAZhlk97dQs\n9mosf4Re69Re8U6MxxGw9Q==\n-----END PRIVATE KEY-----\n", + "client_email": "image-search-service@ncp-1-483610.iam.gserviceaccount.com", + "client_id": "105492884344006453429", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/image-search-service%40ncp-1-483610.iam.gserviceaccount.com", + "universe_domain": "googleapis.com" +} diff --git a/src/main/resources/db/migration/V1__add_image_hash_prefix_index.sql b/src/main/resources/db/migration/V1__add_image_hash_prefix_index.sql deleted file mode 100644 index 2700671..0000000 --- a/src/main/resources/db/migration/V1__add_image_hash_prefix_index.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX idx_image_hash_prefix - ON image_hashes ((hash_value >> 48)); \ No newline at end of file