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