diff --git a/build.gradle b/build.gradle index cac55bd..aff0d0b 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,12 @@ repositories { mavenCentral() } +dependencyManagement { + imports { + mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1' + } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' @@ -34,6 +40,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.mapstruct:mapstruct:1.5.5.Final' + // S3 / MinIO + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' diff --git a/docker-compose.yaml b/docker-compose.yaml index fb3cfb1..11491d7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,4 +13,31 @@ services: redis: image: redis:7 ports: - - "6379:6379" \ No newline at end of file + - "6379:6379" + + minio: + # Используемый образ Docker. Рекомендуется указывать конкретный тег для стабильности. + image: minio/minio:RELEASE.2024-04-18T19-09-19Z + container_name: minio-example + command: server /data --address ":9010" --console-address ":9011" + restart: unless-stopped + environment: + MINIO_ACCESS_KEY: admin + MINIO_SECRET_KEY: admin123 + # Проброс портов с хост-машины в контейнер. + ports: + - "9010:9010" # API port + - "9011:9011" # Console port + volumes: + - minio-data:/data + deploy: + resources: + limits: + memory: 1024m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" +volumes: + minio-data: \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/ReFoundApplication.java b/src/main/java/com/krylov/refound/ReFoundApplication.java index 39fad69..2259c01 100644 --- a/src/main/java/com/krylov/refound/ReFoundApplication.java +++ b/src/main/java/com/krylov/refound/ReFoundApplication.java @@ -1,11 +1,18 @@ package com.krylov.refound; +import com.krylov.refound.config.MinioProperties; +import io.awspring.cloud.autoconfigure.s3.S3AutoConfiguration; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication(scanBasePackages = "com.krylov.refound") -@EnableScheduling +@SpringBootApplication( + scanBasePackages = "com.krylov.refound", + exclude = { S3AutoConfiguration.class } +)@EnableScheduling +@EnableConfigurationProperties(MinioProperties.class) + public class ReFoundApplication { public static void main(String[] args) { diff --git a/src/main/java/com/krylov/refound/config/MinioProperties.java b/src/main/java/com/krylov/refound/config/MinioProperties.java new file mode 100644 index 0000000..314b7f5 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/MinioProperties.java @@ -0,0 +1,15 @@ +package com.krylov.refound.config; + +import jakarta.validation.constraints.NotBlank; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Validated +@ConfigurationProperties(prefix = "minio") +public record MinioProperties ( + @NotBlank String endpoint, + @NotBlank String bucket, + @NotBlank String accessKey, + @NotBlank String secretKey, + @NotBlank String region +) {} diff --git a/src/main/java/com/krylov/refound/config/S3ClientConfig.java b/src/main/java/com/krylov/refound/config/S3ClientConfig.java new file mode 100644 index 0000000..4926e34 --- /dev/null +++ b/src/main/java/com/krylov/refound/config/S3ClientConfig.java @@ -0,0 +1,33 @@ +package com.krylov.refound.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +import java.net.URI; + +@Configuration +@RequiredArgsConstructor +public class S3ClientConfig { + private final MinioProperties properties; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .endpointOverride(URI.create(properties.endpoint())) + .region(Region.of(properties.region())) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(properties.accessKey(), properties.secretKey()))) + // ВАЖНО для MinIO: path-style вместо virtual-hosted style + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build()) + .build(); + } + +} diff --git a/src/main/java/com/krylov/refound/controller/FileController.java b/src/main/java/com/krylov/refound/controller/FileController.java deleted file mode 100644 index ef0a7f8..0000000 --- a/src/main/java/com/krylov/refound/controller/FileController.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.krylov.refound.controller; - -import com.krylov.refound.service.FileStorageService; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping("/api/v1/files") -@RequiredArgsConstructor -public class FileController { - private final FileStorageService storageService; - - @PostMapping - public String upload(@RequestParam("file") MultipartFile file) { - return storageService.save(file); - } -} diff --git a/src/main/java/com/krylov/refound/controller/FileStorageController.java b/src/main/java/com/krylov/refound/controller/FileStorageController.java new file mode 100644 index 0000000..4c04a55 --- /dev/null +++ b/src/main/java/com/krylov/refound/controller/FileStorageController.java @@ -0,0 +1,45 @@ +package com.krylov.refound.controller; + +import com.krylov.refound.service.FileStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriUtils; + +import java.nio.charset.StandardCharsets; + +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +public class FileStorageController { + private final FileStorageService fileStorageService; + + @PostMapping + public ResponseEntity uploadFile(@RequestParam("file") MultipartFile file) { + String objectName = fileStorageService.uploadFile(file); + return ResponseEntity.ok(objectName); // фронт сохранит objectName как ключ картинки + } + + @GetMapping("/{objectName}") + public ResponseEntity downloadFile(@PathVariable String objectName) { + FileStorageService.DownloadedFile downloadedFile = fileStorageService.downloadFile(objectName); + + String encodedFilename = UriUtils.encode(downloadedFile.originalFilename(), StandardCharsets.UTF_8); + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename); + + return ResponseEntity.ok() + .headers(headers) + .contentType(MediaType.parseMediaType(downloadedFile.contentType())) + .body(downloadedFile.content()); + } + + @DeleteMapping("/{objectName}") + public ResponseEntity deleteFile(@PathVariable String objectName) { + fileStorageService.deleteFile(objectName); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/krylov/refound/health/StorageStructureService.java b/src/main/java/com/krylov/refound/health/StorageStructureService.java new file mode 100644 index 0000000..2d63b6a --- /dev/null +++ b/src/main/java/com/krylov/refound/health/StorageStructureService.java @@ -0,0 +1,118 @@ +package com.krylov.refound.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import com.krylov.refound.config.MinioProperties; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.BucketLifecycleConfiguration; +import software.amazon.awssdk.services.s3.model.BucketVersioningStatus; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.ExpirationStatus; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.LifecycleRule; +import software.amazon.awssdk.services.s3.model.NoSuchBucketException; +import software.amazon.awssdk.services.s3.model.PutBucketLifecycleConfigurationRequest; +import software.amazon.awssdk.services.s3.model.PutBucketVersioningRequest; +import software.amazon.awssdk.services.s3.model.VersioningConfiguration; + +import java.util.List; + +/** + * Сервис для управления структурой хранилища в MinIO. + * Отвечает за создание необходимого бакета при запуске приложения. + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class StorageStructureService { + + private final S3Client s3Client; + private final MinioProperties properties; + + /** + * Инициализирует структуру хранилища при запуске приложения. + * Вызывает метод createBuckets(). + */ + @EventListener(ContextRefreshedEvent.class) + public void init() { + createBuckets(); + } + + /** + * Создает основной бакет в MinIO, если он еще не существует. + */ + public void createBuckets() { + log.info("Инициализация основного бакета MinIO..."); + String mainBucket = properties.bucket(); + createBucketIfNotExists(mainBucket); + log.info("Проверка и создание основного бакета завершены."); + + try { + log.info("Включение версионирования для бакета '{}'...", mainBucket); + VersioningConfiguration versioningConfiguration = VersioningConfiguration.builder() + .status(BucketVersioningStatus.ENABLED) + .build(); + + PutBucketVersioningRequest request = PutBucketVersioningRequest.builder() + .bucket(mainBucket) + .versioningConfiguration(versioningConfiguration) + .build(); + + s3Client.putBucketVersioning(request); + log.info("Версионирование для бакета '{}' успешно включено.", mainBucket); + } catch (Exception e) { + log.error("Не удалось включить версионирование для бакета '{}': {}", mainBucket, e.getMessage(), e); + } + + try { + log.info("Установка политики жизненного цикла для бакета '{}'...", mainBucket); + + LifecycleRule rule = LifecycleRule.builder() + .id("delete-after-one-day-rule") + .status(ExpirationStatus.ENABLED) + .filter(f -> f.prefix("")) + .expiration(exp -> exp.days(1)) + .build(); + + BucketLifecycleConfiguration lifecycleConfig = BucketLifecycleConfiguration.builder() + .rules(List.of(rule)) + .build(); + + PutBucketLifecycleConfigurationRequest request = PutBucketLifecycleConfigurationRequest.builder() + .bucket(mainBucket) + .lifecycleConfiguration(lifecycleConfig) + .build(); + + s3Client.putBucketLifecycleConfiguration(request); + log.info("Политика жизненного цикла для бакета '{}' успешно установлена.", mainBucket); + } catch (Exception e) { + log.error("Не удалось установить политику жизненного цикла для бакета '{}': {}", mainBucket, e.getMessage(), e); + } + } + + /** + * Проверяет существование бакета в MinIO и создает его, если он отсутствует. + * + * @param bucketName Имя бакета для проверки и создания. + */ + private void createBucketIfNotExists(String bucketName) { + try { + log.debug("Проверка существования бакета: {}", bucketName); + s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); + log.info("Бакет '{}' уже существует.", bucketName); + } catch (NoSuchBucketException e) { + log.warn("Бакет '{}' не найден. Попытка создания...", bucketName); + try { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + log.info("Бакет '{}' успешно создан.", bucketName); + } catch (Exception ex) { + log.error("Ошибка при создании бакета '{}': {}", bucketName, ex.getMessage(), ex); + } + } catch (Exception e) { + log.error("Ошибка при проверке бакета '{}': {}", bucketName, e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/security/SecurityConfig.java b/src/main/java/com/krylov/refound/security/SecurityConfig.java index 59f1063..136e93e 100644 --- a/src/main/java/com/krylov/refound/security/SecurityConfig.java +++ b/src/main/java/com/krylov/refound/security/SecurityConfig.java @@ -3,6 +3,7 @@ package com.krylov.refound.security; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -28,6 +29,9 @@ public class SecurityConfig { // Public endpoints .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/test/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/files/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/files/**").permitAll() + .requestMatchers(HttpMethod.DELETE, "/api/v1/files/**").permitAll() .requestMatchers(org.springframework.http.HttpMethod.GET, "/api/v1/posts/**").permitAll() .requestMatchers("/api/v1/reviews/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll() diff --git a/src/main/java/com/krylov/refound/service/FileStorageService.java b/src/main/java/com/krylov/refound/service/FileStorageService.java index fb19de3..03d0739 100644 --- a/src/main/java/com/krylov/refound/service/FileStorageService.java +++ b/src/main/java/com/krylov/refound/service/FileStorageService.java @@ -1,42 +1,140 @@ package com.krylov.refound.service; -import com.krylov.refound.enums.ErrorCode; -import com.krylov.refound.exception.ApiException; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.UUID; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; +import com.krylov.refound.config.MinioProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.IOException; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; @Service +@Slf4j +@RequiredArgsConstructor public class FileStorageService { - @Value("${file.upload-dir}") - private String uploadDir; + private final S3Client s3Client; + private final MinioProperties properties; - public String save(MultipartFile file) { - try { - if (file.isEmpty()) { - throw new RuntimeException("File is empty"); - } + /** + * DTO для передачи данных скачанного файла. + * @param originalFilename Оригинальное имя файла. + * @param contentType MIME-тип файла. + * @param content Содержимое файла в виде массива байт. + */ + public record DownloadedFile( + String originalFilename, + String contentType, + byte[] content + ) {} - String fileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + /** + * Загружает файл в MinIO, сохраняя оригинальное имя в метаданных, + * и возвращает его уникальное имя (включая расширение). + * + * @param file Загружаемый файл. + * @return Сгенерированное уникальное имя файла. + */ + public String uploadFile(MultipartFile file) { + try { + if (file.isEmpty()) { + log.error("Попытка загрузить пустой файл."); + throw new RuntimeException("Файл не может быть пустым."); + } - Path path = Paths.get(uploadDir).toAbsolutePath().normalize(); - Files.createDirectories(path); + String originalFilename = file.getOriginalFilename(); + String extension = ""; + if (originalFilename != null && originalFilename.contains(".")) { + extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + } - Path target = path.resolve(fileName); + String objectName = UUID.randomUUID().toString() + extension; + log.info("Загрузка файла '{}'. Новое имя: {}", originalFilename, objectName); - Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING); + Map metadata = new HashMap<>(); + metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)); - return "/files/" + fileName; // URL - } catch (IOException e) { - throw new ApiException(ErrorCode.BAD_REQUEST, "Only image files are allowed", HttpStatus.BAD_REQUEST); + PutObjectRequest request = PutObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .contentType(file.getContentType()) + .metadata(metadata) + .build(); + + s3Client.putObject(request, RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + log.info("Файл '{}' успешно загружен.", objectName); + return objectName; + } catch (IOException e) { + log.error("Ошибка при чтении файла для загрузки: {}", e.getMessage()); + throw new RuntimeException("Ошибка при обработке файла.", e); + } catch (Exception e) { + log.error("Не удалось загрузить файл: {}", e.getMessage()); + throw new RuntimeException("Ошибка при загрузке файла.", e); + } } - } -} + + /** + * Скачивает файл из MinIO. + * + * @param objectName Уникальное имя файла в хранилище. + * @return DTO с содержимым файла и его метаданными. + */ + public DownloadedFile downloadFile(String objectName) { + try { + // 1. Получаем метаданные объекта, чтобы извлечь оригинальное имя + HeadObjectRequest headRequest = HeadObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .build(); + HeadObjectResponse headResponse = s3Client.headObject(headRequest); + // Декодируем имя файла из метаданных + String originalFilename = URLDecoder.decode(headResponse.metadata().get("original-filename"), StandardCharsets.UTF_8); + String contentType = headResponse.contentType(); + + // 2. Получаем сам объект (его содержимое) + GetObjectRequest getRequest = GetObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .build(); + ResponseBytes responseBytes = s3Client.getObjectAsBytes(getRequest); + + log.info("Файл '{}' (оригинальное имя: '{}') успешно скачан.", objectName, originalFilename); + return new DownloadedFile(originalFilename, contentType, responseBytes.asByteArray()); + + } catch (Exception e) { + log.error("Ошибка при скачивании файла '{}': {}", objectName, e.getMessage()); + throw new RuntimeException("Ошибка при скачивании файла.", e); + } + } + + /** + * Удаляет файл из MinIO по его имени. + * + * @param objectName Имя файла для удаления. + */ + public void deleteFile(String objectName) { + try { + log.info("Запрос на удаление файла: {}", objectName); + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(properties.bucket()) + .key(objectName) + .build(); + + s3Client.deleteObject(request); + log.info("Файл '{}' успешно удален.", objectName); + } catch (Exception e) { + log.error("Ошибка при удалении файла '{}': {}", objectName, e.getMessage()); + throw new RuntimeException("Ошибка при удалении файла.", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/krylov/refound/service/PostService.java b/src/main/java/com/krylov/refound/service/PostService.java index bb5a8bb..28285d1 100644 --- a/src/main/java/com/krylov/refound/service/PostService.java +++ b/src/main/java/com/krylov/refound/service/PostService.java @@ -47,43 +47,30 @@ public class PostService { private final NominatimService nominatimService; private final UserService userService; private final FavoriteRepository favoriteRepository; + private final FileStorageService fileStorageService; public PostRequest create(PostRequest request, List images) { Post post = mapper.toEntity(request); post.setCreatedAt(LocalDateTime.now()); post.setStatus(PostStatus.ACTIVE); - post.setUser(userService.getCurrentUser()); // присваиваем текущего пользователя - repository.save(post); // сначала сохраняем post, чтобы получить id + post.setUser(userService.getCurrentUser()); + repository.save(post); if (images != null && !images.isEmpty()) { - Path uploadDir = Paths.get("uploads"); - try { - Files.createDirectories(uploadDir); - } catch (IOException e) { - throw new RuntimeException("Не удалось создать директорию загрузки", e); - } - for (MultipartFile image : images) { if (image == null || image.isEmpty()) { continue; } - String filename = UUID.randomUUID() + "_" + image.getOriginalFilename(); - - try { - Files.copy(image.getInputStream(), uploadDir.resolve(filename)); - } catch (IOException e) { - throw new RuntimeException("Не удалось сохранить файл: " + filename, e); - } + String objectKey = fileStorageService.uploadFile(image); Image img = new Image(); - img.setUrl("/uploads/" + filename); + img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске img.setPost(post); imageRepository.save(img); } } - return request; } @@ -187,43 +174,30 @@ public class PostService { List keepUrls = existingUrls != null ? existingUrls : List.of(); - // удаляем те картинки, которых нет среди "оставленных" с фронта List toRemove = post.getImages().stream() .filter(img -> !keepUrls.contains(img.getUrl())) .toList(); toRemove.forEach(img -> { try { - Files.deleteIfExists(Paths.get("." + img.getUrl())); - } catch (IOException e) { - log.warn("Не удалось удалить файл: {}", img.getUrl()); + fileStorageService.deleteFile(img.getUrl()); + } catch (Exception e) { + log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e); } }); - post.getImages().removeAll(toRemove); // orphanRemoval = true удалит из БД + post.getImages().removeAll(toRemove); - // добавляем новые загруженные файлы if (images != null && !images.isEmpty()) { - Path uploadDir = Paths.get("uploads"); - try { - Files.createDirectories(uploadDir); - } catch (IOException e) { - throw new RuntimeException("Не удалось создать директорию загрузки", e); - } - for (MultipartFile image : images) { if (image == null || image.isEmpty()) { continue; } - String filename = UUID.randomUUID() + "_" + image.getOriginalFilename(); - try { - Files.copy(image.getInputStream(), uploadDir.resolve(filename)); - } catch (IOException e) { - throw new RuntimeException("Не удалось сохранить файл: " + filename, e); - } + + String objectKey = fileStorageService.uploadFile(image); Image img = new Image(); - img.setUrl("/uploads/" + filename); + img.setUrl(objectKey); img.setPost(post); post.getImages().add(img); } @@ -235,7 +209,6 @@ public class PostService { @CacheEvict(value = {"posts", "post"}, allEntries = true) public void delete(Long id) { - Post post = repository.findById(id) .orElseThrow(() -> new ApiException( ErrorCode.NOT_FOUND, @@ -243,6 +216,15 @@ public class PostService { HttpStatus.NOT_FOUND )); + // без этого файлы останутся в MinIO мусором после удаления объявления + post.getImages().forEach(img -> { + try { + fileStorageService.deleteFile(img.getUrl()); + } catch (Exception e) { + log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e); + } + }); + repository.delete(post); } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index dbc314c..11b7867 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -29,6 +29,13 @@ spring: liquibase: change-log: classpath:db/changelog/db.changelog-master.yaml +minio: + endpoint: http://localhost:9010 + bucket: refound-images + access-key: 59SWvKyRv7VZh4XT0p0H + secret-key: uRIPejW6DwSTwdICwxXRA71VuZAAVE9EmHBq7B7K + region: us-east-1 # MinIO не проверяет регион, но SDK требует значение + file: upload-dir: uploads/