diff --git a/pom.xml b/pom.xml
index d57a722..b4d9983 100644
--- a/pom.xml
+++ b/pom.xml
@@ -172,6 +172,11 @@
RELEASE
compile
+
+ io.minio
+ minio
+ 8.4.3
+
diff --git a/src/main/java/group/goforward/battlbuilder/configuration/MinioConfig.java b/src/main/java/group/goforward/battlbuilder/configuration/MinioConfig.java
new file mode 100644
index 0000000..0e99720
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/configuration/MinioConfig.java
@@ -0,0 +1,22 @@
+package group.goforward.battlbuilder.configuration;
+
+import io.minio.MinioClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MinioConfig {
+
+ @Bean
+ public MinioClient minioClient(
+ @Value("${minio.endpoint}") String endpoint,
+ @Value("${minio.access-key}") String accessKey,
+ @Value("${minio.secret-key}") String secretKey
+ ) {
+ return MinioClient.builder()
+ .endpoint(endpoint)
+ .credentials(accessKey, secretKey)
+ .build();
+ }
+}
diff --git a/src/main/java/group/goforward/battlbuilder/model/Product.java b/src/main/java/group/goforward/battlbuilder/model/Product.java
index 63208e6..e9c9834 100644
--- a/src/main/java/group/goforward/battlbuilder/model/Product.java
+++ b/src/main/java/group/goforward/battlbuilder/model/Product.java
@@ -74,6 +74,9 @@ public class Product {
@Column(name = "main_image_url")
private String mainImageUrl;
+ @Column(name = "battl_image_url")
+ private String battlImageUrl;
+
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@@ -158,6 +161,9 @@ public class Product {
this.mainImageUrl = mainImageUrl;
}
+ public String getBattlImageUrl() {return battlImageUrl; }
+
+ public void setBattlImageUrl(String battlImageUrl) {this.battlImageUrl = battlImageUrl; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
diff --git a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
index 0bef0f9..d1dcca2 100644
--- a/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
+++ b/src/main/java/group/goforward/battlbuilder/repos/ProductRepository.java
@@ -1,12 +1,15 @@
package group.goforward.battlbuilder.repos;
+import aj.org.objectweb.asm.commons.Remapper;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.model.Product;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
-import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
@@ -230,4 +233,20 @@ public interface ProductRepository extends JpaRepository {
List findPendingMappingByMerchantId(@Param("merchantId") Integer merchantId);
+
+ Page findByDeletedAtIsNullAndMainImageUrlIsNotNullAndBattlImageUrlIsNull(Pageable pageable);
+
+ @Query("""
+ SELECT p
+ FROM Product p
+ WHERE p.deletedAt IS NULL
+ AND p.mainImageUrl IS NOT NULL
+ AND (
+ p.battlImageUrl IS NULL
+ OR TRIM(p.battlImageUrl) = ''
+ )
+ """)
+ Page findNeedingBattlImageUrlMigration(Pageable pageable);
+
+
}
diff --git a/src/main/java/group/goforward/battlbuilder/services/utils/ImageUrlToMinioMigrator.java b/src/main/java/group/goforward/battlbuilder/services/utils/ImageUrlToMinioMigrator.java
new file mode 100644
index 0000000..b50b098
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/services/utils/ImageUrlToMinioMigrator.java
@@ -0,0 +1,183 @@
+package group.goforward.battlbuilder.services.utils;
+
+import group.goforward.battlbuilder.model.Product;
+import group.goforward.battlbuilder.repos.ProductRepository;
+import io.minio.BucketExistsArgs;
+import io.minio.MakeBucketArgs;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Optional;
+
+@Service
+public class ImageUrlToMinioMigrator {
+
+ private final ProductRepository productRepository;
+ private final MinioClient minioClient;
+
+ private final String bucket;
+ private final String publicBaseUrl;
+
+ private final HttpClient httpClient = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(Duration.ofSeconds(15))
+ .build();
+
+ public ImageUrlToMinioMigrator(ProductRepository productRepository,
+ MinioClient minioClient,
+ @Value("${minio.bucket}") String bucket,
+ @Value("${minio.public-base-url}") String publicBaseUrl) {
+ this.productRepository = productRepository;
+ this.minioClient = minioClient;
+ this.bucket = bucket;
+ this.publicBaseUrl = trimTrailingSlash(publicBaseUrl);
+ }
+
+ /**
+ * Migrates Product.mainImageUrl URLs to MinIO and rewrites mainImageUrl to the new location.
+ *
+ * @param pageSize batch size for DB paging
+ * @param dryRun if true: download+upload is skipped and DB is not updated
+ * @param maxItems optional cap for safety (null = no cap)
+ * @return count of successfully migrated products
+ */
+ @Transactional
+ public int migrateMainImages(int pageSize, boolean dryRun, Integer maxItems) {
+ ensureBucketExists();
+
+ int migrated = 0;
+ int page = 0;
+
+ while (true) {
+ if (maxItems != null && migrated >= maxItems) break;
+
+ Page batch = productRepository
+ .findNeedingBattlImageUrlMigration(PageRequest.of(page, pageSize));
+ if (batch.isEmpty()) break;
+
+ for (Product p : batch.getContent()) {
+ if (maxItems != null && migrated >= maxItems) break;
+
+ String sourceUrl = p.getMainImageUrl();
+ if (sourceUrl == null || sourceUrl.isBlank()) continue;
+
+ // Extra safety: skip if already set (covers any edge cases outside the query)
+ if (p.getBattlImageUrl() != null && !p.getBattlImageUrl().trim().isEmpty()) continue;
+
+ try {
+ if (!dryRun) {
+ String newUrl = uploadFromUrlToMinio(p, sourceUrl);
+ p.setBattlImageUrl(newUrl);
+ productRepository.save(p);
+ }
+ migrated++;
+ } catch (Exception ex) {
+ // fail-soft: continue migrating other products
+ }
+ }
+
+ if (!batch.hasNext()) break;
+ page++;
+ }
+
+ return migrated;
+ }
+
+ private String uploadFromUrlToMinio(Product p, String sourceUrl) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(sourceUrl))
+ .timeout(Duration.ofSeconds(60))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ int status = response.statusCode();
+ if (status < 200 || status >= 300) {
+ throw new IllegalStateException("Failed to download image. HTTP " + status + " url=" + sourceUrl);
+ }
+
+ String contentType = response.headers()
+ .firstValue("content-type")
+ .map(v -> v.split(";", 2)[0].trim())
+ .orElse("application/octet-stream");
+
+ long contentLength = response.headers()
+ .firstValue("content-length")
+ .flatMap(ImageUrlToMinioMigrator::parseLongSafe)
+ .orElse(-1L);
+
+ String ext = extensionForContentType(contentType);
+
+ // Store under a stable key; adjust if you want per-merchant, hashed names, etc.
+ String objectName = "products/" + p.getId() + "/main" + ext;
+
+ try (InputStream in = response.body()) {
+ PutObjectArgs.Builder put = PutObjectArgs.builder()
+ .bucket(bucket)
+ .object(objectName)
+ .contentType(contentType);
+
+ if (contentLength >= 0) {
+ put.stream(in, contentLength, -1);
+ } else {
+ put.stream(in, -1, 10L * 1024 * 1024);
+ }
+
+ minioClient.putObject(put.build());
+ }
+
+ return publicBaseUrl + "/" + bucket + "/" + objectName;
+ }
+
+ private void ensureBucketExists() {
+ try {
+ boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
+ if (!exists) {
+ minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
+ }
+ } catch (Exception e) {
+ throw new IllegalStateException("Unable to ensure MinIO bucket exists: " + bucket, e);
+ }
+ }
+
+ private boolean looksAlreadyMigrated(String url) {
+ String prefix = publicBaseUrl + "/" + bucket + "/";
+ return url.startsWith(prefix);
+ }
+
+ private static Optional parseLongSafe(String v) {
+ try {
+ return Optional.of(Long.parseLong(v));
+ } catch (Exception e) {
+ return Optional.empty();
+ }
+ }
+
+ private static String extensionForContentType(String contentType) {
+ String ct = contentType.toLowerCase(Locale.ROOT);
+ if (ct.equals("image/jpeg") || ct.equals("image/jpg")) return ".jpg";
+ if (ct.equals("image/png")) return ".png";
+ if (ct.equals("image/webp")) return ".webp";
+ if (ct.equals("image/gif")) return ".gif";
+ if (ct.equals("image/svg+xml")) return ".svg";
+ return ".bin";
+ }
+
+ private static String trimTrailingSlash(String s) {
+ if (s == null) return "";
+ return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
+ }
+}
diff --git a/src/main/java/group/goforward/battlbuilder/services/utils/MigrateProductImagesToMinioRunner.java b/src/main/java/group/goforward/battlbuilder/services/utils/MigrateProductImagesToMinioRunner.java
new file mode 100644
index 0000000..8779e4d
--- /dev/null
+++ b/src/main/java/group/goforward/battlbuilder/services/utils/MigrateProductImagesToMinioRunner.java
@@ -0,0 +1,28 @@
+package group.goforward.battlbuilder.services.utils;
+
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Component;
+
+@Component
+@Profile("migrate-images-to-minio")
+public class MigrateProductImagesToMinioRunner implements CommandLineRunner {
+
+ private final ImageUrlToMinioMigrator migrator;
+
+ public MigrateProductImagesToMinioRunner(ImageUrlToMinioMigrator migrator) {
+ this.migrator = migrator;
+ }
+
+ @Override
+ public void run(String... args) {
+ // Tune as needed. Start small; you can remove maxItems once you're confident.
+ int migrated = migrator.migrateMainImages(
+ 200, // pageSize
+ false, // dryRun
+ 1000 // maxItems safety cap
+ );
+
+ System.out.println("Migrated product images: " + migrated);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 392ff66..80e4153 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -35,4 +35,14 @@ spring.mail.properties.mail.smtp.starttls.required=true
#Database settings
-spring.datasource.hikari.max-lifetime=600000
\ No newline at end of file
+spring.datasource.hikari.max-lifetime=600000
+
+
+minio.endpoint=https://minio.dev.gofwd.group
+minio.access-key=
+minio.secret-key=
+minio.bucket=battlbuilds
+
+# Public base URL used to write back into products.main_image_url
+minio.public-base-url=https://minio.dev.gofwd.group
+