From 12b3d2ae5071493435066b4c5a1011c13eb736be Mon Sep 17 00:00:00 2001 From: Don Strawsburg Date: Thu, 18 Dec 2025 11:19:51 -0500 Subject: [PATCH] added minio and fixed repository code Slave fucked up --- pom.xml | 5 + .../configuration/MinioConfig.java | 22 +++ .../goforward/battlbuilder/model/Product.java | 6 + .../battlbuilder/repos/ProductRepository.java | 21 +- .../utils/ImageUrlToMinioMigrator.java | 183 ++++++++++++++++++ .../MigrateProductImagesToMinioRunner.java | 28 +++ src/main/resources/application.properties | 12 +- 7 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 src/main/java/group/goforward/battlbuilder/configuration/MinioConfig.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/utils/ImageUrlToMinioMigrator.java create mode 100644 src/main/java/group/goforward/battlbuilder/services/utils/MigrateProductImagesToMinioRunner.java 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 +