diff --git a/pom.xml b/pom.xml index 9fe6075..869d507 100644 --- a/pom.xml +++ b/pom.xml @@ -89,6 +89,11 @@ spring-boot-starter-test test + + + org.springframework.boot + spring-boot-starter-mail + org.springframework.security diff --git a/src/main/java/com/example/demo/RecipeDemoApplication.java b/src/main/java/com/example/demo/RecipeDemoApplication.java index de1e812..a583133 100644 --- a/src/main/java/com/example/demo/RecipeDemoApplication.java +++ b/src/main/java/com/example/demo/RecipeDemoApplication.java @@ -2,8 +2,10 @@ package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class RecipeDemoApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/demo/controller/EmailController.java b/src/main/java/com/example/demo/controller/EmailController.java new file mode 100644 index 0000000..ac02f78 --- /dev/null +++ b/src/main/java/com/example/demo/controller/EmailController.java @@ -0,0 +1,40 @@ +package com.example.demo.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import com.example.demo.service.EmailService; +import com.example.demo.service.OtpStore; + + +@RestController +@RequestMapping("/api/email") +public class EmailController { + + @Autowired + private OtpStore otpStore; + + + + @Autowired + private EmailService emailService; + + + @PostMapping("/send") + public String send(@RequestParam String email) { + String otp = emailService.sendOtpEmail(email); + return "OTP sent to " + email + " (for demo, OTP: " + otp + ")"; + } + + @PostMapping("/verify") + public ResponseEntity verify(@RequestParam String email, @RequestParam String otp) { + if (otpStore.validate(email, otp)) { + otpStore.clear(email); + + return ResponseEntity.ok("Verified!"); + } else { + return ResponseEntity.status(400).body("Invalid or expired code."); + } + } +} diff --git a/src/main/java/com/example/demo/controller/SiteController.java b/src/main/java/com/example/demo/controller/SiteController.java index af2da6b..2ed12eb 100644 --- a/src/main/java/com/example/demo/controller/SiteController.java +++ b/src/main/java/com/example/demo/controller/SiteController.java @@ -46,6 +46,11 @@ public class SiteController { return "home"; } + + @GetMapping("/verify") + public String verifyPage() { + return "verify"; + } @GetMapping("/login") public String viewLoginPage(@RequestParam(required = false) String redirect, Model model) { diff --git a/src/main/java/com/example/demo/controller/UserController.java b/src/main/java/com/example/demo/controller/UserController.java index 0250e33..30429aa 100644 --- a/src/main/java/com/example/demo/controller/UserController.java +++ b/src/main/java/com/example/demo/controller/UserController.java @@ -4,6 +4,7 @@ import java.security.Principal; import java.util.List; import java.util.Optional; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/com/example/demo/entity/User.java b/src/main/java/com/example/demo/entity/User.java index 1597aeb..1959668 100644 --- a/src/main/java/com/example/demo/entity/User.java +++ b/src/main/java/com/example/demo/entity/User.java @@ -45,9 +45,9 @@ public class User implements UserDetails { @Column(unique = true) private String email; - + private String hashedpassword; - + @Column(name = "created_at") private LocalDateTime createdAt; @@ -202,4 +202,6 @@ public class User implements UserDetails { } return username; } + + } \ No newline at end of file diff --git a/src/main/java/com/example/demo/service/EmailService.java b/src/main/java/com/example/demo/service/EmailService.java new file mode 100644 index 0000000..630e9f5 --- /dev/null +++ b/src/main/java/com/example/demo/service/EmailService.java @@ -0,0 +1,38 @@ +package com.example.demo.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +@Service +public class EmailService { + + @Autowired + private JavaMailSender mailSender; + + @Autowired + private OtpStore otpStore; + + public String sendOtpEmail(String toEmail) { + String otp = OtpUtil.generateOtp(6); + otpStore.save(toEmail, otp); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(toEmail); + message.setSubject("Welcome to Thyme Crunch – Verify Your Account"); + message.setText( + "Welcome to Thyme Crunch!\n\n" + + "Thank you for creating an account. To complete your registration, " + + "please enter the verification code below:\n\n" + + "Your verification code: " + otp + "\n\n" + + "This code will expire in 5 minutes. If you did not create an account " + + "with Thyme Crunch, you can safely ignore this email.\n\n" + + "Happy cooking,\n" + + "The Thyme Crunch Team" + ); + + mailSender.send(message); + return otp; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/service/Impl/RecipeServiceImpl.java b/src/main/java/com/example/demo/service/Impl/RecipeServiceImpl.java index 31bcc9d..7488b86 100644 --- a/src/main/java/com/example/demo/service/Impl/RecipeServiceImpl.java +++ b/src/main/java/com/example/demo/service/Impl/RecipeServiceImpl.java @@ -317,67 +317,45 @@ public class RecipeServiceImpl implements RecipeService { List tagsToRemove = new ArrayList<>(); if (updatedIngredients != null) { - - for (int i = 0; i < updatedIngredients.size(); i++) { - RecipeIngredientDto riDto = updatedIngredients.get(i); + for (RecipeIngredient ri : existingRecipe.getRecipeIngredients()) { + boolean existsInUpdatedList = updatedIngredients.stream() + .anyMatch(dto -> java.util.Objects.equals( + ri.getIngredient().getName().toLowerCase(), + dto.getIngredientName().toLowerCase())); + if (!existsInUpdatedList) + ingredientsToRemove.add(ri); + } + existingRecipe.getRecipeIngredients().removeAll(ingredientsToRemove); - RecipeIngredient existingRI = existingRecipe.getRecipeIngredients().stream() - .filter(ri -> java.util.Objects.equals(ri.getIngredient().getName(), riDto.getIngredientName())) - .findFirst() - .orElse(null); + for (int i = 0; i < updatedIngredients.size(); i++) { + RecipeIngredientDto riDto = updatedIngredients.get(i); - if (existingRI != null) { - existingRI.setQuantity(riDto.getQuantity()); - existingRI.setUnit(riDto.getUnit()); - existingRI.setNotes(riDto.getNotes()); - existingRI.setOrderIndex(i); - } else { - Ingredient ingredient = ingredientRepository.findByNameIgnoreCase(riDto.getIngredientName()) - .orElseGet(() -> new Ingredient(riDto.getIngredientName())); + RecipeIngredient existingRI = existingRecipe.getRecipeIngredients().stream() + .filter(ri -> java.util.Objects.equals( + ri.getIngredient().getName().toLowerCase(), + riDto.getIngredientName().toLowerCase())) + .findFirst() + .orElse(null); - if (ingredient.getId() == null) { - ingredientRepository.save(ingredient); - } + if (existingRI != null) { + existingRI.setQuantity(riDto.getQuantity()); + existingRI.setUnit(riDto.getUnit()); + existingRI.setNotes(riDto.getNotes()); + existingRI.setOrderIndex(i); + } else { + Ingredient ingredient = ingredientRepository.findByNameIgnoreCase(riDto.getIngredientName()) + .orElseGet(() -> new Ingredient(riDto.getIngredientName())); - RecipeIngredient newRI = new RecipeIngredient(existingRecipe, ingredient, riDto.getQuantity(), - riDto.getUnit(), riDto.getNotes()); - newRI.setOrderIndex(i); + if (ingredient.getId() == null) { + ingredientRepository.save(ingredient); + } - existingRecipe.getRecipeIngredients().add(newRI); - } - } - - existingRecipe.getRecipeIngredients().removeAll(ingredientsToRemove); - - for (RecipeIngredientDto riDto : updatedIngredients) { - - RecipeIngredient existingRI = existingRecipe.getRecipeIngredients().stream() - .filter(ri -> java.util.Objects.equals(ri.getIngredient().getName(), riDto.getIngredientName())) - .findFirst() - .orElse(null); - - if (existingRI != null) { - - existingRI.setQuantity(riDto.getQuantity()); - existingRI.setUnit(riDto.getUnit()); - existingRI.setNotes(riDto.getNotes()); - } - - else { - - Ingredient ingredient = ingredientRepository.findByNameIgnoreCase(riDto.getIngredientName()) - .orElseGet(() -> new Ingredient(riDto.getIngredientName())); - - if (ingredient.getId() == null) { - ingredientRepository.save(ingredient); - } - - RecipeIngredient newRI = new RecipeIngredient(existingRecipe, ingredient, riDto.getQuantity(), - riDto.getUnit(), riDto.getNotes()); - - existingRecipe.getRecipeIngredients().add(newRI); - } - } + RecipeIngredient newRI = new RecipeIngredient(existingRecipe, ingredient, + riDto.getQuantity(), riDto.getUnit(), riDto.getNotes()); + newRI.setOrderIndex(i); + existingRecipe.getRecipeIngredients().add(newRI); + } + } } if (updatedSteps != null) { @@ -460,6 +438,8 @@ public class RecipeServiceImpl implements RecipeService { recipeRepository.save(existingRecipe); return convertToDto(existingRecipe); } + + @Override diff --git a/src/main/java/com/example/demo/service/OtpStore.java b/src/main/java/com/example/demo/service/OtpStore.java new file mode 100644 index 0000000..417006a --- /dev/null +++ b/src/main/java/com/example/demo/service/OtpStore.java @@ -0,0 +1,44 @@ +package com.example.demo.service; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class OtpStore { + private final Map otpMap = new HashMap<>(); + private final Map expiryMap = new HashMap<>(); + private static final long EXPIRY_MS = 5 * 60 * 1000; + + public void save(String email, String otp) { + otpMap.put(email, otp); + expiryMap.put(email, System.currentTimeMillis() + EXPIRY_MS); + } + + public boolean validate(String email, String otp) { + if (!expiryMap.containsKey(email) || System.currentTimeMillis() > expiryMap.get(email)) { + clear(email); // expired + return false; + } + return otp.equals(otpMap.get(email)); + } + + public void clear(String email) { + otpMap.remove(email); + expiryMap.remove(email); + } + + @Scheduled(fixedRate = 120000) + public void clearExpired() { + long now = System.currentTimeMillis(); + expiryMap.entrySet().removeIf(entry -> { + if (now > entry.getValue()) { + otpMap.remove(entry.getKey()); + return true; + } + return false; + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/demo/service/OtpUtil.java b/src/main/java/com/example/demo/service/OtpUtil.java new file mode 100644 index 0000000..f63f4bd --- /dev/null +++ b/src/main/java/com/example/demo/service/OtpUtil.java @@ -0,0 +1,15 @@ +package com.example.demo.service; + +import java.security.SecureRandom; + +public class OtpUtil { + private static final SecureRandom random = new SecureRandom(); + + public static String generateOtp(int length) { + StringBuilder otp = new StringBuilder(); + for (int i = 0; i < length; i++) { + otp.append(random.nextInt(10)); + } + return otp.toString(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ee99327..5df447d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,5 +22,12 @@ spring.servlet.multipart.max-request-size=20MB server.tomcat.max-swallow-size=-1 server.tomcat.max-part-count=100 +spring.mail.host = smtp.gmail.com +spring.mail.port = 587 +spring.mail.username = thymeverify@gmail.com +spring.mail.password = bvzz gsdo zjxz ames +spring.mail.properties.mail.smtp.auth = true +spring.mail.properties.mail.smtp.starttls.enable = true + # Stable upload directory for images app.upload.dir=${THYMECRUNCH_UPLOAD_DIR:${user.home}/thymecrunch/uploads} \ No newline at end of file diff --git a/src/main/resources/templates/create-account.html b/src/main/resources/templates/create-account.html index 6d5b4b5..1232a47 100644 --- a/src/main/resources/templates/create-account.html +++ b/src/main/resources/templates/create-account.html @@ -146,27 +146,25 @@ function isProfane(str) { const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content'); try { - const response = await fetch("/api/users", { + sessionStorage.setItem("pendingEmail", email); + sessionStorage.setItem("pendingUser", JSON.stringify({ + username: name, + email: email, + hashedpassword: password, + role: "ROLE_USER" + })); + + await fetch(`/api/email/send?email=${encodeURIComponent(email)}`, { method: "POST", - headers: { - [csrfHeader]: csrfToken, - "Content-Type": "application/json" - }, - body: JSON.stringify(userData) + headers: { [csrfHeader]: csrfToken } }); - if (response.ok) { - passwordError.style.color = "green"; - passwordError.textContent = "Account created successfully. Redirecting to login..."; - setTimeout(function () { - window.location.href = "/login"; - }, 1500); - } else { - const errorText = await response.text(); - passwordError.style.color = "red"; - passwordError.textContent = "Account creation failed. Please try a different username or email."; - console.error("Create account failed:", errorText); - } + passwordError.style.color = "green"; + passwordError.textContent = "Check your email for a verification code..."; + setTimeout(function () { + window.location.href = "/verify"; + }, 1500); + } catch (error) { passwordError.style.color = "red"; passwordError.textContent = "Could not connect to the server."; diff --git a/src/main/resources/templates/update-recipe.html b/src/main/resources/templates/update-recipe.html index 2c7e329..ce11e1c 100644 --- a/src/main/resources/templates/update-recipe.html +++ b/src/main/resources/templates/update-recipe.html @@ -559,8 +559,8 @@ document.querySelectorAll('#ingredients-container .dynamic-row').forEach((row, i const notes = row.querySelector('.ing-notes').value.trim(); formData.append('ingredientQuantity', qty); - if (unit) formData.append('ingredientUnit', unit); - if (notes) formData.append('ingredientNotes', notes); + formData.append('ingredientUnit', row.querySelector('.ing-unit').value.trim()); + formData.append('ingredientNotes', row.querySelector('.ing-notes').value.trim()); formData.append('ingredientOrder', index); }); diff --git a/src/main/resources/templates/verify.html b/src/main/resources/templates/verify.html new file mode 100644 index 0000000..a00cacf --- /dev/null +++ b/src/main/resources/templates/verify.html @@ -0,0 +1,82 @@ + + + + + + + Verify Your Thyme Crunch Account + + + + + + + + + + Thyme Crunch + + + + + + + + + + + Verify Your Account + Enter the 6-digit code sent to your email. + + + Verification Code + + + + Verify + + + + + + + + + + \ No newline at end of file
Enter the 6-digit code sent to your email.