Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ public class ImageUploadProperties {
private long maxUploadBytes;
private int maxPerUser;
private int maxPerDay;
private int ticketTtlMinutes;
private List<String> allowedTypes;
private List<String> externalAllowedHosts;

public long getMaxUploadBytes() {
return maxUploadBytes;
Expand All @@ -37,11 +39,27 @@ public void setMaxPerDay(int maxPerDay) {
this.maxPerDay = maxPerDay;
}

public int getTicketTtlMinutes() {
return ticketTtlMinutes;
}

public void setTicketTtlMinutes(int ticketTtlMinutes) {
this.ticketTtlMinutes = ticketTtlMinutes;
}

public List<String> getAllowedTypes() {
return allowedTypes;
}

public void setAllowedTypes(List<String> allowedTypes) {
this.allowedTypes = allowedTypes;
}

public List<String> getExternalAllowedHosts() {
return externalAllowedHosts;
}

public void setExternalAllowedHosts(List<String> externalAllowedHosts) {
this.externalAllowedHosts = externalAllowedHosts;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -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<String> getExternalAllowedHosts() {
List<String> 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<String> 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;
Expand All @@ -76,16 +138,82 @@ 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 =
Optional.ofNullable(file.getContentType()).orElse("").toLowerCase(Locale.ROOT);
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) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<RecipeImageUploadTicket, String> {
Optional<RecipeImageUploadTicket> findFirstByUserIdAndImageFileIdAndConsumedFalseOrderByCreatedAtDesc(
String userId, String imageFileId);

long deleteByCreatedAtBefore(Instant threshold);
}
Loading