Functional Verification

This commit is contained in:
durn
2026-04-28 22:35:00 -06:00
parent cd35378ffa
commit 229bc6bf69
14 changed files with 297 additions and 78 deletions
+5
View File
@@ -89,6 +89,11 @@
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<!--- Email -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.security</groupId> <groupId>org.springframework.security</groupId>
@@ -2,8 +2,10 @@ package com.example.demo;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class RecipeDemoApplication { public class RecipeDemoApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -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<String> 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.");
}
}
}
@@ -46,6 +46,11 @@ public class SiteController {
return "home"; return "home";
} }
@GetMapping("/verify")
public String verifyPage() {
return "verify";
}
@GetMapping("/login") @GetMapping("/login")
public String viewLoginPage(@RequestParam(required = false) String redirect, Model model) { public String viewLoginPage(@RequestParam(required = false) String redirect, Model model) {
@@ -4,6 +4,7 @@ import java.security.Principal;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@@ -45,9 +45,9 @@ public class User implements UserDetails {
@Column(unique = true) @Column(unique = true)
private String email; private String email;
private String hashedpassword; private String hashedpassword;
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private LocalDateTime createdAt;
@@ -202,4 +202,6 @@ public class User implements UserDetails {
} }
return username; return username;
} }
} }
@@ -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;
}
}
@@ -317,67 +317,45 @@ public class RecipeServiceImpl implements RecipeService {
List<Tag> tagsToRemove = new ArrayList<>(); List<Tag> tagsToRemove = new ArrayList<>();
if (updatedIngredients != null) { if (updatedIngredients != null) {
for (RecipeIngredient ri : existingRecipe.getRecipeIngredients()) {
for (int i = 0; i < updatedIngredients.size(); i++) { boolean existsInUpdatedList = updatedIngredients.stream()
RecipeIngredientDto riDto = updatedIngredients.get(i); .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() for (int i = 0; i < updatedIngredients.size(); i++) {
.filter(ri -> java.util.Objects.equals(ri.getIngredient().getName(), riDto.getIngredientName())) RecipeIngredientDto riDto = updatedIngredients.get(i);
.findFirst()
.orElse(null);
if (existingRI != null) { RecipeIngredient existingRI = existingRecipe.getRecipeIngredients().stream()
existingRI.setQuantity(riDto.getQuantity()); .filter(ri -> java.util.Objects.equals(
existingRI.setUnit(riDto.getUnit()); ri.getIngredient().getName().toLowerCase(),
existingRI.setNotes(riDto.getNotes()); riDto.getIngredientName().toLowerCase()))
existingRI.setOrderIndex(i); .findFirst()
} else { .orElse(null);
Ingredient ingredient = ingredientRepository.findByNameIgnoreCase(riDto.getIngredientName())
.orElseGet(() -> new Ingredient(riDto.getIngredientName()));
if (ingredient.getId() == null) { if (existingRI != null) {
ingredientRepository.save(ingredient); 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(), if (ingredient.getId() == null) {
riDto.getUnit(), riDto.getNotes()); ingredientRepository.save(ingredient);
newRI.setOrderIndex(i); }
existingRecipe.getRecipeIngredients().add(newRI); RecipeIngredient newRI = new RecipeIngredient(existingRecipe, ingredient,
} riDto.getQuantity(), riDto.getUnit(), riDto.getNotes());
} newRI.setOrderIndex(i);
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);
}
}
} }
if (updatedSteps != null) { if (updatedSteps != null) {
@@ -460,6 +438,8 @@ public class RecipeServiceImpl implements RecipeService {
recipeRepository.save(existingRecipe); recipeRepository.save(existingRecipe);
return convertToDto(existingRecipe); return convertToDto(existingRecipe);
} }
@Override @Override
@@ -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<String, String> otpMap = new HashMap<>();
private final Map<String, Long> 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;
});
}
}
@@ -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();
}
}
@@ -22,5 +22,12 @@ spring.servlet.multipart.max-request-size=20MB
server.tomcat.max-swallow-size=-1 server.tomcat.max-swallow-size=-1
server.tomcat.max-part-count=100 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 # Stable upload directory for images
app.upload.dir=${THYMECRUNCH_UPLOAD_DIR:${user.home}/thymecrunch/uploads} app.upload.dir=${THYMECRUNCH_UPLOAD_DIR:${user.home}/thymecrunch/uploads}
@@ -146,27 +146,25 @@ function isProfane(str) {
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content'); const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
try { 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", method: "POST",
headers: { headers: { [csrfHeader]: csrfToken }
[csrfHeader]: csrfToken,
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
}); });
if (response.ok) { passwordError.style.color = "green";
passwordError.style.color = "green"; passwordError.textContent = "Check your email for a verification code...";
passwordError.textContent = "Account created successfully. Redirecting to login..."; setTimeout(function () {
setTimeout(function () { window.location.href = "/verify";
window.location.href = "/login"; }, 1500);
}, 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);
}
} catch (error) { } catch (error) {
passwordError.style.color = "red"; passwordError.style.color = "red";
passwordError.textContent = "Could not connect to the server."; passwordError.textContent = "Could not connect to the server.";
@@ -559,8 +559,8 @@ document.querySelectorAll('#ingredients-container .dynamic-row').forEach((row, i
const notes = row.querySelector('.ing-notes').value.trim(); const notes = row.querySelector('.ing-notes').value.trim();
formData.append('ingredientQuantity', qty); formData.append('ingredientQuantity', qty);
if (unit) formData.append('ingredientUnit', unit); formData.append('ingredientUnit', row.querySelector('.ing-unit').value.trim());
if (notes) formData.append('ingredientNotes', notes); formData.append('ingredientNotes', row.querySelector('.ing-notes').value.trim());
formData.append('ingredientOrder', index); formData.append('ingredientOrder', index);
}); });
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<meta name="_csrf" th:content="${_csrf.token}"/>
<title>Verify Your Thyme Crunch Account</title>
<link rel="stylesheet" th:href="@{css/create-account.css}">
<link href="https://fonts.googleapis.com/css2?family=Delius+Swash+Caps&family=Mali:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700" rel="stylesheet">
</head>
<body>
<div class="header-wrap">
<a th:href="@{/explore}">
<header class="top-header">
<img th:src="@{/images/header_left.png}" alt="Violin f-hole shape to the left of header." class="swirl">
<h1 class="site-name">Thyme Crunch</h1>
<img th:src="@{/images/header_right.png}" alt="Violin f-hole shape to the right of header." class="swirl">
</header>
</a>
</div>
<div class="align-decor">
<img th:src="@{images/decor_left.png}" alt="" class="vert_swirl">
<main class="main-content">
<div class="login-box">
<h2>Verify Your Account</h2>
<p>Enter the 6-digit code sent to your email.</p>
<div class="rows">
<label for="otpInput">Verification Code</label>
<input type="text" id="otpInput" maxlength="6" placeholder="000000" required>
</div>
<p id="message"></p>
<button onclick="verifyOtp()">Verify</button>
</div>
</main>
<img th:src="@{images/decor_right.png}" alt="" class="vert_swirl">
</div>
<script>
async function verifyOtp() {
const otp = document.getElementById("otpInput").value;
const email = sessionStorage.getItem("pendingEmail");
const pendingUser = JSON.parse(sessionStorage.getItem("pendingUser"));
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
const response = await fetch(`/api/email/verify?email=${encodeURIComponent(email)}&otp=${encodeURIComponent(otp)}`, {
method: "POST",
headers: { [csrfHeader]: csrfToken }
});
if (response.ok) {
await fetch("/api/users", {
method: "POST",
headers: {
[csrfHeader]: csrfToken,
"Content-Type": "application/json"
},
body: JSON.stringify(pendingUser)
});
sessionStorage.removeItem("pendingEmail");
sessionStorage.removeItem("pendingUser");
message.style.color = "green";
message.textContent = "Account verified! Redirecting to login...";
setTimeout(() => window.location.href = "/login", 1500);
} else {
message.style.color = "red";
message.textContent = "Invalid or expired code. Please try again.";
}
}
</script>
</body>
</html>