diff --git a/.run/DiscoveryServerApplication.run.xml b/.run/DiscoveryServerApplication.run.xml
new file mode 100644
index 0000000..dfd80cc
--- /dev/null
+++ b/.run/DiscoveryServerApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/GatewayApplication.run.xml b/.run/GatewayApplication.run.xml
new file mode 100644
index 0000000..1b53351
--- /dev/null
+++ b/.run/GatewayApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.run/ServerApplication.run.xml b/.run/ServerApplication.run.xml
new file mode 100644
index 0000000..ec3f63a
--- /dev/null
+++ b/.run/ServerApplication.run.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/common/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java b/backend/common/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java
index da2d7ed..9c8430b 100644
--- a/backend/common/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java
+++ b/backend/common/src/main/java/dev/rheinsw/shared/mail/MailServiceClient.java
@@ -25,9 +25,6 @@ public class MailServiceClient {
private static final String MAIL_ENDPOINT = "http://gateway/api/mail";
- @Value("${INTERNAL_API_KEY}")
- private String internalApiKey;
-
@Async
public void sendMail(String email, String subject, String userMessage) {
MailRequest request = new MailRequest(email, subject, userMessage);
@@ -37,7 +34,6 @@ public class MailServiceClient {
private void postEmail(MailRequest request) {
try {
HttpHeaders headers = new HttpHeaders();
- headers.set("X-Internal-Auth", internalApiKey);
HttpEntity entity = new HttpEntity<>(request, headers);
diff --git a/backend/gateway/pom.xml b/backend/gateway/pom.xml
index 84eb49e..2a150a4 100644
--- a/backend/gateway/pom.xml
+++ b/backend/gateway/pom.xml
@@ -19,6 +19,23 @@
+
+ org.springframework.cloud
+ spring-cloud-starter-gateway
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-client
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
dev.rheinsw
common
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java
new file mode 100644
index 0000000..30f8d2a
--- /dev/null
+++ b/backend/gateway/src/main/java/dev/rheinsw/gateway/GatewayApplication.java
@@ -0,0 +1,15 @@
+package dev.rheinsw.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 04.05.25
+ */
+@SpringBootApplication
+public class GatewayApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(GatewayApplication.class, args);
+ }
+}
diff --git a/backend/gateway/src/main/java/dev/rheinsw/gateway/Main.java b/backend/gateway/src/main/java/dev/rheinsw/gateway/Main.java
deleted file mode 100644
index 6cc731a..0000000
--- a/backend/gateway/src/main/java/dev/rheinsw/gateway/Main.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.rheinsw.gateway;
-
-/**
- * @author Thatsaphorn Atchariyaphap
- * @since 04.05.25
- */
-public class Main {
- public static void main(String[] args) {
- System.out.println("Hello, World!");
- }
-}
\ No newline at end of file
diff --git a/backend/gateway/src/main/resources/application.yml b/backend/gateway/src/main/resources/application.yml
new file mode 100644
index 0000000..2b2c236
--- /dev/null
+++ b/backend/gateway/src/main/resources/application.yml
@@ -0,0 +1,23 @@
+server:
+ port: 8080
+
+eureka:
+ client:
+ service-url:
+ defaultZone: http://localhost:8761/eureka/
+
+spring:
+ application:
+ name: gateway
+ main:
+ web-application-type: reactive # Set the application type to reactive
+
+ cloud:
+ gateway:
+ routes:
+ - id: server
+ uri: lb://server
+ predicates:
+ - Path=/api/**
+ filters:
+ - StripPrefix=1
\ No newline at end of file
diff --git a/backend/server/pom.xml b/backend/server/pom.xml
index 7d849cc..60305af 100644
--- a/backend/server/pom.xml
+++ b/backend/server/pom.xml
@@ -18,7 +18,60 @@
UTF-8
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler.plugin.version}
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.cloud
+ spring-cloud-starter-netflix-eureka-client
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+ jakarta.validation
+ jakarta.validation-api
+
+
+ org.postgresql
+ postgresql
+
+
dev.rheinsw
common
diff --git a/backend/server/src/main/java/dev/rheinsw/server/Main.java b/backend/server/src/main/java/dev/rheinsw/server/Main.java
deleted file mode 100644
index c6143a9..0000000
--- a/backend/server/src/main/java/dev/rheinsw/server/Main.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package dev.rheinsw.server;
-
-/**
-*
-* @author Thatsaphorn Atchariyaphap
-* @since 04.05.25
-*/public class Main {
- public static void main(String[] args) {
- System.out.println("Hello, World!");
- }
-}
\ No newline at end of file
diff --git a/backend/server/src/main/java/dev/rheinsw/server/ServerApplication.java b/backend/server/src/main/java/dev/rheinsw/server/ServerApplication.java
new file mode 100644
index 0000000..5558e76
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/ServerApplication.java
@@ -0,0 +1,21 @@
+package dev.rheinsw.server;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 04.05.25
+ */
+@EnableAsync
+@SpringBootApplication(
+ scanBasePackages = {"dev.rheinsw"}
+)
+public class ServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(ServerApplication.class, args);
+ }
+
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/ServerConfig.java b/backend/server/src/main/java/dev/rheinsw/server/ServerConfig.java
new file mode 100644
index 0000000..020ac93
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/ServerConfig.java
@@ -0,0 +1,12 @@
+package dev.rheinsw.server;
+
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 04.05.25
+ */
+@Configuration
+public class ServerConfig {
+
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/controller/contact/ContactController.java b/backend/server/src/main/java/dev/rheinsw/server/controller/contact/ContactController.java
new file mode 100644
index 0000000..351d99c
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/controller/contact/ContactController.java
@@ -0,0 +1,34 @@
+package dev.rheinsw.server.controller.contact;
+
+import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
+import dev.rheinsw.server.usecase.contact.SubmitContactUseCase;
+import lombok.AllArgsConstructor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 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 SubmitContactUseCase submitContactUseCase;
+
+ @PostMapping
+ public ResponseEntity submitContact(@RequestBody ContactRequestDto request) {
+ log.info("Received contact form from: {}", request.name());
+ return submitContactUseCase.submitContact(request);
+ }
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/controller/contact/HCaptchaValidator.java b/backend/server/src/main/java/dev/rheinsw/server/controller/contact/HCaptchaValidator.java
new file mode 100644
index 0000000..b9f90b7
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/controller/contact/HCaptchaValidator.java
@@ -0,0 +1,55 @@
+package dev.rheinsw.server.controller.contact;
+
+import dev.rheinsw.server.domain.contact.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() {{
+ 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;
+ }
+ }
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/domain/contact/config/HCaptchaConfig.java b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/config/HCaptchaConfig.java
new file mode 100644
index 0000000..4c36fbe
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/config/HCaptchaConfig.java
@@ -0,0 +1,19 @@
+package dev.rheinsw.server.domain.contact.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;
+
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequest.java b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequest.java
new file mode 100644
index 0000000..02c085e
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequest.java
@@ -0,0 +1,95 @@
+package dev.rheinsw.server.domain.contact.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;
+ }
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequestDto.java b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequestDto.java
new file mode 100644
index 0000000..3a4cba5
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/domain/contact/model/ContactRequestDto.java
@@ -0,0 +1,15 @@
+package dev.rheinsw.server.domain.contact.model;
+
+import dev.rheinsw.shared.transport.Dto;
+
+/**
+ * @param company optional
+ * @param phone optional
+ * @param website optional
+ * @param captcha required for hCaptcha validation
+ * @author Bummsa / BoomerHD / Thatsaphorn Atchariyaphap
+ * @since 21.04.25
+ */
+public record ContactRequestDto(String name, String email, String message, String company, String phone, String website,
+ String captcha) implements Dto {
+}
\ No newline at end of file
diff --git a/backend/server/src/main/java/dev/rheinsw/server/repository/contact/ContactRequestsRepo.java b/backend/server/src/main/java/dev/rheinsw/server/repository/contact/ContactRequestsRepo.java
new file mode 100644
index 0000000..94249ce
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/repository/contact/ContactRequestsRepo.java
@@ -0,0 +1,12 @@
+package dev.rheinsw.server.repository.contact;
+
+import dev.rheinsw.server.domain.contact.model.ContactRequest;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 22.04.25
+ */
+public interface ContactRequestsRepo extends JpaRepository {
+ // empty
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCase.java b/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCase.java
new file mode 100644
index 0000000..7a5ae81
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCase.java
@@ -0,0 +1,12 @@
+package dev.rheinsw.server.usecase.contact;
+
+import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
+import org.springframework.http.ResponseEntity;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 04.05.25
+ */
+public interface SubmitContactUseCase {
+ ResponseEntity submitContact(ContactRequestDto request);
+}
diff --git a/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCaseImpl.java b/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCaseImpl.java
new file mode 100644
index 0000000..504ceb2
--- /dev/null
+++ b/backend/server/src/main/java/dev/rheinsw/server/usecase/contact/SubmitContactUseCaseImpl.java
@@ -0,0 +1,114 @@
+package dev.rheinsw.server.usecase.contact;
+
+import dev.rheinsw.server.domain.contact.model.ContactRequest;
+import dev.rheinsw.server.domain.contact.model.ContactRequestDto;
+import dev.rheinsw.server.repository.contact.ContactRequestsRepo;
+import dev.rheinsw.shared.mail.MailServiceClient;
+import dev.rheinsw.server.controller.contact.HCaptchaValidator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+/**
+ * @author Thatsaphorn Atchariyaphap
+ * @since 04.05.25
+ */
+@Service
+public class SubmitContactUseCaseImpl implements SubmitContactUseCase {
+
+ private static final Logger log = LoggerFactory.getLogger(SubmitContactUseCaseImpl.class);
+
+ private final HCaptchaValidator captchaValidator;
+ private final ContactRequestsRepo contactRepository;
+ private final MailServiceClient mailServiceClient;
+
+ public SubmitContactUseCaseImpl(HCaptchaValidator captchaValidator, ContactRequestsRepo contactRepository, MailServiceClient mailServiceClient) {
+ this.captchaValidator = captchaValidator;
+ this.contactRepository = contactRepository;
+ this.mailServiceClient = mailServiceClient;
+ }
+
+ @Override
+ public ResponseEntity submitContact(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 : "-";
+ }
+}
\ No newline at end of file
diff --git a/backend/server/src/main/resources/application.yml b/backend/server/src/main/resources/application.yml
new file mode 100644
index 0000000..43158ce
--- /dev/null
+++ b/backend/server/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+server:
+ port: 0 # random port
+
+spring:
+ application:
+ name: server
+ 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
\ No newline at end of file