diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml new file mode 100644 index 0000000..1d83a90 --- /dev/null +++ b/.gitea/workflows/build-and-deploy.yml @@ -0,0 +1,72 @@ +name: Build and Deploy + +on: + release: + types: [published] + +env: + APP_NAME: delta-force-guide-server + +jobs: + build-and-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 (Corretto) + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: corretto + cache: gradle + + - name: Build with Gradle + run: > + ./gradlew bootJar -x test + -PartefactVersion="${{ gitea.event.release.tag_name }}" + -PbuildChannel=stable + + - name: Resolve JAR file path + id: jar + run: | + JAR_PATH=$(find build/libs -name '*.jar' | head -1) + echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT" + + - name: Upload JAR to Gitea Release + run: | + TAG="${{ gitea.event.release.tag_name }}" + FILE="${{ steps.jar.outputs.file }}" + ASSET_NAME="${APP_NAME}-${TAG}.jar" + curl -X POST \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -H "Content-Type: multipart/form-data" \ + -F "attachment=@${FILE};filename=${ASSET_NAME}" \ + "${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases/${{ gitea.event.release.id }}/assets?name=${ASSET_NAME}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Generate image tags + id: meta + run: | + DOCKERHUB_USER="${{ secrets.DOCKER_HUB_USERNAME }}" + REPO_NAME=$(echo "${{ gitea.repository.name }}" | tr '[:upper:]' '[:lower:]') + echo "tag_version=${DOCKERHUB_USER}/${REPO_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT" + echo "tag_latest=${DOCKERHUB_USER}/${REPO_NAME}:latest" >> "$GITHUB_OUTPUT" + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.ci + build-args: JAR_FILE=${{ steps.jar.outputs.file }} + push: true + tags: | + ${{ steps.meta.outputs.tag_version }} + ${{ steps.meta.outputs.tag_latest }} diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml deleted file mode 100644 index 116f12f..0000000 --- a/.github/workflows/build-and-deploy.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Build and Deploy - -on: - release: - types: [published] - -env: - APP_NAME: delta-force-guide-server - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -jobs: - # ================================================================ - # Single Job: Build, Upload JAR to Release, and Push to GHCR - # ================================================================ - build-and-release: - runs-on: ubuntu-latest - permissions: - contents: write - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Set up JDK 21 (Corretto) - uses: actions/setup-java@v4 - with: - java-version: 21 - distribution: corretto - cache: gradle - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - - # 使用 Release Tag 做为 Gradle 属性传入 - - name: Build with Gradle - run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}" - - - name: Resolve JAR file path - id: jar - run: | - JAR_PATH=$(find build/libs -name '*.jar' | head -1) - echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT" - - # 上传 JAR 包到 GitHub Release 中 - - name: Upload JAR to GitHub Release - uses: svenstaro/upload-release-action@v2 - with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ steps.jar.outputs.file }} - asset_name: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }}.jar - tag: ${{ github.event.release.tag_name }} - overwrite: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # 登录到 GitHub Container Registry (GHCR) - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # 镜像打标签准备 - - name: Generate image tags - id: meta - run: | - OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') - REPO_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]') - echo "tag_version=ghcr.io/$OWNER_LC/$REPO_LC:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - echo "tag_latest=ghcr.io/$OWNER_LC/$REPO_LC:latest" >> "$GITHUB_OUTPUT" - - # 构建并上传镜像到 GHCR - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - file: Dockerfile.ci - build-args: JAR_FILE=${{ steps.jar.outputs.file }} - push: true - tags: | - ${{ steps.meta.outputs.tag_version }} - ${{ steps.meta.outputs.tag_latest }} - cache-from: type=gha - cache-to: type=gha,mode=max - 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..a98947a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,8 @@ plugins { } val artefactVersion: String by project +val buildChannel: String by project +val vendor: String by project group = "com.onixbyte.helix" version = artefactVersion @@ -36,6 +38,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) @@ -60,6 +63,16 @@ dependencies { testRuntimeOnly(libs.junit.launcher) } +tasks.processResources { + filesMatching("application.yaml") { + expand( + "appVersion" to artefactVersion, + "channel" to buildChannel, + "vendor" to vendor + ) + } +} + tasks.test { useJUnitPlatform() } 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/DeltaForceGuideApplication.java b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java index 4d69512..a23fb88 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/DeltaForceGuideApplication.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** + * Entry point for the Delta Force Guide Server application. + * + * @author zihluwang + */ @SpringBootApplication public class DeltaForceGuideApplication { diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java index d4b745c..a7ad3f1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java +++ b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java @@ -12,6 +12,11 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +/** + * Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library. + * + * @author zihluwang + */ @Component public class TokenClient { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java index 32ec283..6bba2db 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java @@ -1,10 +1,35 @@ 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; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration @EnableConfigurationProperties(AppProperties.class) -public class AppConfig { +public class AppConfig implements WebMvcConfigurer { + + private final TrafficInterceptor trafficInterceptor; + + private final GitHubWebhookInterceptor gitHubWebhookInterceptor; + + @Autowired + 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/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java index 291695b..1e13243 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; import java.util.stream.Stream; +/** + * Configuration for CORS (Cross-Origin Resource Sharing) policies. + * + * @author zihluwang + */ @Configuration @EnableConfigurationProperties({CorsProperties.class}) public class CorsConfig { + /** + * Creates the CORS configuration source with properties from configuration. + * + * @param properties the CORS configuration properties + * @return the CORS configuration source + */ @Bean public CorsConfigurationSource corsConfigurationSource( CorsProperties properties 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..893852a --- /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 webhookFilterBean(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/JacksonConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java index a64a1ea..895bcd1 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/JacksonConfig.java @@ -6,6 +6,11 @@ import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilde import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Configuration for Jackson JSON serialisation and deserialisation settings. + * + * @author zihluwang + */ @Configuration public class JacksonConfig { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java index 61acbfc..0e2ae74 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/MyBatisConfig.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config; import org.mybatis.spring.annotation.MapperScan; import org.springframework.context.annotation.Configuration; +/** + * Configuration for MyBatis SQL mapping framework integration. + * + * @author zihluwang + */ @Configuration @MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"}) public class MyBatisConfig { diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java new file mode 100644 index 0000000..9f47fe1 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/OpenApiConfiguration.java @@ -0,0 +1,26 @@ +package com.onixbyte.deltaforceguide.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import org.springframework.context.annotation.Configuration; + +@OpenAPIDefinition( + info = @Info( + title = "Delta Force Guide Server", + description = "API for managing Delta Force game firearm builds", + version = "1.3.4", + contact = @Contact( + name = "Zihlu Wang", + email = "zihlu.wang@onixbyte.com" + ), + license = @License( + name = "MIT", + url = "https://git.onixbyte.cn/onixbyte/delta-force-guide-server/-/raw/main/LICENCE" + ) + ) +) +@Configuration +public class OpenApiConfiguration { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java index 8e8cc50..b32a691 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -23,12 +23,23 @@ import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.web.cors.CorsConfigurationSource; +/** + * Spring Security configuration defining authentication, authorisation, and filter chains. + * + * @author zihluwang + */ @Configuration @EnableWebSecurity @EnableMethodSecurity @EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) public class SecurityConfig { + /** + * Configures the HTTP security filter chain including endpoint authorisation and JWT filter. + * + * @param http the HTTP security builder + * @return the configured security filter chain + */ @Bean public SecurityFilterChain securityFilterChain( HttpSecurity httpSecurity, @@ -48,11 +59,20 @@ public class SecurityConfig { .build(); } + /** + * Provides the BCrypt password encoder for credential hashing. + * @return the BCrypt password encoder + */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + /** + * Provides the authentication manager for the security configuration. + * + * @return the authentication manager + */ @Bean public AuthenticationManager authenticationManager( UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java index 44e3a4c..1dfe3ff 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SpringDataConfig.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +/** + * Configuration for Spring Data JPA auditing and repository settings. + * + * @author zihluwang + */ @Configuration @EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"}) public class SpringDataConfig { 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..d9c5db7 --- /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.GitHubWebhookProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({GitHubWebhookProperties.class}) +public class WebhookConfig { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java index 86229aa..db0f42e 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/AuthController.java @@ -18,7 +18,13 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.time.Duration; +import java.time.LocalDateTime; +/** + * REST controller for user authentication endpoints (login, logout). + * + * @author zihluwang + */ @Tag(name = "用户鉴权", description = "处理用户登录与退出功能") @RestController @RequestMapping("/auth") @@ -38,12 +44,14 @@ public class AuthController { @PostMapping("/login") public ResponseEntity login(@Validated @RequestBody LoginRequest request) { var user = authService.login(request); + var currentTime = LocalDateTime.now(); var accessToken = tokenClient.generateToken(user); var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); + var cookieMaxAge = accessTokenCookie.getMaxAge(); return ResponseEntity.ok() .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) - .body(UserResponse.from(user)); + .body(UserResponse.from(user, currentTime.plus(cookieMaxAge))); } @RequiresAuth diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java index f817f66..527e730 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/DailyPasswordController.java @@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +/** + * REST controller for retrieving daily-generated passwords. + * + * @author zihluwang + */ @Tag(name = "每日密码", description = "获取每日密码信息") @RestController @RequestMapping("/daily-passwords") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java index 7d95b69..4843c2d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/FirearmController.java @@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +/** + * REST controller for firearm CRUD operations. + * + * @author zihluwang + */ @Tag(name = "武器管理", description = "与武器有关的操作") @RestController @RequestMapping("/firearms") 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/controller/GlobalExceptionHandler.java b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java index c0b19f1..4edd154 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/GlobalExceptionHandler.java @@ -6,6 +6,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * Global exception handler that translates exceptions into standard error responses. + * + * @author zihluwang + */ @RestControllerAdvice public class GlobalExceptionHandler { diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java index b7c7a8d..7eaf94c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/ModificationController.java @@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +/** + * REST controller for modification CRUD operations, including batch creation and deletion. + * + * @author zihluwang + */ @Tag(name = "改装管理", description = "对枪械改装的管理") @RestController @RequestMapping("/modifications") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java index e22097d..b9f4e59 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/TagController.java @@ -10,6 +10,11 @@ import com.onixbyte.deltaforceguide.service.ModificationService; import java.util.List; +/** + * REST controller for retrieving available modification tags. + * + * @author zihluwang + */ @Tag(name = "标签管理", description = "管理标签信息") @RestController @RequestMapping("/tags") diff --git a/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java index a73b43c..67a08ef 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java +++ b/src/main/java/com/onixbyte/deltaforceguide/controller/VersionController.java @@ -2,10 +2,12 @@ package com.onixbyte.deltaforceguide.controller; import com.onixbyte.deltaforceguide.service.AppService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "版本信息") @RestController @RequestMapping("/versions") public class VersionController { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java index f6f46f8..8108db3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/converter/FirearmTypeConverter.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; +/** + * JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation. + * + * @author zihluwang + */ @Converter public class FirearmTypeConverter implements AttributeConverter { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java index 29fa638..b94b61c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryRequest.java @@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank; import java.util.ArrayList; import java.util.List; +/** + * Request DTO for creating or updating an accessory attached to a modification. + * + * @author zihluwang + */ public record AccessoryRequest( @NotBlank(message = "插槽名称不能为空") String slotName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java index 421410d..ade3eac 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/AccessoryResponse.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory; import java.util.List; +/** + * Response DTO for an accessory attached to a modification. + * + * @author zihluwang + */ public record AccessoryResponse( String slotName, String accessoryName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java index 2a381ad..edfa9c0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPassword.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * DTO representing a single daily-generated password for a map. + * + * @author zihluwang + */ public record DailyPassword( String mapName, String password diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java index c685542..5e3c12f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordData.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import java.time.LocalDateTime; import java.util.List; +/** + * DTO containing daily password data including update information and password list. + * + * @author zihluwang + */ public record DailyPasswordData( String updateDate, Integer totalCount, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java index a2fa36b..9af994f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordMetadata.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * DTO holding metadata about the daily password source and update tracking. + * + * @author zihluwang + */ public record DailyPasswordMetadata( String version, String author diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java index 7d29a27..d3ce808 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/DailyPasswordResponse.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * Response DTO wrapping daily password data with metadata. + * + * @author zihluwang + */ public record DailyPasswordResponse( String status, String message, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java index f1efbfa..11879fc 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ErrorResponse.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.domain.dto; +/** + * Standard error response body returned on API failures. + * + * @author zihluwang + */ public record ErrorResponse( String message ) { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java index 74e1993..02fb855 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmRequest.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +/** + * Request DTO for creating or updating a firearm. + * + * @author zihluwang + */ public record FirearmRequest( String name, FirearmType type, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java index eb5b1ac..0e9eaba 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/FirearmResponse.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.enumeration.FirearmType; +/** + * Response DTO for a firearm record, including associated modifications. + * + * @author zihluwang + */ public record FirearmResponse( Long id, String name, 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/dto/LoginRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java index ab820ec..106d533 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/LoginRequest.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +/** + * Login request containing principle (username/email) and credential (password). + * + * @author zihluwang + */ @Schema(description = "登录请求") public record LoginRequest( @NotBlank(message = "登录名称不能为空") diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java index cdb3d93..c0aa59f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchCreateRequest.java @@ -5,6 +5,11 @@ import jakarta.validation.constraints.NotEmpty; import java.util.List; +/** + * Request DTO for batch creation of modifications. + * + * @author zihluwang + */ public record ModificationBatchCreateRequest( @NotEmpty(message = "批量创建列表不能为空") List<@Valid ModificationRequest> modifications diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java index 7e46400..4243895 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationBatchDeleteRequest.java @@ -5,6 +5,11 @@ import jakarta.validation.constraints.Positive; import java.util.List; +/** + * Request DTO for batch deletion of modifications by ID. + * + * @author zihluwang + */ public record ModificationBatchDeleteRequest( @NotEmpty(message = "批量删除ID列表不能为空") List<@Positive(message = "ID必须为正数") Long> ids diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java index c602dbd..e4f6708 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationRequest.java @@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive; import java.util.ArrayList; import java.util.List; +/** + * Request DTO for creating or updating a modification. + * + * @author zihluwang + */ public record ModificationRequest( @NotNull(message = "武器ID不能为空") @Positive(message = "武器ID必须为正数") diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java index c4d4492..aca6ef5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/ModificationResponse.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification; import java.util.List; +/** + * Response DTO for a modification record including accessories and tags. + * + * @author zihluwang + */ public record ModificationResponse( Long id, Long firearmId, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java index 0eefb62..20738bd 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/PageResponse.java @@ -4,6 +4,11 @@ import org.springframework.data.domain.Page; import java.util.List; +/** + * Generic paginated response wrapper for list endpoints. + * + * @author zihluwang + */ public record PageResponse( List items, int page, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java index 414becd..a180eb3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningRequest.java @@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +/** + * Request DTO for a tuning adjustment on an accessory. + * + * @author zihluwang + */ public record TuningRequest( @NotBlank(message = "调校项名称不能为空") String tuningName, diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java index 3f6febf..405ecde 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/TuningResponse.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.Tuning; +/** + * Response DTO for a tuning adjustment on an accessory. + * + * @author zihluwang + */ public record TuningResponse( String tuningName, Double tuningValue diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java index 75d9188..4a2d82d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/dto/UserResponse.java @@ -2,16 +2,25 @@ package com.onixbyte.deltaforceguide.domain.dto; import com.onixbyte.deltaforceguide.domain.entity.User; +import java.time.LocalDateTime; + +/** + * Response DTO for a user account, including associated credentials. + * + * @author zihluwang + */ public record UserResponse( Long id, String username, - String email + String email, + LocalDateTime expiration ) { - public static UserResponse from(User user) { + public static UserResponse from(User user, LocalDateTime expiration) { return new UserResponse( user.getId(), user.getUsername(), - user.getEmail() + user.getEmail(), + expiration ); } } 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 8c4b6b9..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,7 +2,13 @@ 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. + * + * @author zihluwang + */ public class Accessory { private String slotName; @@ -45,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/Firearm.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java index 65942d3..c71cee7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Firearm.java @@ -15,6 +15,11 @@ import jakarta.persistence.Table; import java.util.ArrayList; import java.util.List; +/** + * Entity representing a firearm in the Delta Force game. + * + * @author zihluwang + */ @Entity @Table(name = "firearm") public class Firearm { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java index ed145b5..a1942f7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/Modification.java @@ -7,6 +7,11 @@ import org.hibernate.annotations.Type; import java.util.ArrayList; import java.util.List; +/** + * Entity representing a firearm modification or build configuration. + * + * @author zihluwang + */ @Entity @Table( name = "modification", 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 4ab9879..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,12 @@ package com.onixbyte.deltaforceguide.domain.entity; +import java.util.Objects; + +/** + * Entity representing a tuning adjustment for an accessory, stored as JSONB within Accessory. + * + * @author zihluwang + */ public class Tuning { private String tuningName; @@ -23,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/domain/entity/User.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java index 67142df..d696b2d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/User.java @@ -12,6 +12,11 @@ import jakarta.persistence.Table; import java.util.ArrayList; import java.util.List; +/** + * Entity representing an application user with authentication credentials. + * + * @author zihluwang + */ @Entity @Table(name = "app_user") public class User { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java index b8a09a6..2ecdd37 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredential.java @@ -12,6 +12,11 @@ import jakarta.persistence.MapsId; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +/** + * Entity representing a user credential linked to an authentication provider. + * + * @author zihluwang + */ @Entity @Table(name = "app_user_credential") public class UserCredential { @@ -28,7 +33,7 @@ public class UserCredential { @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user")) private User user; - @Column(name = "credential", nullable = false, length = 255) + @Column(name = "credential", nullable = false) private String credential; public UserCredentialId getId() { diff --git a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java index 57a819e..1e25f45 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java +++ b/src/main/java/com/onixbyte/deltaforceguide/domain/entity/UserCredentialId.java @@ -5,6 +5,11 @@ import jakarta.persistence.Embeddable; import java.io.Serializable; import java.util.Objects; +/** + * Composite key for the UserCredential entity, combining user ID and provider. + * + * @author zihluwang + */ @Embeddable public class UserCredentialId implements Serializable { diff --git a/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java index d4965fa..77bdbdb 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java +++ b/src/main/java/com/onixbyte/deltaforceguide/enumeration/FirearmType.java @@ -1,5 +1,11 @@ package com.onixbyte.deltaforceguide.enumeration; +/** + * Enumeration of firearm types in the Delta Force game. + * Each type is associated with an integer code used for database persistence. + * + * @author zihluwang + */ public enum FirearmType { RIFLE(0), @@ -21,6 +27,13 @@ public enum FirearmType { return code; } + /** + * Resolve a FirearmType from its integer code. + * + * @param code the integer code, may be null + * @return the corresponding FirearmType, or null if the code is null + * @throws IllegalArgumentException if the code does not match any known type + */ public static FirearmType fromCode(Integer code) { if (code == null) { return null; diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java index aad96d2..d1b7ff2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java @@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.exeption; import org.springframework.http.HttpStatus; +/** + * Custom runtime exception that carries an HTTP status code for API error responses. + * + * @author zihluwang + */ public class BizException extends RuntimeException { /** diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java index f4b9220..e2068a6 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -16,7 +16,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -29,6 +28,11 @@ import java.time.Instant; import java.util.Objects; import java.util.Optional; +/** + * Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request. + * + * @author zihluwang + */ @Component public class TokenAuthenticationFilter extends OncePerRequestFilter { @@ -52,6 +56,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter { this.handlerExceptionResolver = handlerExceptionResolver; } + /** + * Extracts JWT from httpOnly cookie, validates it, and sets the security context. + * + * @param request the HTTP request + * @param response the HTTP response + * @param filterChain the filter chain + */ @Override protected void doFilterInternal( @NonNull HttpServletRequest request, 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..c1b552a --- /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.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(body, secret); + + 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/interceptor/TrafficInterceptor.java b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java new file mode 100644 index 0000000..c7aac50 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/interceptor/TrafficInterceptor.java @@ -0,0 +1,46 @@ +package com.onixbyte.deltaforceguide.interceptor; + +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.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class TrafficInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(TrafficInterceptor.class); + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + var ip = resolveClientIp(request); + var method = request.getMethod(); + var uri = request.getRequestURI(); + var query = request.getQueryString(); + var contentType = request.getContentType(); + var contentLength = request.getContentLength(); + var userAgent = request.getHeader("User-Agent"); + + log.debug("Request method={}, uri={}, query={}, ip={}, content-type={}, content-length={}, user-agent={}", + method, uri, query, ip, contentType, contentLength, userAgent); + return true; + } + + private String resolveClientIp(HttpServletRequest request) { + var xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isBlank()) { + return xForwardedFor.split(",")[0].trim(); + } + var xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isBlank()) { + return xRealIp.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java index 64f8ddd..378fe9b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/AppManager.java @@ -18,6 +18,10 @@ public class AppManager { * @return the version string of this application */ public String getVersion() { - return appProperties.version(); + return "v%s-%s by @%s".formatted( + appProperties.version(), + appProperties.channel(), + appProperties.vendor() + ); } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java index 7bec528..f6ef16f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/CookieManager.java @@ -6,6 +6,11 @@ import org.springframework.stereotype.Component; import java.time.Duration; +/** + * Manager providing cookie construction operations with configurable properties. + * + * @author zihluwang + */ @Component public class CookieManager { diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java index 3ec28f9..6b6ac59 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/DailyPasswordManager.java @@ -17,6 +17,11 @@ import java.time.Duration; import java.time.LocalDate; import java.util.Objects; +/** + * Manager for daily password data access and caching coordination. + * + * @author zihluwang + */ @Component public class DailyPasswordManager { @@ -49,6 +54,10 @@ public class DailyPasswordManager { this.redisTemplate = redisTemplate; } + /** + * Retrieves the daily password from cache or generates a new one. + * @return the daily password response + */ public DailyPasswordResponse getDailyPassword() { var key = CACHE_KEY_PREFIX + LocalDate.now(); 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/UserCredentialManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java index f4aa494..d77c687 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserCredentialManager.java @@ -3,11 +3,15 @@ package com.onixbyte.deltaforceguide.manager; import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * Manager for user credential persistence and authentication data access. + * + * @author zihluwang + */ @Component public class UserCredentialManager { @@ -17,27 +21,52 @@ public class UserCredentialManager { this.userCredentialRepository = userCredentialRepository; } - @Transactional(readOnly = true) + /** + * Finds all credentials belonging to a specific user. + * + * @param userId the user ID + * @return list of matching credentials + */ public List findAllByUserId(Long userId) { return userCredentialRepository.findAllByUserId(userId); } - @Transactional(readOnly = true) + /** + * Finds a credential for a specific user and provider combination. + * + * @param userId the user ID + * @param provider the authentication provider + * @return the matching credential, if found + */ public Optional findByUserIdAndProvider(Long userId, String provider) { return userCredentialRepository.findByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Persists a new or updated credential. + * + * @param userCredential the credential to save + * @return the saved credential + */ public UserCredential save(UserCredential userCredential) { return userCredentialRepository.save(userCredential); } - @Transactional + /** + * Deletes a credential for a specific user and provider. + * + * @param userId the user ID + * @param provider the authentication provider + */ public void deleteByUserIdAndProvider(Long userId, String provider) { userCredentialRepository.deleteByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Deletes all credentials belonging to a user. + * + * @param userId the user ID + */ public void deleteAllByUserId(Long userId) { userCredentialRepository.deleteAllByUserId(userId); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java index fb8ac56..d63c0f7 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/UserManager.java @@ -8,6 +8,11 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; +/** + * Manager for user entity persistence and query operations. + * + * @author zihluwang + */ @Component public class UserManager { @@ -17,17 +22,30 @@ public class UserManager { this.userRepository = userRepository; } - @Transactional(readOnly = true) + /** + * Finds a user by their ID. + * + * @param id the user ID + * @return the matching user, if found + */ public Optional findById(Long id) { return userRepository.findById(id); } - @Transactional(readOnly = true) + /** + * Retrieves all registered users. + * @return list of all users + */ public List findAll() { return userRepository.findAll(); } - @Transactional(readOnly = true) + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the matching user, if found + */ public Optional findByUsername(String username) { return userRepository.findByUsername(username); } @@ -37,16 +55,31 @@ public class UserManager { return userRepository.findByEmail(email); } - @Transactional + /** + * Persists a new or updated user. + * + * @param user the user to save + * @return the saved user + */ public User save(User user) { return userRepository.save(user); } - @Transactional + /** + * Deletes a user by their ID. + * + * @param id the user ID to delete + */ public void deleteById(Long id) { userRepository.deleteById(id); } + /** + * Finds a user by their username or email address. + * + * @param principal the username or email to search for + * @return the matching user, if found + */ public Optional findByUsernameOrEmail(String principal) { return userRepository.findByUsernameOrEmail(principal); } 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..21c5b52 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/manager/WebhookManager.java @@ -0,0 +1,24 @@ +package com.onixbyte.deltaforceguide.manager; + +import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class WebhookManager { + + private final GitHubWebhookProperties gitHubWebhookProperties; + + public WebhookManager(GitHubWebhookProperties gitHubWebhookProperties) { + this.gitHubWebhookProperties = gitHubWebhookProperties; + } + + public String secret() { + return gitHubWebhookProperties.secret(); + } + + public List allowedUsers() { + return gitHubWebhookProperties.allowedUsers(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java index 79b056c..b13bca2 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/AppProperties.java @@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "app.common") public record AppProperties( - String version + String version, + String channel, + String vendor ) { } diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java index 86dab77..0cfde08 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CookieProperties.java @@ -6,6 +6,16 @@ import org.springframework.boot.web.server.Cookie; import java.time.Duration; +/** + * Configuration properties for HTTP cookies used in authentication, prefixed with "app.cookie". + * + * @param httpOnly whether the cookie is httpOnly + * @param secure whether the cookie is secure + * @param path the cookie path + * @param maxAge the maximum age of the cookie + * @param sameSite the SameSite policy for the cookie + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.cookie") public record CookieProperties( @DefaultValue("true") Boolean httpOnly, diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java index ff064d5..193640f 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/CorsProperties.java @@ -6,6 +6,18 @@ import org.springframework.http.HttpMethod; import java.time.Duration; +/** + * Configuration properties for CORS settings, prefixed with "app.cors". + * + * @param allowedHeaders headers allowed in CORS requests + * @param allowedMethods HTTP methods allowed in CORS requests + * @param allowedOrigins origins permitted to make cross-origin requests + * @param allowCredentials whether credentials are allowed in CORS requests + * @param allowPrivateNetwork whether private network access is permitted + * @param maxAge how long the CORS preflight response may be cached + * @param exposedHeaders headers exposed to the client in CORS responses + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.cors") public record CorsProperties( @DefaultValue({"Content-Type", "Authorization"}) 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..673051a --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/GitHubWebhookProperties.java @@ -0,0 +1,12 @@ +package com.onixbyte.deltaforceguide.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "app.webhook.github") +public record GitHubWebhookProperties( + String secret, + List allowedUsers +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java index 0d7a618..74c693c 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java @@ -4,6 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import java.time.Duration; +/** + * Configuration properties for JWT token generation and validation, prefixed with "app.jwt". + * + * @param issuer the JWT issuer claim + * @param secret the signing secret for JWT tokens + * @param validTime the duration for which a token remains valid + * @author zihluwang + */ @ConfigurationProperties(prefix = "app.jwt") public record TokenProperties( String issuer, diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java index dcf85f8..46c84b9 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/FirearmRepository.java @@ -7,9 +7,18 @@ 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. + * + * @author zihluwang + */ @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/repository/ModificationRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java index d4f891a..3d9fcc0 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/ModificationRepository.java @@ -13,6 +13,12 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * Spring Data JPA repository for {@link Modification} entity operations, + * including native JSONB tag filtering for Postgres. + * + * @author zihluwang + */ @Repository public interface ModificationRepository extends JpaRepository { @@ -27,6 +33,14 @@ public interface ModificationRepository extends JpaRepository findById(@NonNull Long id); + /** + * Page query modifications with optional firearm and JSONB tag filtering. + * + * @param firearmId optional firearm ID filter (nullable) + * @param tagsJson optional JSON array of tags to match via Postgres {@code @>} operator (nullable) + * @param pageable pagination information + * @return a page of matching modifications + */ @Query(value = """ SELECT * FROM modification m WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) @@ -40,6 +54,12 @@ public interface ModificationRepository extends JpaRepository pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); + /** + * Retrieve all distinct tag values from modifications, optionally filtered by firearm. + * + * @param firearmId optional firearm ID filter (nullable) + * @return list of distinct tag strings + */ @Query(value = "SELECT DISTINCT jsonb_array_elements_text(cast(tags as jsonb)) FROM modification WHERE (:firearmId IS NULL OR firearm_id = :firearmId)", nativeQuery = true) List findAllTags(@Param("firearmId") Long firearmId); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java index 94fb8ec..53776e3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java +++ b/src/main/java/com/onixbyte/deltaforceguide/repository/UserCredentialRepository.java @@ -12,9 +12,20 @@ import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; +/** + * Spring Data JPA repository for {@link UserCredential} entity operations. + * + * @author zihluwang + */ @Repository public interface UserCredentialRepository extends JpaRepository { + /** + * Find all credentials belonging to a given user. + * + * @param userId the user ID + * @return list of matching credentials + */ @EntityGraph(attributePaths = {"user"}) @Query(""" select uc @@ -23,6 +34,13 @@ public interface UserCredentialRepository extends JpaRepository findAllByUserId(@Param("userId") Long userId); + /** + * Find a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider identifier + * @return an optional containing the matching credential, or empty if not found + */ @EntityGraph(attributePaths = {"user"}) @Query(""" select uc @@ -32,6 +50,12 @@ public interface UserCredentialRepository extends JpaRepository findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); + /** + * Delete a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider identifier + */ @Modifying @Query(""" delete from UserCredential uc @@ -40,6 +64,11 @@ public interface UserCredentialRepository extends JpaRepository { @@ -28,6 +33,12 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); + /** + * Find a user by either username or email. + * + * @param principal the username or email to search for + * @return an optional containing the matching user, or empty if not found + */ @EntityGraph(attributePaths = {"credentials"}) @Query(""" select u diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java index f022be3..9b6b014 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/annotation/RequiresAuth.java @@ -7,6 +7,11 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +/** + * Annotation to mark controller endpoints that require authentication. + * + * @author zihluwang + */ @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @PreAuthorize("isAuthenticated()") diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java index 692efaf..9bd2ae3 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -8,6 +8,11 @@ import org.springframework.security.core.GrantedAuthority; import java.util.Collection; import java.util.List; +/** + * Custom authentication token for username/password-based login flows. + * + * @author zihluwang + */ public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer { private final String username; private String password; diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java index d5e9a65..01e6baa 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -17,6 +17,11 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +/** + * Authentication provider that validates username/password credentials against stored BCrypt hashes. + * + * @author zihluwang + */ @Component public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { @@ -36,6 +41,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro this.userCredentialRepository = userCredentialRepository; } + /** + * Validates the username/password credentials against stored BCrypt hashes. + * + * @param authentication the authentication request object + * @return a fully authenticated object including user details + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { @@ -75,6 +86,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro return usernamePasswordAuthentication; } + /** + * Checks if this provider supports the given authentication type. + * + * @param authentication the authentication class to check + * @return true if this provider supports the given type + */ @Override public boolean supports(Class authentication) { return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java index b1bbfc7..da41902 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/AuthService.java @@ -10,9 +10,11 @@ import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.stereotype.Service; -import java.util.Objects; -import java.util.Optional; - +/** + * Service handling user authentication, login, and session management. + * + * @author zihluwang + */ @Service public class AuthService { @@ -23,6 +25,16 @@ public class AuthService { this.authenticationManager = authenticationManager; } + /** + * Authenticates a user with the given login credentials. + *

+ * Delegates authentication to Spring Security's {@link AuthenticationManager} and verifies + * that the result is of the expected {@link UsernamePasswordAuthentication} type. + * + * @param request the login credentials containing principle and password + * @return the authenticated {@link User} + * @throws BizException if authentication fails or the result type is unexpected + */ public User login(LoginRequest request) { var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication .unauthenticated(request.principle(), request.credential())); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java index 764b47b..eb93086 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/CookieService.java @@ -6,6 +6,11 @@ import org.springframework.stereotype.Service; import java.time.Duration; +/** + * Service for building HTTP cookies with configurable properties. + * + * @author zihluwang + */ @Service public class CookieService { @@ -15,10 +20,25 @@ public class CookieService { this.cookieManager = cookieManager; } + /** + * Builds a response cookie with the default max age from configuration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @return a configured ResponseCookie + */ public ResponseCookie buildCookie(String cookieName, String value) { return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); } + /** + * Builds a response cookie with a custom valid duration. + * + * @param cookieName the cookie name + * @param value the cookie value + * @param validDuration the cookie's max age + * @return a configured ResponseCookie + */ public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) { return buildCookieInternal(cookieName, value, validDuration); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java index 3ad3d4e..fa2118d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/DailyPasswordService.java @@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse; import com.onixbyte.deltaforceguide.manager.DailyPasswordManager; import org.springframework.stereotype.Service; +/** + * Service for generating and caching daily rotation passwords. + * + * @author zihluwang + */ @Service public class DailyPasswordService { @@ -13,6 +18,10 @@ public class DailyPasswordService { this.dailyPasswordManager = dailyPasswordManager; } + /** + * Retrieves the daily password for the current day. + * @return the daily password response + */ public DailyPasswordResponse getDailyPassword() { return dailyPasswordManager.getDailyPassword(); } diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java index 3b69c15..5f5098b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/FirearmService.java @@ -11,9 +11,13 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +/** + * Service handling firearm business logic including CRUD operations and queries. + * + * @author zihluwang + */ @Service public class FirearmService { @@ -23,7 +27,13 @@ public class FirearmService { this.firearmRepository = firearmRepository; } - @Transactional(readOnly = true) + /** + * Queries firearms with optional type filter and pagination. + * + * @param type optional firearm type filter + * @param pageable pagination parameters + * @return a paginated response of firearm records + */ public PageResponse pageQuery(FirearmType type, Pageable pageable) { Page page = type == null ? firearmRepository.findAll(pageable) @@ -32,13 +42,24 @@ public class FirearmService { return PageResponse.from(page.map(FirearmResponse::from)); } - @Transactional(readOnly = true) + /** + * Finds a firearm by its ID. + * + * @param id the firearm ID + * @return the firearm response + */ public FirearmResponse queryById(Long id) { return firearmRepository.findById(id) .map(FirearmResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); } + /** + * Creates a new firearm from the provided request data. + * + * @param request the firearm creation request + * @return the created firearm response + */ public FirearmResponse addFirearm(FirearmRequest request) { var firearm = firearmRepository.save(Firearm.builder() .name(request.name()) @@ -54,7 +75,13 @@ public class FirearmService { return FirearmResponse.from(firearm); } - @Transactional + /** + * Updates an existing firearm identified by ID. + * + * @param id the firearm ID + * @param request the updated firearm data + * @return the updated firearm response + */ public FirearmResponse updateFirearm(Long id, FirearmRequest request) { var firearm = firearmRepository.findById(id) .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); @@ -71,7 +98,11 @@ public class FirearmService { return FirearmResponse.from(firearmRepository.save(firearm)); } - @Transactional + /** + * Deletes a firearm by its ID. + * + * @param id the firearm ID to delete + */ public void deleteFirearm(Long id) { Firearm firearm = firearmRepository.findById(id) .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); diff --git a/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java b/src/main/java/com/onixbyte/deltaforceguide/service/ModificationService.java index 39e5ec0..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; @@ -17,34 +18,46 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -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; import java.util.Set; +/** + * Service handling modification business logic including CRUD, batch operations, and tag filtering. + * + * @author zihluwang + */ @Service 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; } - @Transactional(readOnly = true) + /** + * Queries modifications with optional firearm and tag filters. + * + * @param firearmId optional firearm ID filter + * @param tags optional tag list filter + * @param pageable pagination parameters + * @return a paginated response of modification records + */ public PageResponse pageQuery(Long firearmId, List tags, Pageable pageable) { String tagsJson = null; if (tags != null && !tags.isEmpty()) { @@ -65,53 +78,55 @@ public class ModificationService { return PageResponse.from(page.map(ModificationResponse::from)); } - @Transactional(readOnly = true) + /** + * Finds a modification by its ID. + * + * @param id the modification ID + * @return the modification response + */ public ModificationResponse queryById(Long id) { return modificationRepository.findById(id) .map(ModificationResponse::from) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); } - @Transactional(readOnly = true) + /** + * Finds all unique tags across modifications, optionally scoped to a firearm. + * + * @param firearmId optional firearm ID to scope the tag search + * @return list of unique tag strings + */ public List findAllTags(Long firearmId) { return modificationRepository.findAllTags(firearmId); } - @Transactional + /** + * Creates a new modification for a given firearm. + * + * @param request the modification creation request + * @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); } - @Transactional + /** + * Creates multiple modifications in a single batch operation. + * + * @param requests list of modification creation requests + * @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); } - @Transactional + /** + * Updates an existing modification identified by ID. + * + * @param id the modification ID + * @param request the updated modification data + * @return the updated modification response + */ public ModificationResponse update(Long id, ModificationRequest request) { Modification modification = modificationRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); @@ -130,14 +145,22 @@ public class ModificationService { return ModificationResponse.from(modificationRepository.save(modification)); } - @Transactional + /** + * Deletes a modification by its ID. + * + * @param id the modification ID to delete + */ public void delete(Long id) { Modification modification = modificationRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); modificationRepository.delete(modification); } - @Transactional + /** + * Deletes multiple modifications in a single batch operation. + * + * @param ids list of modification IDs to delete + */ public void batchDelete(List ids) { Set uniqueIds = new LinkedHashSet<>(ids); List modifications = modificationRepository.findAllById(uniqueIds); @@ -155,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/UserService.java b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java index c57ce4f..700e26d 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java +++ b/src/main/java/com/onixbyte/deltaforceguide/service/UserService.java @@ -6,11 +6,15 @@ import com.onixbyte.deltaforceguide.manager.UserCredentialManager; import com.onixbyte.deltaforceguide.manager.UserManager; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; import java.util.List; +/** + * Service for user account management and profile operations. + * + * @author zihluwang + */ @Service public class UserService { @@ -22,29 +26,53 @@ public class UserService { this.userCredentialManager = userCredentialManager; } - @Transactional(readOnly = true) + /** + * Retrieves all registered users. + * + * @return list of all users + */ public List findAll() { return userManager.findAll(); } - @Transactional(readOnly = true) + /** + * Finds a user by their ID. + * + * @param id the user ID + * @return the user + */ public User queryById(Long id) { return userManager.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + id)); } - @Transactional(readOnly = true) + /** + * Finds a user by their username. + * + * @param username the username to search for + * @return the user + */ public User queryByUsername(String username) { return userManager.findByUsername(username) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + username)); } - @Transactional + /** + * Creates a new user account. + * + * @param user the user entity to persist + * @return the saved user entity + */ public User create(User user) { return userManager.save(user); } - @Transactional + /** + * Updates an existing user account. + * + * @param user the user entity with updated fields + * @return the saved user entity + */ public User update(User user) { if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId()); @@ -52,13 +80,24 @@ public class UserService { return userManager.save(user); } - @Transactional(readOnly = true) + /** + * Finds all credentials associated with a user. + * + * @param userId the user ID + * @return list of user credentials + */ public List findCredentials(Long userId) { ensureUserExists(userId); return userCredentialManager.findAllByUserId(userId); } - @Transactional(readOnly = true) + /** + * Queries a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider + * @return the matching credential + */ public UserCredential queryCredential(Long userId, String provider) { ensureUserExists(userId); return userCredentialManager.findByUserIdAndProvider(userId, provider) @@ -67,7 +106,14 @@ public class UserService { "User credential not found: userId=" + userId + ", provider=" + provider)); } - @Transactional + /** + * Creates or updates a credential for a user and provider. + * + * @param userId the user ID + * @param provider the authentication provider + * @param credential the credential value + * @return the saved credential + */ public UserCredential upsertCredential(Long userId, String provider, String credential) { User user = ensureUserExists(userId); UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider) @@ -78,13 +124,22 @@ public class UserService { return userCredentialManager.save(userCredential); } - @Transactional + /** + * Deletes a specific credential for a user by provider. + * + * @param userId the user ID + * @param provider the authentication provider + */ public void deleteCredential(Long userId, String provider) { ensureUserExists(userId); userCredentialManager.deleteByUserIdAndProvider(userId, provider); } - @Transactional + /** + * Deletes a user and all associated credentials. + * + * @param id the user ID to delete + */ public void deleteById(Long id) { ensureUserExists(id); userCredentialManager.deleteAllByUserId(id); 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..e864a77 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/service/WebhookService.java @@ -0,0 +1,219 @@ +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; + } + + var data = 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.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/CookieName.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java index 329852b..26f0112 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CookieName.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.shared; +/** + * Constants for HTTP cookie names used for authentication tokens. + * + * @author zihluwang + */ public class CookieName { public static final String ACCESS_TOKEN = "AccessToken"; diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java index 115f935..9488536 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java @@ -1,5 +1,10 @@ package com.onixbyte.deltaforceguide.shared; +/** + * Constants for supported authentication provider identifiers. + * + * @author zihluwang + */ public class CredentialProvider { public static final String LOCAL = "LOCAL"; 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/shared/JacksonModules.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java index 36332aa..ae47823 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonModules.java @@ -13,6 +13,12 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +/** + * Shared Jackson {@link com.fasterxml.jackson.databind.Module} instances for custom date/time + * serialisation and deserialisation across the application. + * + * @author zihluwang + */ public class JacksonModules { public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java index 5a300ba..4e50c38 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/JacksonRedisSerialiser.java @@ -4,6 +4,12 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +/** + * Singleton serialiser for Redis cache operations using + * {@link GenericJackson2JsonRedisSerializer} with JavaTime module support. + * + * @author zihluwang + */ public class JacksonRedisSerialiser { public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer(); diff --git a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java index f1360c8..1d576a5 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java +++ b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java @@ -4,8 +4,19 @@ import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; +/** + * Utility class for date and time operations using system-default time zone. + * + * @author zihluwang + */ public class DateTimeUtil { + /** + * Convert a {@link LocalDateTime} to an {@link Instant} using the system-default time zone. + * + * @param ldt the local date-time to convert + * @return the corresponding instant + */ public static Instant asInstant(LocalDateTime ldt) { return ldt.atZone(ZoneId.systemDefault()) .toInstant(); 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..8fcdf2f 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -43,3 +43,9 @@ logging: level: org.hibernate: orm.connections.pooling: off + +app: + common: + version: ${appVersion} + channel: ${channel} + vendor: ${vendor}