diff --git a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/ImageUploadProperties.java b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/ImageUploadProperties.java index 9a0760bd..8409e3b8 100644 --- a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/ImageUploadProperties.java +++ b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/ImageUploadProperties.java @@ -11,7 +11,9 @@ public class ImageUploadProperties { private long maxUploadBytes; private int maxPerUser; private int maxPerDay; + private int ticketTtlMinutes; private List allowedTypes; + private List externalAllowedHosts; public long getMaxUploadBytes() { return maxUploadBytes; @@ -37,6 +39,14 @@ public void setMaxPerDay(int maxPerDay) { this.maxPerDay = maxPerDay; } + public int getTicketTtlMinutes() { + return ticketTtlMinutes; + } + + public void setTicketTtlMinutes(int ticketTtlMinutes) { + this.ticketTtlMinutes = ticketTtlMinutes; + } + public List getAllowedTypes() { return allowedTypes; } @@ -44,4 +54,12 @@ public List getAllowedTypes() { public void setAllowedTypes(List allowedTypes) { this.allowedTypes = allowedTypes; } + + public List getExternalAllowedHosts() { + return externalAllowedHosts; + } + + public void setExternalAllowedHosts(List externalAllowedHosts) { + this.externalAllowedHosts = externalAllowedHosts; + } } diff --git a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageService.java b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageService.java index f4cb8bc3..2e4b0da0 100644 --- a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageService.java +++ b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageService.java @@ -2,9 +2,11 @@ import com.mealflow.appapi.recipes.domain.Recipe; import com.mealflow.appapi.recipes.repository.RecipeRepository; -import java.nio.file.Paths; +import java.net.URI; import java.time.Clock; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; import java.util.Optional; import org.slf4j.Logger; @@ -19,20 +21,26 @@ public class RecipeImageService { private final RecipeRepository recipes; private final RecipeImageQuotaRepository quotaRepository; + private final RecipeImageUploadTicketRepository ticketRepository; private final ImageUploadProperties uploadProps; private final ImageKitClient imageKitClient; + private final ImageKitProperties imageKitProperties; private final Clock clock; public RecipeImageService( RecipeRepository recipes, RecipeImageQuotaRepository quotaRepository, + RecipeImageUploadTicketRepository ticketRepository, ImageUploadProperties uploadProps, ImageKitClient imageKitClient, + ImageKitProperties imageKitProperties, Clock clock) { this.recipes = recipes; this.quotaRepository = quotaRepository; + this.ticketRepository = ticketRepository; this.uploadProps = uploadProps; this.imageKitClient = imageKitClient; + this.imageKitProperties = imageKitProperties; this.clock = clock; } @@ -54,10 +62,64 @@ public ImageKitUploadResult upload(String userId, MultipartFile file, String rec String fileName = buildFilename(userId, file); ImageKitUploadResult result = imageKitClient.upload(file, fileName, userId); + ticketRepository.save( + new RecipeImageUploadTicket(userId, result.url(), result.fileId(), clock.instant(), false)); + incrementDailyQuota(userId); return result; } + public ImageKitUploadResult consumeTicket(String userId, String imageFileId) { + if (imageFileId == null || imageFileId.isBlank()) { + return null; + } + RecipeImageUploadTicket ticket = ticketRepository + .findFirstByUserIdAndImageFileIdAndConsumedFalseOrderByCreatedAtDesc(userId, imageFileId) + .orElse(null); + if (ticket == null) { + return null; + } + + int ttlMinutes = uploadProps.getTicketTtlMinutes(); + if (ttlMinutes > 0 && ticket.getCreatedAt().isBefore(clock.instant().minusSeconds(ttlMinutes * 60L))) { + return null; + } + + ticket.setConsumed(true); + ticketRepository.save(ticket); + return new ImageKitUploadResult(ticket.getImageUrl(), ticket.getImageFileId(), null, null); + } + + public java.util.List getExternalAllowedHosts() { + List base = uploadProps.getExternalAllowedHosts(); + String imageKitHost = resolveImageKitHost(); + if (imageKitHost == null) { + return base; + } + if (base == null || base.isEmpty()) { + return List.of(imageKitHost); + } + boolean exists = base.stream().anyMatch(host -> host.equalsIgnoreCase(imageKitHost)); + if (exists) { + return base; + } + List merged = new ArrayList<>(base); + merged.add(imageKitHost); + return merged; + } + + private String resolveImageKitHost() { + String endpoint = imageKitProperties.getUrlEndpoint(); + if (endpoint == null || endpoint.isBlank()) { + return null; + } + try { + return URI.create(endpoint).getHost(); + } catch (Exception ex) { + return null; + } + } + public void deleteByFileId(String fileId) { if (fileId == null || fileId.isBlank()) { return; @@ -76,7 +138,7 @@ private void validateFile(MultipartFile file) { long maxBytes = uploadProps.getMaxUploadBytes(); if (maxBytes > 0 && file.getSize() > maxBytes) { - throw new ImageUploadValidationException("Image is too large. Max size is 5MB."); + throw new ImageUploadValidationException("Image is too large. Max size is 10MB."); } String contentType = @@ -84,8 +146,74 @@ private void validateFile(MultipartFile file) { if (uploadProps.getAllowedTypes() != null && !uploadProps.getAllowedTypes().isEmpty() && !uploadProps.getAllowedTypes().contains(contentType)) { - throw new ImageUploadValidationException("Unsupported image type. Please use JPG, PNG, or WEBP."); + throw new ImageUploadValidationException("Unsupported image type. Please use JPG, PNG, WEBP, or HEIC."); } + + validateMagicBytes(file); + } + + private void validateMagicBytes(MultipartFile file) { + try { + byte[] head = new byte[12]; + try (var stream = file.getInputStream()) { + int read = stream.read(head); + if (read < 12) { + throw new ImageUploadValidationException( + "Unsupported image type. Please use JPG, PNG, WEBP, or HEIC."); + } + } + + if (looksLikeJpeg(head) || looksLikePng(head) || looksLikeWebp(head) || looksLikeHeic(head)) { + return; + } + } catch (ImageUploadValidationException ex) { + throw ex; + } catch (Exception ex) { + throw new ImageUploadValidationException("Could not read uploaded image."); + } + + throw new ImageUploadValidationException("Unsupported image type. Please use JPG, PNG, WEBP, or HEIC."); + } + + private boolean looksLikeJpeg(byte[] bytes) { + return (bytes[0] & 0xFF) == 0xFF && (bytes[1] & 0xFF) == 0xD8; + } + + private boolean looksLikePng(byte[] bytes) { + return (bytes[0] & 0xFF) == 0x89 + && (bytes[1] & 0xFF) == 0x50 + && (bytes[2] & 0xFF) == 0x4E + && (bytes[3] & 0xFF) == 0x47 + && (bytes[4] & 0xFF) == 0x0D + && (bytes[5] & 0xFF) == 0x0A + && (bytes[6] & 0xFF) == 0x1A + && (bytes[7] & 0xFF) == 0x0A; + } + + private boolean looksLikeWebp(byte[] bytes) { + return bytes[0] == 'R' + && bytes[1] == 'I' + && bytes[2] == 'F' + && bytes[3] == 'F' + && bytes[8] == 'W' + && bytes[9] == 'E' + && bytes[10] == 'B' + && bytes[11] == 'P'; + } + + private boolean looksLikeHeic(byte[] bytes) { + if (bytes.length < 12) { + return false; + } + boolean ftyp = bytes[4] == 'f' && bytes[5] == 't' && bytes[6] == 'y' && bytes[7] == 'p'; + if (!ftyp) { + return false; + } + String brand = new String(bytes, 8, 4); + return brand.startsWith("heic") + || brand.startsWith("heif") + || brand.startsWith("mif1") + || brand.startsWith("msf1"); } private boolean hasExistingImage(String userId, String recipeId) { @@ -130,14 +258,6 @@ private void incrementDailyQuota(String userId) { private String buildFilename(String userId, MultipartFile file) { String extension = extensionFor(file.getContentType()); - String original = file.getOriginalFilename(); - if (original != null && !original.isBlank()) { - String base = Paths.get(original).getFileName().toString(); - int dot = base.lastIndexOf('.'); - if (dot > 0 && dot < base.length() - 1) { - extension = base.substring(dot); - } - } long timestamp = clock.instant().toEpochMilli(); return "recipe-" + userId + "-" + timestamp + extension; diff --git a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicket.java b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicket.java new file mode 100644 index 00000000..982b01c8 --- /dev/null +++ b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicket.java @@ -0,0 +1,72 @@ +package com.mealflow.appapi.recipes.image; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("recipe_image_upload_tickets") +public class RecipeImageUploadTicket { + @Id + private String id; + + private String userId; + private String imageUrl; + private String imageFileId; + private Instant createdAt; + private boolean consumed; + + public RecipeImageUploadTicket() {} + + public RecipeImageUploadTicket( + String userId, String imageUrl, String imageFileId, Instant createdAt, boolean consumed) { + this.userId = userId; + this.imageUrl = imageUrl; + this.imageFileId = imageFileId; + this.createdAt = createdAt; + this.consumed = consumed; + } + + public String getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getImageFileId() { + return imageFileId; + } + + public void setImageFileId(String imageFileId) { + this.imageFileId = imageFileId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public boolean isConsumed() { + return consumed; + } + + public void setConsumed(boolean consumed) { + this.consumed = consumed; + } +} diff --git a/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicketRepository.java b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicketRepository.java new file mode 100644 index 00000000..0657b635 --- /dev/null +++ b/services/app-api/src/main/java/com/mealflow/appapi/recipes/image/RecipeImageUploadTicketRepository.java @@ -0,0 +1,12 @@ +package com.mealflow.appapi.recipes.image; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface RecipeImageUploadTicketRepository extends MongoRepository { + Optional findFirstByUserIdAndImageFileIdAndConsumedFalseOrderByCreatedAtDesc( + String userId, String imageFileId); + + long deleteByCreatedAtBefore(Instant threshold); +} diff --git a/services/app-api/src/main/java/com/mealflow/appapi/recipes/service/RecipeService.java b/services/app-api/src/main/java/com/mealflow/appapi/recipes/service/RecipeService.java index c82db7ef..133ffc05 100644 --- a/services/app-api/src/main/java/com/mealflow/appapi/recipes/service/RecipeService.java +++ b/services/app-api/src/main/java/com/mealflow/appapi/recipes/service/RecipeService.java @@ -4,6 +4,7 @@ import com.mealflow.appapi.recipes.domain.Recipe; import com.mealflow.appapi.recipes.image.RecipeImageService; import com.mealflow.appapi.recipes.repository.RecipeRepository; +import java.net.URI; import java.time.Clock; import java.time.Instant; import java.util.List; @@ -74,13 +75,14 @@ public Recipe create( boolean fromExternal) { Instant now = clock.instant(); + ImageResolution image = resolveImageForCreate(userId, imageUrl, imageFileId, fromExternal); Recipe recipe = new Recipe( userId, title, description, - imageUrl, - imageFileId, + image.imageUrl(), + image.imageFileId(), ingredients, steps, cookingTimeMinutes, @@ -114,12 +116,13 @@ public Recipe patch( } Recipe existing = getForUser(userId, recipeId); + ImageResolution image = resolveImageForPatch(userId, existing, imageUrl, imageFileId, fromExternal); String oldImageFileId = existing.getImageFileId(); existing.applyPatch( title, description, - imageUrl, - imageFileId, + image.imageUrl(), + image.imageFileId(), ingredients, steps, cookingTimeMinutes, @@ -157,4 +160,79 @@ public Recipe clearImage(String userId, String recipeId) { recipe.setUpdatedAt(clock.instant()); return recipeRepository.save(recipe); } + + private ImageResolution resolveImageForCreate( + String userId, String imageUrl, String imageFileId, boolean fromExternal) { + if (imageFileId != null) { + var ticket = imageService.consumeTicket(userId, imageFileId); + if (ticket == null) { + throw new RecipeValidationException("Image upload expired or invalid. Please re-upload."); + } + return new ImageResolution(ticket.url(), ticket.fileId()); + } + + if (imageUrl != null) { + if (fromExternal) { + validateExternalImageUrl(imageUrl); + return new ImageResolution(imageUrl, null); + } + throw new RecipeValidationException("Image must be uploaded before saving."); + } + + return new ImageResolution(null, null); + } + + private ImageResolution resolveImageForPatch( + String userId, Recipe existing, String imageUrl, String imageFileId, Boolean fromExternal) { + if (imageFileId == null && imageUrl == null) { + return new ImageResolution(null, null); + } + + String currentUrl = existing.getImageUrl(); + String currentFileId = existing.getImageFileId(); + + if (imageFileId != null) { + if (imageFileId.equals(currentFileId)) { + return new ImageResolution(currentUrl, currentFileId); + } + var ticket = imageService.consumeTicket(userId, imageFileId); + if (ticket == null) { + throw new RecipeValidationException("Image upload expired or invalid. Please re-upload."); + } + return new ImageResolution(ticket.url(), ticket.fileId()); + } + + if (imageUrl != null) { + if (imageUrl.equals(currentUrl)) { + return new ImageResolution(currentUrl, currentFileId); + } + if (Boolean.TRUE.equals(fromExternal)) { + validateExternalImageUrl(imageUrl); + return new ImageResolution(imageUrl, null); + } + throw new RecipeValidationException("Image must be uploaded before saving."); + } + + return new ImageResolution(null, null); + } + + private record ImageResolution(String imageUrl, String imageFileId) {} + + private void validateExternalImageUrl(String imageUrl) { + var allowedHosts = imageService.getExternalAllowedHosts(); + if (allowedHosts == null || allowedHosts.isEmpty()) { + return; + } + + String host; + try { + host = URI.create(imageUrl).getHost(); + } catch (Exception ex) { + throw new RecipeValidationException("External image URL is invalid."); + } + + if (host == null || allowedHosts.stream().noneMatch(allowed -> allowed.equalsIgnoreCase(host))) { + throw new RecipeValidationException("External image host is not allowed."); + } + } } diff --git a/services/app-api/src/main/resources/application.properties b/services/app-api/src/main/resources/application.properties index f4cd7dfb..5c990036 100644 --- a/services/app-api/src/main/resources/application.properties +++ b/services/app-api/src/main/resources/application.properties @@ -23,7 +23,9 @@ app.ratelimit.api-per-minute=${RATE_LIMIT_API_PER_MINUTE:120} app.images.max-upload-bytes=${APP_IMAGES_MAX_UPLOAD_BYTES:10485760} app.images.max-per-user=${APP_IMAGES_MAX_PER_USER:200} app.images.max-per-day=${APP_IMAGES_MAX_PER_DAY:20} +app.images.ticket-ttl-minutes=${APP_IMAGES_TICKET_TTL_MINUTES:60} app.images.allowed-types=${APP_IMAGES_ALLOWED_TYPES:image/jpeg,image/png,image/webp,image/heic} +app.images.external-allowed-hosts=${APP_IMAGES_EXTERNAL_ALLOWED_HOSTS:www.themealdb.com} spring.servlet.multipart.max-file-size=${APP_IMAGES_MAX_UPLOAD_SIZE:10MB} spring.servlet.multipart.max-request-size=${APP_IMAGES_MAX_UPLOAD_SIZE:10MB}