Backend migration

This commit is contained in:
2025-04-27 17:42:02 +00:00
parent e114f9553a
commit 8b1d6eb7cf
52 changed files with 2326 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
package dev.rheinsw.contactService;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@EnableAsync
@SpringBootApplication(
scanBasePackages = {"dev.rheinsw"}
)
public class ContactServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ContactServiceApplication.class, args);
}
}

View File

@@ -0,0 +1,19 @@
package dev.rheinsw.contactService.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
/**
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@Setter
@Getter
@Configuration
@ConfigurationProperties(prefix = "hcaptcha")
public class HCaptchaConfig {
private String secret;
}

View File

@@ -0,0 +1,114 @@
package dev.rheinsw.contactService.controller;
import dev.rheinsw.contactService.dto.ContactRequestDto;
import dev.rheinsw.contactService.model.ContactRequest;
import dev.rheinsw.contactService.repository.ContactRequestsRepo;
import dev.rheinsw.contactService.service.HCaptchaValidator;
import dev.rheinsw.shared.mail.MailServiceClient;
import lombok.AllArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/**
* REST controller to handle contact form submissions.
*
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@RestController
@AllArgsConstructor
@RequestMapping("/contact")
public class ContactController {
private static final Logger log = LoggerFactory.getLogger(ContactController.class);
private final HCaptchaValidator captchaValidator;
private final ContactRequestsRepo contactRepository;
private final MailServiceClient mailServiceClient;
@PostMapping
public ResponseEntity<String> submitContact(@RequestBody ContactRequestDto request) {
log.info("Received contact form from: {}", request.name);
log.debug("Captcha token: {}", request.captcha);
log.info("Message: {}", request.message);
if (request.email != null) {
log.info("Reply to: {} ({})", request.email, request.name);
}
if (!isValidCaptcha(request.captcha)) {
log.warn("Captcha verification failed for {}", request.email);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Captcha verification failed");
}
ContactRequest message = new ContactRequest()
.setName(request.name)
.setEmail(request.email)
.setMessage(request.message)
.setCompany(request.company)
.setPhone(request.phone)
.setWebsite(request.website)
.setCaptchaToken(request.captcha)
.setSubmittedAt(LocalDateTime.now());
contactRepository.save(message);
notifyContactAndTeam(request);
return ResponseEntity.ok("Contact form submitted successfully");
}
private boolean isValidCaptcha(String captcha) {
return "10000000-aaaa-bbbb-cccc-000000000001".equals(captcha) || captchaValidator.isValid(captcha);
}
private void notifyContactAndTeam(ContactRequestDto request) {
// User confirmation
String userSubject = "Kontaktanfrage erhalten";
String userBody = """
Hallo %s,
wir haben Ihre Nachricht erhalten und melden uns schnellstmöglich bei Ihnen.
Ihre Nachricht:
%s
Mit freundlichen Grüßen
Rhein Software
""".formatted(request.name, request.message);
mailServiceClient.sendMail(request.email, userSubject, userBody);
// Team notification
String teamSubject = "Neue Kontaktanfrage";
String teamBody = """
Neue Kontaktanfrage von: %s
E-Mail: %s
Unternehmen: %s
Telefonnummer: %s
Webseite: %s
Nachricht:
%s
""".formatted(
request.name,
request.email,
safe(request.company),
safe(request.phone),
safe(request.website),
request.message
);
mailServiceClient.sendMail("rhein.software@gmail.com", teamSubject, teamBody);
}
private String safe(String value) {
return value != null ? value : "-";
}
}

View File

@@ -0,0 +1,18 @@
package dev.rheinsw.contactService.dto;
import dev.rheinsw.shared.transport.Dto;
/**
* @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
public class ContactRequestDto implements Dto {
public String name;
public String email;
public String message;
public String company; // optional
public String phone; // optional
public String website; // optional
public String captcha; // required for hCaptcha validation
}

View File

@@ -0,0 +1,95 @@
package dev.rheinsw.contactService.model;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* @author Thatsaphorn Atchariyaphap
* @since 22.04.25
*/
@Entity
@Data
@NoArgsConstructor
@Table(name = "contact_requests")
public class ContactRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Size(max = 100)
private String name;
@Size(max = 100)
@Email
private String email;
@Size(max = 1000)
@Column(length = 1000)
private String message;
@Size(max = 100)
private String company;
@Size(max = 20)
private String phone;
@Size(max = 100)
private String website;
@Size(max = 1024)
@Column(name = "captcha_token", length = 1024)
private String captchaToken;
private LocalDateTime submittedAt;
public ContactRequest setName(String name) {
this.name = name;
return this;
}
public ContactRequest setEmail(String email) {
this.email = email;
return this;
}
public ContactRequest setMessage(String message) {
this.message = message;
return this;
}
public ContactRequest setCompany(String company) {
this.company = company;
return this;
}
public ContactRequest setPhone(String phone) {
this.phone = phone;
return this;
}
public ContactRequest setWebsite(String website) {
this.website = website;
return this;
}
public ContactRequest setCaptchaToken(String captchaToken) {
this.captchaToken = captchaToken;
return this;
}
public ContactRequest setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
return this;
}
}

View File

@@ -0,0 +1,12 @@
package dev.rheinsw.contactService.repository;
import dev.rheinsw.contactService.model.ContactRequest;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* @author Thatsaphorn Atchariyaphap
* @since 22.04.25
*/
public interface ContactRequestsRepo extends JpaRepository<ContactRequest, Long> {
// empty
}

View File

@@ -0,0 +1,55 @@
package dev.rheinsw.contactService.service;
import dev.rheinsw.contactService.config.HCaptchaConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* @author Thatsaphorn Atchariyaphap
* @since 21.04.25
*/
@Service
public class HCaptchaValidator {
private static final Logger log = LoggerFactory.getLogger(HCaptchaValidator.class);
private final HCaptchaConfig config;
private final RestTemplate restTemplate;
public HCaptchaValidator(HCaptchaConfig config, RestTemplate restTemplate) {
this.config = config;
this.restTemplate = restTemplate;
}
public boolean isValid(String token) {
if (token == null || token.isBlank()) {
log.warn("Captcha token is missing or blank");
return false;
}
String secret = config.getSecret();
if (secret == null || secret.isBlank()) {
log.error("Captcha secret is missing");
return false;
}
try {
var response = restTemplate.postForObject(
"https://api.hcaptcha.com/siteverify",
new org.springframework.util.LinkedMultiValueMap<String, String>() {{
add("secret", secret);
add("response", token);
}},
Map.class
);
return response != null && Boolean.TRUE.equals(response.get("success"));
} catch (Exception e) {
log.error("Failed to verify hCaptcha", e);
return false;
}
}
}

View File

@@ -0,0 +1,31 @@
server:
port: 0 # random port
spring:
application:
name: contactService
datasource:
url: jdbc:postgresql://localhost:5432/rheinsw_dev
username: rheinsw
password: rheinsw
jpa:
hibernate:
ddl-auto: none
show-sql: true
properties:
hibernate:
format_sql: true
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
hcaptcha:
secret: ES_ff59a664dc764f92870bf2c7b4eab7c5
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE

View File

@@ -0,0 +1,12 @@
CREATE TABLE contact_requests
(
id SERIAL PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100),
message VARCHAR(1000),
company VARCHAR(100),
phone VARCHAR(20),
website VARCHAR(100),
captcha_token VARCHAR(1024),
submitted_at TIMESTAMP
);

View File

@@ -0,0 +1,125 @@
package dev.rheinsw.contactService.controller;
import dev.rheinsw.contactService.dto.ContactRequestDto;
import dev.rheinsw.contactService.model.ContactRequest;
import dev.rheinsw.contactService.repository.ContactRequestsRepo;
import dev.rheinsw.contactService.service.HCaptchaValidator;
import dev.rheinsw.shared.mail.MailServiceClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class ContactControllerTest {
@Mock
private HCaptchaValidator hCaptchaValidator;
@Mock
private ContactRequestsRepo repository;
@Mock
private MailServiceClient mailServiceClient;
@InjectMocks
private ContactController controller;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void submitContact_ShouldReturnSuccess_WhenDevCaptchaTokenUsed() {
// Arrange
ContactRequestDto request = new ContactRequestDto();
request.name = "Test User";
request.email = "test@example.com";
request.message = "Hello!";
request.captcha = "10000000-aaaa-bbbb-cccc-000000000001";
// Act
ResponseEntity<String> response = controller.submitContact(request);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Contact form submitted successfully", response.getBody());
verify(repository, times(1)).save(any(ContactRequest.class));
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
verifyNoInteractions(hCaptchaValidator);
}
@Test
void submitContact_ShouldReturnSuccess_WhenCaptchaValid() {
// Arrange
ContactRequestDto request = new ContactRequestDto();
request.name = "Valid User";
request.email = "valid@example.com";
request.message = "Some message";
request.captcha = "real-token";
when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
// Act
ResponseEntity<String> response = controller.submitContact(request);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Contact form submitted successfully", response.getBody());
verify(hCaptchaValidator, times(1)).isValid("real-token");
verify(repository, times(1)).save(any(ContactRequest.class));
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
}
@Test
void submitContact_ShouldReturnForbidden_WhenCaptchaInvalid() {
// Arrange
ContactRequestDto request = new ContactRequestDto();
request.name = "Bot User";
request.email = "bot@example.com";
request.message = "Spam spam spam";
request.captcha = "invalid-token";
when(hCaptchaValidator.isValid("invalid-token")).thenReturn(false);
// Act
ResponseEntity<String> response = controller.submitContact(request);
// Assert
assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());
assertEquals("Captcha verification failed", response.getBody());
verify(hCaptchaValidator).isValid("invalid-token");
verifyNoInteractions(repository);
verifyNoInteractions(mailServiceClient);
}
@Test
void submitContact_ShouldHandleNullOptionalFields() {
// Arrange
ContactRequestDto request = new ContactRequestDto();
request.name = "No Company";
request.email = "user@example.com";
request.message = "Just a message";
request.captcha = "real-token";
request.company = null;
request.phone = null;
request.website = null;
when(hCaptchaValidator.isValid("real-token")).thenReturn(true);
// Act
ResponseEntity<String> response = controller.submitContact(request);
// Assert
assertEquals(HttpStatus.OK, response.getStatusCode());
verify(repository).save(any(ContactRequest.class));
verify(mailServiceClient, times(2)).sendMail(anyString(), anyString(), anyString());
}
}

View File

@@ -0,0 +1,147 @@
package dev.rheinsw.contactService.service;
import dev.rheinsw.contactService.config.HCaptchaConfig;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ExtendWith(MockitoExtension.class)
class HCaptchaValidatorTest {
@Mock
private HCaptchaConfig hCaptchaConfig;
@Mock
private RestTemplate restTemplate;
private HCaptchaValidator validator;
@BeforeEach
void setUp() {
validator = new HCaptchaValidator(hCaptchaConfig, restTemplate);
}
@Test
void isValid_ShouldReturnFalse_WhenTokenIsNull() {
// Arrange
// Act
boolean result = validator.isValid(null);
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnFalse_WhenTokenIsBlank() {
// Arrange
// Act
boolean result = validator.isValid(" ");
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnFalse_WhenSecretIsNull() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn(null);
// Act
boolean result = validator.isValid("test-token");
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnFalse_WhenSecretIsBlank() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn(" ");
// Act
boolean result = validator.isValid("test-token");
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnFalse_WhenApiResponseIsNull() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
Mockito.when(restTemplate.postForObject(
eq("https://api.hcaptcha.com/siteverify"),
any(),
eq(Map.class)
)).thenReturn(null);
// Act
boolean result = validator.isValid("test-token");
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnFalse_WhenApiResponseSuccessIsFalse() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
Mockito.when(restTemplate.postForObject(
eq("https://api.hcaptcha.com/siteverify"),
any(),
eq(Map.class)
)).thenReturn(Map.of("success", false));
// Act
boolean result = validator.isValid("test-token");
// Assert
assertFalse(result);
}
@Test
void isValid_ShouldReturnTrue_WhenApiResponseSuccessIsTrue() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
Mockito.when(restTemplate.postForObject(
eq("https://api.hcaptcha.com/siteverify"),
any(),
eq(Map.class)
)).thenReturn(Map.of("success", true));
// Act
boolean result = validator.isValid("test-token");
// Assert
assertTrue(result);
}
@Test
void isValid_ShouldReturnFalse_WhenExceptionIsThrown() {
// Arrange
Mockito.when(hCaptchaConfig.getSecret()).thenReturn("test-secret");
Mockito.when(restTemplate.postForObject(
eq("https://api.hcaptcha.com/siteverify"),
any(),
eq(Map.class)
)).thenThrow(new RuntimeException("Simulated Exception"));
// Act
boolean result = validator.isValid("test-token");
// Assert
assertFalse(result);
}
}