diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index b549ff8..5be1b24 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -125,6 +125,18 @@ jobs: docker build -t "$FRONTEND_IMAGE" ./frontend docker push "$FRONTEND_IMAGE" + - name: Cleanup Docker on runner (prevent vdisk growth) + if: always() + shell: bash + run: | + set +e + + # Keep recent artifacts, drop old local residue from CI builds. + docker container prune -f --filter "until=168h" || true + docker image prune -a -f --filter "until=168h" || true + docker builder prune -a -f --filter "until=168h" || true + docker network prune -f --filter "until=168h" || true + deploy: needs: build-and-push runs-on: ubuntu-latest diff --git a/backend/build.gradle b/backend/build.gradle index d54f8b3..2fa95cd 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.jsoup:jsoup:1.18.3' implementation platform('org.lwjgl:lwjgl-bom:3.3.4') implementation 'org.lwjgl:lwjgl' implementation 'org.lwjgl:lwjgl-assimp' diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java index 7a3d262..f562a92 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -22,6 +22,9 @@ import com.printcalculator.service.SlicerService; import com.printcalculator.service.media.PublicMediaQueryService; import com.printcalculator.service.shop.ShopStorageService; import com.printcalculator.service.storage.ClamAVService; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.safety.Safelist; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -64,6 +67,10 @@ public class AdminShopProductControllerService { private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); + private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none() + .addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a") + .addAttributes("a", "href") + .addProtocols("a", "href", "http", "https", "mailto", "tel"); private final ShopProductRepository shopProductRepository; private final ShopCategoryRepository shopCategoryRepository; @@ -613,17 +620,17 @@ public class AdminShopProductControllerService { excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); String fallbackDescription = firstNonBlank( - normalizeOptional(payload.getDescription()), - normalizeOptional(payload.getDescriptionIt()), - normalizeOptional(payload.getDescriptionEn()), - normalizeOptional(payload.getDescriptionDe()), - normalizeOptional(payload.getDescriptionFr()) + normalizeRichTextOptional(payload.getDescription()), + normalizeRichTextOptional(payload.getDescriptionIt()), + normalizeRichTextOptional(payload.getDescriptionEn()), + normalizeRichTextOptional(payload.getDescriptionDe()), + normalizeRichTextOptional(payload.getDescriptionFr()) ); Map descriptions = new LinkedHashMap<>(); - descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); - descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); - descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); - descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + descriptions.put("it", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionIt()), fallbackDescription)); + descriptions.put("en", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionEn()), fallbackDescription)); + descriptions.put("de", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionDe()), fallbackDescription)); + descriptions.put("fr", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionFr()), fallbackDescription)); String fallbackSeoTitle = firstNonBlank( normalizeOptional(payload.getSeoTitle()), @@ -689,6 +696,27 @@ public class AdminShopProductControllerService { return normalized.isBlank() ? null : normalized; } + private String normalizeRichTextOptional(String value) { + String normalized = normalizeOptional(value); + if (normalized == null) { + return null; + } + + String sanitized = Jsoup.clean( + normalized, + "", + PRODUCT_DESCRIPTION_SAFELIST, + new Document.OutputSettings().prettyPrint(false) + ).trim(); + + if (sanitized.isBlank()) { + return null; + } + + String plainText = Jsoup.parse(sanitized).text(); + return plainText != null && !plainText.trim().isEmpty() ? sanitized : null; + } + private String firstNonBlank(String... values) { if (values == null) { return null; diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 9323706..e37c450 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -9,10 +9,12 @@ import com.printcalculator.dto.ShopProductDetailDto; import com.printcalculator.dto.ShopProductModelDto; import com.printcalculator.dto.ShopProductSummaryDto; import com.printcalculator.dto.ShopProductVariantOptionDto; +import com.printcalculator.entity.FilamentVariant; import com.printcalculator.entity.ShopCategory; import com.printcalculator.entity.ShopProduct; import com.printcalculator.entity.ShopProductModelAsset; import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.FilamentVariantRepository; import com.printcalculator.repository.ShopCategoryRepository; import com.printcalculator.repository.ShopProductModelAssetRepository; import com.printcalculator.repository.ShopProductRepository; @@ -31,6 +33,7 @@ import java.util.Collection; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -46,6 +49,7 @@ public class PublicShopCatalogService { private final ShopProductRepository shopProductRepository; private final ShopProductVariantRepository shopProductVariantRepository; private final ShopProductModelAssetRepository shopProductModelAssetRepository; + private final FilamentVariantRepository filamentVariantRepository; private final PublicMediaQueryService publicMediaQueryService; private final ShopStorageService shopStorageService; @@ -53,12 +57,14 @@ public class PublicShopCatalogService { ShopProductRepository shopProductRepository, ShopProductVariantRepository shopProductVariantRepository, ShopProductModelAssetRepository shopProductModelAssetRepository, + FilamentVariantRepository filamentVariantRepository, PublicMediaQueryService publicMediaQueryService, ShopStorageService shopStorageService) { this.shopCategoryRepository = shopCategoryRepository; this.shopProductRepository = shopProductRepository; this.shopProductVariantRepository = shopProductVariantRepository; this.shopProductModelAssetRepository = shopProductModelAssetRepository; + this.filamentVariantRepository = filamentVariantRepository; this.publicMediaQueryService = publicMediaQueryService; this.shopStorageService = shopStorageService; } @@ -99,7 +105,12 @@ public class PublicShopCatalogService { List products = productContext.entries().stream() .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) - .map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug(), language)) + .map(entry -> toProductSummaryDto( + entry, + productContext.productMediaBySlug(), + productContext.variantColorHexByMaterialAndColor(), + language + )) .toList(); ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null @@ -128,7 +139,12 @@ public class PublicShopCatalogService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); } - return toProductDetailDto(entry, productContext.productMediaBySlug(), language); + return toProductDetailDto( + entry, + productContext.productMediaBySlug(), + productContext.variantColorHexByMaterialAndColor(), + language + ); } public ProductModelDownload getProductModelDownload(String slug) { @@ -187,11 +203,32 @@ public class PublicShopCatalogService { entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(), language ); + Map variantColorHexByMaterialAndColor = buildFilamentVariantColorHexMap(); Map entriesBySlug = entries.stream() .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); - return new PublicProductContext(entries, entriesBySlug, productMediaBySlug); + return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor); + } + + private Map buildFilamentVariantColorHexMap() { + Map colorsByMaterialAndColor = new LinkedHashMap<>(); + for (FilamentVariant variant : filamentVariantRepository.findByIsActiveTrue()) { + String materialCode = variant.getFilamentMaterialType() != null + ? variant.getFilamentMaterialType().getMaterialCode() + : null; + String key = toMaterialAndColorKey(materialCode, variant.getColorName()); + if (key == null) { + continue; + } + + String colorHex = trimToNull(variant.getColorHex()); + if (colorHex == null) { + continue; + } + colorsByMaterialAndColor.putIfAbsent(key, colorHex); + } + return colorsByMaterialAndColor; } private List loadPublicProducts(Collection activeCategoryIds) { @@ -349,6 +386,7 @@ public class PublicShopCatalogService { private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, Map> productMediaBySlug, + Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); return new ShopProductSummaryDto( @@ -365,7 +403,7 @@ public class PublicShopCatalogService { ), resolvePriceFrom(entry.variants()), resolvePriceTo(entry.variants()), - toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), selectPrimaryMedia(images), toProductModelDto(entry) ); @@ -373,6 +411,7 @@ public class PublicShopCatalogService { private ShopProductDetailDto toProductDetailDto(ProductEntry entry, Map> productMediaBySlug, + Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); @@ -398,9 +437,9 @@ public class PublicShopCatalogService { buildCategoryBreadcrumbs(entry.product().getCategory()), resolvePriceFrom(entry.variants()), resolvePriceTo(entry.variants()), - toVariantDto(entry.defaultVariant(), entry.defaultVariant()), + toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), entry.variants().stream() - .map(variant -> toVariantDto(variant, entry.defaultVariant())) + .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor)) .toList(), selectPrimaryMedia(images), images, @@ -408,21 +447,61 @@ public class PublicShopCatalogService { ); } - private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant) { + private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, + ShopProductVariant defaultVariant, + Map variantColorHexByMaterialAndColor) { if (variant == null) { return null; } + String colorHex = trimToNull(variant.getColorHex()); + if (colorHex == null) { + String key = toMaterialAndColorKey(variant.getInternalMaterialCode(), variant.getColorName()); + colorHex = key != null ? variantColorHexByMaterialAndColor.get(key) : null; + } return new ShopProductVariantOptionDto( variant.getId(), variant.getSku(), variant.getVariantLabel(), variant.getColorName(), - variant.getColorHex(), + colorHex, variant.getPriceChf(), defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) ); } + private String toMaterialAndColorKey(String materialCode, String colorName) { + String normalizedMaterialCode = normalizeMaterialCode(materialCode); + String normalizedColorName = normalizeColorName(colorName); + if (normalizedMaterialCode == null || normalizedColorName == null) { + return null; + } + return normalizedMaterialCode + "|" + normalizedColorName; + } + + private String normalizeMaterialCode(String materialCode) { + String raw = trimToNull(materialCode); + if (raw == null) { + return null; + } + return raw.toUpperCase(Locale.ROOT); + } + + private String normalizeColorName(String colorName) { + String raw = trimToNull(colorName); + if (raw == null) { + return null; + } + return raw.toLowerCase(Locale.ROOT); + } + + private String trimToNull(String value) { + String raw = String.valueOf(value == null ? "" : value).trim(); + if (raw.isEmpty()) { + return null; + } + return raw; + } + private ShopProductModelDto toProductModelDto(ProductEntry entry) { if (entry.modelAsset() == null) { return null; @@ -494,7 +573,8 @@ public class PublicShopCatalogService { private record PublicProductContext( List entries, Map entriesBySlug, - Map> productMediaBySlug + Map> productMediaBySlug, + Map variantColorHexByMaterialAndColor ) { } diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.html b/frontend/src/app/features/admin/pages/admin-shop.component.html index 6cbe2cb..039c9d7 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.html +++ b/frontend/src/app/features/admin/pages/admin-shop.component.html @@ -635,17 +635,90 @@ /> -