fixed image locale storage on minio

This commit is contained in:
SlimusMinus
2026-07-10 01:27:30 +03:00
parent 712a5f1e2b
commit f371a442d0
12 changed files with 415 additions and 90 deletions

View File

@@ -24,6 +24,12 @@ repositories {
mavenCentral() mavenCentral()
} }
dependencyManagement {
imports {
mavenBom 'io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1'
}
}
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
@@ -34,6 +40,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.mapstruct:mapstruct:1.5.5.Final' 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 // JWT
implementation 'io.jsonwebtoken:jjwt-api:0.12.5' implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'

View File

@@ -13,4 +13,31 @@ services:
redis: redis:
image: redis:7 image: redis:7
ports: ports:
- "6379:6379" - "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:

View File

@@ -1,11 +1,18 @@
package com.krylov.refound; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(scanBasePackages = "com.krylov.refound") @SpringBootApplication(
@EnableScheduling scanBasePackages = "com.krylov.refound",
exclude = { S3AutoConfiguration.class }
)@EnableScheduling
@EnableConfigurationProperties(MinioProperties.class)
public class ReFoundApplication { public class ReFoundApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -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
) {}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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<String> uploadFile(@RequestParam("file") MultipartFile file) {
String objectName = fileStorageService.uploadFile(file);
return ResponseEntity.ok(objectName); // фронт сохранит objectName как ключ картинки
}
@GetMapping("/{objectName}")
public ResponseEntity<byte[]> 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<Void> deleteFile(@PathVariable String objectName) {
fileStorageService.deleteFile(objectName);
return ResponseEntity.noContent().build();
}
}

View File

@@ -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);
}
}
}

View File

@@ -3,6 +3,7 @@ package com.krylov.refound.security;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; 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.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
@@ -28,6 +29,9 @@ public class SecurityConfig {
// Public endpoints // Public endpoints
.requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/test/**").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(org.springframework.http.HttpMethod.GET, "/api/v1/posts/**").permitAll()
.requestMatchers("/api/v1/reviews/**").permitAll() .requestMatchers("/api/v1/reviews/**").permitAll()
.requestMatchers("/files/**", "/uploads/**").permitAll() .requestMatchers("/files/**", "/uploads/**").permitAll()

View File

@@ -1,42 +1,140 @@
package com.krylov.refound.service; package com.krylov.refound.service;
import com.krylov.refound.enums.ErrorCode; import com.krylov.refound.config.MinioProperties;
import com.krylov.refound.exception.ApiException; import lombok.RequiredArgsConstructor;
import java.io.IOException; import lombok.extern.slf4j.Slf4j;
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 org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; 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 @Service
@Slf4j
@RequiredArgsConstructor
public class FileStorageService { public class FileStorageService {
@Value("${file.upload-dir}") private final S3Client s3Client;
private String uploadDir; private final MinioProperties properties;
public String save(MultipartFile file) { /**
try { * DTO для передачи данных скачанного файла.
if (file.isEmpty()) { * @param originalFilename Оригинальное имя файла.
throw new RuntimeException("File is empty"); * @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(); String originalFilename = file.getOriginalFilename();
Files.createDirectories(path); 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<String, String> metadata = new HashMap<>();
metadata.put("original-filename", URLEncoder.encode(originalFilename, StandardCharsets.UTF_8));
return "/files/" + fileName; // URL PutObjectRequest request = PutObjectRequest.builder()
} catch (IOException e) { .bucket(properties.bucket())
throw new ApiException(ErrorCode.BAD_REQUEST, "Only image files are allowed", HttpStatus.BAD_REQUEST); .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<GetObjectResponse> 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);
}
}
}

View File

@@ -47,43 +47,30 @@ public class PostService {
private final NominatimService nominatimService; private final NominatimService nominatimService;
private final UserService userService; private final UserService userService;
private final FavoriteRepository favoriteRepository; private final FavoriteRepository favoriteRepository;
private final FileStorageService fileStorageService;
public PostRequest create(PostRequest request, List<MultipartFile> images) { public PostRequest create(PostRequest request, List<MultipartFile> images) {
Post post = mapper.toEntity(request); Post post = mapper.toEntity(request);
post.setCreatedAt(LocalDateTime.now()); post.setCreatedAt(LocalDateTime.now());
post.setStatus(PostStatus.ACTIVE); post.setStatus(PostStatus.ACTIVE);
post.setUser(userService.getCurrentUser()); // присваиваем текущего пользователя post.setUser(userService.getCurrentUser());
repository.save(post); // сначала сохраняем post, чтобы получить id repository.save(post);
if (images != null && !images.isEmpty()) { 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) { for (MultipartFile image : images) {
if (image == null || image.isEmpty()) { if (image == null || image.isEmpty()) {
continue; continue;
} }
String filename = UUID.randomUUID() + "_" + image.getOriginalFilename(); String objectKey = fileStorageService.uploadFile(image);
try {
Files.copy(image.getInputStream(), uploadDir.resolve(filename));
} catch (IOException e) {
throw new RuntimeException("Не удалось сохранить файл: " + filename, e);
}
Image img = new Image(); Image img = new Image();
img.setUrl("/uploads/" + filename); img.setUrl(objectKey); // храним ключ MinIO, а не путь на диске
img.setPost(post); img.setPost(post);
imageRepository.save(img); imageRepository.save(img);
} }
} }
return request; return request;
} }
@@ -187,43 +174,30 @@ public class PostService {
List<String> keepUrls = existingUrls != null ? existingUrls : List.of(); List<String> keepUrls = existingUrls != null ? existingUrls : List.of();
// удаляем те картинки, которых нет среди "оставленных" с фронта
List<Image> toRemove = post.getImages().stream() List<Image> toRemove = post.getImages().stream()
.filter(img -> !keepUrls.contains(img.getUrl())) .filter(img -> !keepUrls.contains(img.getUrl()))
.toList(); .toList();
toRemove.forEach(img -> { toRemove.forEach(img -> {
try { try {
Files.deleteIfExists(Paths.get("." + img.getUrl())); fileStorageService.deleteFile(img.getUrl());
} catch (IOException e) { } catch (Exception e) {
log.warn("Не удалось удалить файл: {}", img.getUrl()); log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e);
} }
}); });
post.getImages().removeAll(toRemove); // orphanRemoval = true удалит из БД post.getImages().removeAll(toRemove);
// добавляем новые загруженные файлы
if (images != null && !images.isEmpty()) { 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) { for (MultipartFile image : images) {
if (image == null || image.isEmpty()) { if (image == null || image.isEmpty()) {
continue; continue;
} }
String filename = UUID.randomUUID() + "_" + image.getOriginalFilename();
try { String objectKey = fileStorageService.uploadFile(image);
Files.copy(image.getInputStream(), uploadDir.resolve(filename));
} catch (IOException e) {
throw new RuntimeException("Не удалось сохранить файл: " + filename, e);
}
Image img = new Image(); Image img = new Image();
img.setUrl("/uploads/" + filename); img.setUrl(objectKey);
img.setPost(post); img.setPost(post);
post.getImages().add(img); post.getImages().add(img);
} }
@@ -235,7 +209,6 @@ public class PostService {
@CacheEvict(value = {"posts", "post"}, allEntries = true) @CacheEvict(value = {"posts", "post"}, allEntries = true)
public void delete(Long id) { public void delete(Long id) {
Post post = repository.findById(id) Post post = repository.findById(id)
.orElseThrow(() -> new ApiException( .orElseThrow(() -> new ApiException(
ErrorCode.NOT_FOUND, ErrorCode.NOT_FOUND,
@@ -243,6 +216,15 @@ public class PostService {
HttpStatus.NOT_FOUND HttpStatus.NOT_FOUND
)); ));
// без этого файлы останутся в MinIO мусором после удаления объявления
post.getImages().forEach(img -> {
try {
fileStorageService.deleteFile(img.getUrl());
} catch (Exception e) {
log.warn("Не удалось удалить файл из хранилища: {}", img.getUrl(), e);
}
});
repository.delete(post); repository.delete(post);
} }

View File

@@ -29,6 +29,13 @@ spring:
liquibase: liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml 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: file:
upload-dir: uploads/ upload-dir: uploads/