diff --git a/.gitignore b/.gitignore index b734735..e026c93 100644 --- a/.gitignore +++ b/.gitignore @@ -157,3 +157,6 @@ gradle-app.setting .classpath gradle.properties + +# frp config +frpc.toml diff --git a/build.gradle.kts b/build.gradle.kts index 0ce2b78..878912f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.onixbyte.identityGenerator) implementation(libs.onixbyte.captcha) implementation(libs.onixbyte.regions) + implementation(libs.onixbyte.cryptoToolbox) implementation(libs.jwt.core) implementation(libs.spring.boot.configurationProcessor) implementation(libs.spring.boot.actuator) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89621f5..d9650f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1" postgresDriverVersion = "42.7.9" h2Version = "2.2.224" springBootVersion = "3.5.13" -springSecurityVersion = "6.5.2" reactorVersion = "3.7.8" junitPlatformVersion = "1.12.2" -onixbyteVersion = "3.3.0" +onixbyteVersion = "3.4.0" onixbyteCaptcha = "1.1.0" onixbyteRegions = "2025.12.0" awsSdkVersion = "2.25.48" @@ -32,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" } onixbyte-tuple = { group = "com.onixbyte", name = "tuple" } onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" } +onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" } onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" } onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" } onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } @@ -42,12 +42,10 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" } hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" } postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } -mysql-driver = { group = "com.mysql", name = "mysql-connector-j" } h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" } spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" } flyway-core = { group = "org.flywaydb", name = "flyway-core" } flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" } -flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" } # Spring Boot Core & Web spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index a29d2b5..6bba2db 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,5 +1,6 @@ package com.onixbyte.deltaforceguide.config; +import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor; import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor; import com.onixbyte.deltaforceguide.properties.AppProperties; import org.springframework.beans.factory.annotation.Autowired; @@ -14,13 +15,21 @@ public class AppConfig implements WebMvcConfigurer { private final TrafficInterceptor trafficInterceptor; + private final GitHubWebhookInterceptor gitHubWebhookInterceptor; + @Autowired - public AppConfig(TrafficInterceptor trafficInterceptor) { + public AppConfig( + TrafficInterceptor trafficInterceptor, + GitHubWebhookInterceptor gitHubWebhookInterceptor + ) { this.trafficInterceptor = trafficInterceptor; + this.gitHubWebhookInterceptor = gitHubWebhookInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(trafficInterceptor); + registry.addInterceptor(gitHubWebhookInterceptor) + .addPathPatterns("/webhooks/github"); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java new file mode 100644 index 0000000..b0c3e0f --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/FilterConfig.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.filter.WebhookFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class FilterConfig { + + @Bean + public FilterRegistrationBean webhookFilter(WebhookFilter webhookFilter) { + var registrationBean = new FilterRegistrationBean(); + + registrationBean.setFilter(webhookFilter); + registrationBean.addUrlPatterns("/webhooks/*"); + registrationBean.setOrder(1); + + return registrationBean; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java new file mode 100644 index 0000000..1cb1a6c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/WebhookConfig.java @@ -0,0 +1,10 @@ +package com.onixbyte.deltaforceguide.config; + +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(WebhookProperties.class) +public class WebhookConfig { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java new file mode 100644 index 0000000..580e1f9 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GitHubWebhookController.java @@ -0,0 +1,43 @@ +package com.onixbyte.deltaforceguide.controller; + +import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest; +import com.onixbyte.deltaforceguide.service.WebhookService; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/webhooks/github") +public class GitHubWebhookController { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookController.class); + + private final WebhookService webhookService; + + public GitHubWebhookController(WebhookService webhookService) { + this.webhookService = webhookService; + } + + @PostMapping + public ResponseEntity handleWebhook( + @RequestHeader(GitHubWebhookHeader.EVENT) String event, + @RequestBody GitHubIssueRequest request + ) { + if (!"issues".equals(event)) { + log.debug("Ignoring non-issue event: {}", event); + return ResponseEntity.ok().build(); + } + if (!"opened".equals(request.action())) { + log.debug("Ignoring issue action: {}", request.action()); + return ResponseEntity.ok().build(); + } + webhookService.processIssueEvent(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java new file mode 100644 index 0000000..8c4e1a6 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubIssueRequest.java @@ -0,0 +1,15 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubIssueRequest( + String action, + GitHubWebhookIssue issue, + GitHubWebhookRepository repository, + GitHubWebhookSender sender +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java new file mode 100644 index 0000000..91263cc --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookIssue.java @@ -0,0 +1,20 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookIssue( + String url, + Long id, + Long number, + String title, + String body, + List labels +) { + +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java new file mode 100644 index 0000000..33954ab --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookLabel.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookLabel( + String name +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java new file mode 100644 index 0000000..e3858d6 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookRepository.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookRepository( + String fullName +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java new file mode 100644 index 0000000..416891e --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/GitHubWebhookSender.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.domain.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public record GitHubWebhookSender( + String login +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java index fc9802d..cde914d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Accessory.java @@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.domain.entity; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** * Entity representing an accessory attached to a modification, stored as JSONB. @@ -50,4 +51,22 @@ public class Accessory { public void removeTuning(Tuning tuning) { this.tunings.remove(tuning); } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Accessory accessory)) { + return false; + } + return Objects.equals(slotName, accessory.slotName) + && Objects.equals(accessoryName, accessory.accessoryName) + && Objects.equals(tunings, accessory.tunings); + } + + @Override + public int hashCode() { + return Objects.hash(slotName, accessoryName, tunings); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java index 102ea63..a4eb1f1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Tuning.java @@ -1,5 +1,7 @@ package com.onixbyte.deltaforceguide.domain.entity; +import java.util.Objects; + /** * Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory. * @@ -28,4 +30,21 @@ public class Tuning { public void setTuningValue(Double tuningValue) { this.tuningValue = tuningValue; } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Tuning tuning)) { + return false; + } + return Objects.equals(tuningName, tuning.tuningName) + && Objects.equals(tuningValue, tuning.tuningValue); + } + + @Override + public int hashCode() { + return Objects.hash(tuningName, tuningValue); + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java new file mode 100644 index 0000000..ddfd071 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/WebhookFilter.java @@ -0,0 +1,22 @@ +package com.onixbyte.deltaforceguide.filter; + +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class WebhookFilter implements Filter { + @Override + public void doFilter( + ServletRequest request, + ServletResponse response, + FilterChain chain + ) throws IOException, ServletException { + var httpRequest = (HttpServletRequest) request; + var wrappedRequest = new RepeatedlyReadRequestWrapper(httpRequest); + chain.doFilter(wrappedRequest, response); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java new file mode 100644 index 0000000..9aacbe7 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/GitHubWebhookInterceptor.java @@ -0,0 +1,83 @@ +package com.onixbyte.deltaforceguide.interceptor; + +import com.onixbyte.crypto.util.CryptoUtil; +import com.onixbyte.deltaforceguide.exeption.BizException; +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import com.onixbyte.deltaforceguide.shared.GitHubWebhookHeader; +import com.onixbyte.deltaforceguide.wrapper.RepeatedlyReadRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * Verifies GitHub webhook requests by validating the {@code X-Hub-Signature-256} + * header against the configured secret using HMAC-SHA256. + * + *

Verification is skipped when no secret is configured. The signature format is + * {@code sha256=} as documented by GitHub. + */ +@Component +public class GitHubWebhookInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(GitHubWebhookInterceptor.class); + + private final WebhookManager webhookManager; + + public GitHubWebhookInterceptor(WebhookManager webhookManager) { + this.webhookManager = webhookManager; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(request instanceof RepeatedlyReadRequestWrapper req)) { + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Request body is not readable"); + } + + var secret = webhookManager.github().secret(); + if (secret == null || secret.isBlank()) { + log.debug("No GitHub webhook secret configured, skipping signature verification"); + return true; + } + + var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256); + if (signatureHeader == null || signatureHeader.isBlank()) { + log.warn("Missing {} header from ip={}", + GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, + "Missing webhook signature header"); + } + + var body = req.getBodyString(); + try { + var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body); + + if (!MessageDigest.isEqual( + computed.getBytes(StandardCharsets.UTF_8), + signatureHeader.getBytes(StandardCharsets.UTF_8))) { + log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr()); + throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature"); + } + + return true; + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + log.error("Failed to compute HMAC-SHA256", e); + throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, + "Failed to verify webhook signature"); + } + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java new file mode 100644 index 0000000..f7defab --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/ModificationManager.java @@ -0,0 +1,130 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.domain.entity.Accessory; +import com.onixbyte.deltaforceguide.domain.entity.Firearm; +import com.onixbyte.deltaforceguide.domain.entity.Modification; +import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest; +import com.onixbyte.deltaforceguide.domain.dto.ModificationResponse; +import com.onixbyte.deltaforceguide.domain.dto.TuningRequest; +import com.onixbyte.deltaforceguide.repository.FirearmRepository; +import com.onixbyte.deltaforceguide.repository.ModificationRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +@Component +public class ModificationManager { + + private final ModificationRepository modificationRepository; + private final FirearmRepository firearmRepository; + + public ModificationManager( + ModificationRepository modificationRepository, + FirearmRepository firearmRepository + ) { + this.modificationRepository = modificationRepository; + this.firearmRepository = firearmRepository; + } + + @Transactional + public ModificationResponse create(ModificationRequest request) { + var firearm = firearmRepository.findById(request.firearmId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found: " + request.firearmId())); + var modification = toEntity(request, firearm); + return ModificationResponse.from(modificationRepository.save(modification)); + } + + @Transactional + public List batchCreate(List requests) { + var firearmIds = requests.stream() + .map(ModificationRequest::firearmId) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + + Map firearmMap = new HashMap<>(); + firearmRepository.findAllById(firearmIds) + .forEach(firearm -> firearmMap.put(firearm.getId(), firearm)); + + if (firearmMap.size() != firearmIds.size()) { + var missing = firearmIds.stream() + .filter((id) -> !firearmMap.containsKey(id)) + .toList(); + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found: " + missing); + } + + var modifications = requests.stream() + .map(req -> toEntity(req, firearmMap.get(req.firearmId()))) + .toList(); + return modificationRepository.saveAll(modifications) + .stream() + .map(ModificationResponse::from) + .toList(); + } + + public Long resolveFirearmId(Long firearmId, String firearmName) { + if (firearmId != null) { + return firearmId; + } + if (firearmName == null || firearmName.isBlank()) { + return null; + } + var matches = firearmRepository.findByName(firearmName); + if (matches.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, + "Firearm not found by name: " + firearmName); + } + return matches.getFirst().getId(); + } + + private Modification toEntity(ModificationRequest request, Firearm firearm) { + return Modification.builder() + .firearm(firearm) + .name(request.name()) + .code(request.code()) + .tags(request.tags()) + .accessories(toAccessories(request.accessories())) + .note(request.note()) + .author(request.author()) + .videoUrl(request.videoUrl()) + .build(); + } + + private List toAccessories(List requests) { + if (requests == null) { + return new ArrayList<>(); + } + return requests.stream().map(this::toAccessory).toList(); + } + + private Accessory toAccessory(AccessoryRequest request) { + var accessory = new Accessory(); + accessory.setSlotName(request.slotName()); + accessory.setAccessoryName(request.accessoryName()); + accessory.setTunings(toTunings(request.tunings())); + return accessory; + } + + private List toTunings(List requests) { + if (requests == null) { + return new ArrayList<>(); + } + return requests.stream().map(this::toTuning).toList(); + } + + private Tuning toTuning(TuningRequest request) { + var tuning = new Tuning(); + tuning.setTuningName(request.tuningName()); + tuning.setTuningValue(request.tuningValue()); + return tuning; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java new file mode 100644 index 0000000..1e63f36 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -0,0 +1,21 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties; +import com.onixbyte.deltaforceguide.properties.WebhookProperties; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class WebhookManager { + + private final WebhookProperties webhookProperties; + + @Autowired + public WebhookManager(WebhookProperties webhookProperties) { + this.webhookProperties = webhookProperties; + } + + public GitHubWebhookProperties github() { + return webhookProperties.github(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java new file mode 100644 index 0000000..bf91c6a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import java.util.List; + +public record GitHubWebhookProperties( + String secret, + List allowedUsers +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java new file mode 100644 index 0000000..d420105 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/WebhookProperties.java @@ -0,0 +1,9 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.webhook") +public record WebhookProperties( + GitHubWebhookProperties github +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index e9ad930..46c84b9 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -7,6 +7,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; + /** * Spring Data JPA repository for {@link Firearm} entity operations. * @@ -16,5 +18,7 @@ import org.springframework.stereotype.Repository; public interface FirearmRepository extends JpaRepository { Page findAllByType(FirearmType type, Pageable pageable); + + List findByName(String name); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 7a9657e..9b97e8f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java @@ -9,6 +9,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +import com.onixbyte.deltaforceguide.manager.ModificationManager; import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository; import com.fasterxml.jackson.core.JsonProcessingException; @@ -20,10 +21,8 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.util.ArrayList; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; -import java.util.Map; import java.util.Set; /** @@ -36,15 +35,18 @@ public class ModificationService { private final ModificationRepository modificationRepository; private final FirearmRepository firearmRepository; + private final ModificationManager modificationManager; private final ObjectMapper objectMapper; public ModificationService( ModificationRepository modificationRepository, FirearmRepository firearmRepository, + ModificationManager modificationManager, ObjectMapper objectMapper ) { this.modificationRepository = modificationRepository; this.firearmRepository = firearmRepository; + this.modificationManager = modificationManager; this.objectMapper = objectMapper; } @@ -105,11 +107,7 @@ public class ModificationService { * @return the created modification response */ public ModificationResponse create(ModificationRequest request) { - Firearm firearm = firearmRepository.findById(request.firearmId()) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId())); - - Modification modification = toEntity(request, firearm); - return ModificationResponse.from(modificationRepository.save(modification)); + return modificationManager.create(request); } /** @@ -119,27 +117,7 @@ public class ModificationService { * @return list of created modification responses */ public List batchCreate(List requests) { - Set firearmIds = requests.stream() - .map(ModificationRequest::firearmId) - .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); - - Map firearmMap = new HashMap<>(); - firearmRepository.findAllById(firearmIds).forEach(firearm -> firearmMap.put(firearm.getId(), firearm)); - - if (firearmMap.size() != firearmIds.size()) { - List missingFirearmIds = firearmIds.stream() - .filter(id -> !firearmMap.containsKey(id)) - .toList(); - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + missingFirearmIds); - } - - List modifications = requests.stream() - .map(request -> toEntity(request, firearmMap.get(request.firearmId()))) - .toList(); - return modificationRepository.saveAll(modifications) - .stream() - .map(ModificationResponse::from) - .toList(); + return modificationManager.batchCreate(requests); } /** @@ -200,19 +178,6 @@ public class ModificationService { modificationRepository.deleteAllInBatch(modifications); } - private Modification toEntity(ModificationRequest request, Firearm firearm) { - return Modification.builder() - .firearm(firearm) - .name(request.name()) - .code(request.code()) - .tags(safeTags(request.tags())) - .accessories(toAccessories(request.accessories())) - .note(request.note()) - .author(request.author()) - .videoUrl(request.videoUrl()) - .build(); - } - private List safeTags(List tags) { return tags == null ? new ArrayList<>() : tags; } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java new file mode 100644 index 0000000..3476958 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -0,0 +1,220 @@ +package com.onixbyte.deltaforceguide.service; + +import com.onixbyte.deltaforceguide.domain.dto.*; +import com.onixbyte.deltaforceguide.manager.ModificationManager; +import com.onixbyte.deltaforceguide.manager.WebhookManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import com.onixbyte.deltaforceguide.exeption.BizException; +import org.yaml.snakeyaml.Yaml; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +@Service +public class WebhookService { + + private static final Logger log = LoggerFactory.getLogger(WebhookService.class); + private static final String TRIGGER_LABEL = "weapon-mod"; + private static final Duration DEDUP_TTL = Duration.ofHours(12); + private static final Pattern YAML_FENCE = + Pattern.compile("```ya?ml\\s*\\R(.*?)```", Pattern.DOTALL); + + private final ModificationManager modificationManager; + private final RedisTemplate redisTemplate; + private final WebhookManager webhookManager; + private final Yaml yaml; + + public WebhookService( + ModificationManager modificationManager, + RedisTemplate redisTemplate, + WebhookManager webhookManager + ) { + this.modificationManager = modificationManager; + this.redisTemplate = redisTemplate; + this.webhookManager = webhookManager; + this.yaml = new Yaml(); + } + + public void processIssueEvent(GitHubIssueRequest request) { + var issue = request.issue(); + var repository = request.repository(); + var sender = request.sender(); + + if (!isAllowedSender(sender)) { + log.info("Issue #{} sender={} not in allowed-users, skipping", + issue.number(), sender != null ? sender.login() : "null"); + return; + } + + if (!hasTriggerLabel(issue.labels())) { + log.debug("Issue #{} lacks trigger label, skipping", issue.number()); + return; + } + + var dedupKey = "github:webhook:processed:%s:%d" + .formatted(repository.fullName(), issue.number()); + var acquired = redisTemplate.opsForValue() + .setIfAbsent(dedupKey, "1", DEDUP_TTL); + if (acquired == null || !acquired) { + log.info("Issue #{} already processed, skipping", issue.number()); + return; + } + + var parsedYaml = extractYaml(issue.body()); + if (parsedYaml == null) { + log.warn("No YAML block found in issue #{} body", issue.number()); + return; + } + + @SuppressWarnings("unchecked") + var data = (Map) yaml.load(parsedYaml); + if (data == null) { + log.warn("Empty YAML block in issue #{}", issue.number()); + return; + } + + if (data.containsKey("modifications")) { + processBatch(issue.number(), data); + } else { + processSingle(issue.number(), data); + } + } + + private void processSingle(Long issueNumber, Map data) { + var request = mapToRequest(data); + log.info("Creating modification from issue #{}: name={}", issueNumber, request.name()); + modificationManager.create(request); + } + + @SuppressWarnings("unchecked") + private void processBatch(Long issueNumber, Map data) { + var list = (List>) data.get("modifications"); + if (list == null || list.isEmpty()) { + log.warn("Empty modifications list in issue #{}", issueNumber); + return; + } + var requests = list.stream() + .map(this::mapToRequest) + .toList(); + log.info("Batch creating {} modifications from issue #{}", requests.size(), issueNumber); + modificationManager.batchCreate(requests); + } + + private ModificationRequest mapToRequest(Map data) { + Long firearmId = modificationManager.resolveFirearmId( + toLong(data.get("firearmId")), + (String) data.get("firearmName")); + if (firearmId == null) { + throw new BizException(HttpStatus.BAD_REQUEST, + "YAML must contain firearmId or firearmName"); + } + String name = (String) data.get("name"); + String code = (String) data.get("code"); + List tags = toStringList(data.get("tags")); + List accessories = mapAccessories(data.get("accessories")); + String note = (String) data.get("note"); + String author = (String) data.get("author"); + String videoUrl = (String) data.get("videoUrl"); + + return new ModificationRequest(firearmId, name, code, tags, accessories, + note, author, videoUrl); + } + + private List mapAccessories(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new AccessoryRequest( + (String) map.get("slotName"), + (String) map.get("accessoryName"), + mapTunings(map.get("tunings")) + )); + } + } + return result; + } + + private List mapTunings(Object raw) { + if (!(raw instanceof List list)) { + return new ArrayList<>(); + } + var result = new ArrayList(); + for (var item : list) { + if (item instanceof Map map) { + result.add(new TuningRequest( + (String) map.get("tuningName"), + toDouble(map.get("tuningValue")) + )); + } + } + return result; + } + + private List toStringList(Object raw) { + if (raw instanceof List list) { + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + return new ArrayList<>(); + } + + private boolean isAllowedSender( + GitHubWebhookSender sender + ) { + var allowedUsers = webhookManager.github().allowedUsers(); + if (allowedUsers == null || allowedUsers.isEmpty()) { + return true; + } + if (sender == null || sender.login() == null) { + return false; + } + return allowedUsers.contains(sender.login()); + } + + private boolean hasTriggerLabel(List labels) { + if (labels == null) { + return false; + } + return labels.stream().anyMatch(label -> TRIGGER_LABEL.equals(label.name())); + } + + private String extractYaml(String body) { + if (body == null) { + return null; + } + var matcher = YAML_FENCE.matcher(body); + return matcher.find() ? matcher.group(1) : null; + } + + private Long toLong(Object value) { + if (value instanceof Number num) { + return num.longValue(); + } + if (value instanceof String s) { + return Long.parseLong(s); + } + return null; + } + + private Double toDouble(Object value) { + if (value instanceof Number num) { + return num.doubleValue(); + } + if (value instanceof String s) { + return Double.parseDouble(s); + } + return null; + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java b/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java new file mode 100644 index 0000000..05b9d58 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/GitHubWebhookHeader.java @@ -0,0 +1,56 @@ +package com.onixbyte.deltaforceguide.shared; + +/** + * This class lists the header names that GitHub sends in webhook requests. + * + * @author siujamo + */ +public class GitHubWebhookHeader { + + /** + * The unique identifier of the webhook. + */ + public static final String HOOK_ID = "X-GitHub-Hook-ID"; + + /** + * The name of the event that triggered the delivery. + */ + public static final String EVENT = "X-GitHub-Event"; + + /** + * A globally unique identifier (GUID) to identify the event. + */ + public static final String DELIVERY = "X-GitHub-Delivery"; + + /** + * This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex + * digest of the request body, and is generated using the SHA-1 hash function and the secret as + * the HMAC {@code key}. {@code X-Hub-Signature} is provided for compatibility with + * existing integrations. We recommend that you use the more secure + * {@code X-Hub-Signature-256} instead. + */ + public static final String SIGNATURE = "X-Hub-Signature"; + + /** + * This header is sent if the webhook is configured with a {@code secret}. This is the HMAC hex + * digest of the request body, and is generated using the SHA-256 hash function and the + * {@code secret} as the HMAC key. For more information, see Validating webhook deliveries. + */ + public static final String SIGNATURE_256 = "X-Hub-Signature-256"; + + /** + * This header will always have the prefix {@code GitHub-Hookshot/}. + */ + public static final String USER_AGENT = "User-Agent"; + + /** + * The type of resource where the webhook was created. + */ + public static final String INSTALLATION_TARGET_TYPE = "X-GitHub-Hook-Installation-Target-Type"; + + /** + * The unique identifier of the resource where the webhook was created. + */ + public static final String INSTALLATION_TARGET_ID = "X-GitHub-Hook-Installation-Target-ID"; +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java new file mode 100644 index 0000000..ee31c22 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/wrapper/RepeatedlyReadRequestWrapper.java @@ -0,0 +1,56 @@ +package com.onixbyte.deltaforceguide.wrapper; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.springframework.util.StreamUtils; +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public class RepeatedlyReadRequestWrapper extends HttpServletRequestWrapper { + + private final byte[] body; + + public RepeatedlyReadRequestWrapper(HttpServletRequest request) throws IOException { + super(request); + this.body = StreamUtils.copyToByteArray(request.getInputStream()); + } + + @Override + public ServletInputStream getInputStream() { + final var byteArrayInputStream = new ByteArrayInputStream(body); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() throws IOException { + return byteArrayInputStream.read(); + } + }; + } + + @Override + public BufferedReader getReader() throws IOException { + return new BufferedReader(new InputStreamReader(this.getInputStream(), StandardCharsets.UTF_8)); + } + + public String getBodyString() { + return new String(body, StandardCharsets.UTF_8); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 66611ee..74de069 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -39,6 +39,12 @@ mybatis: type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler mapper-locations: classpath:/mapper/*.xml +app: + webhook: + github: + secret: ${GITHUB_WEBHOOK_SECRET:} + allowed-users: [] + logging: level: org.hibernate: