Merge pull request 'Develop' (#1) from develop into main
Build and Deploy / build-and-release (release) Failing after 15m51s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-04 17:40:34 +08:00
95 changed files with 1699 additions and 179 deletions
+72
View File
@@ -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 }}
-86
View File
@@ -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
+3
View File
@@ -157,3 +157,6 @@ gradle-app.setting
.classpath .classpath
gradle.properties gradle.properties
# frp config
frpc.toml
+13
View File
@@ -5,6 +5,8 @@ plugins {
} }
val artefactVersion: String by project val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.helix" group = "com.onixbyte.helix"
version = artefactVersion version = artefactVersion
@@ -36,6 +38,7 @@ dependencies {
implementation(libs.onixbyte.identityGenerator) implementation(libs.onixbyte.identityGenerator)
implementation(libs.onixbyte.captcha) implementation(libs.onixbyte.captcha)
implementation(libs.onixbyte.regions) implementation(libs.onixbyte.regions)
implementation(libs.onixbyte.cryptoToolbox)
implementation(libs.jwt.core) implementation(libs.jwt.core)
implementation(libs.spring.boot.configurationProcessor) implementation(libs.spring.boot.configurationProcessor)
implementation(libs.spring.boot.actuator) implementation(libs.spring.boot.actuator)
@@ -60,6 +63,16 @@ dependencies {
testRuntimeOnly(libs.junit.launcher) testRuntimeOnly(libs.junit.launcher)
} }
tasks.processResources {
filesMatching("application.yaml") {
expand(
"appVersion" to artefactVersion,
"channel" to buildChannel,
"vendor" to vendor
)
}
}
tasks.test { tasks.test {
useJUnitPlatform() useJUnitPlatform()
} }
+2 -4
View File
@@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1"
postgresDriverVersion = "42.7.9" postgresDriverVersion = "42.7.9"
h2Version = "2.2.224" h2Version = "2.2.224"
springBootVersion = "3.5.13" springBootVersion = "3.5.13"
springSecurityVersion = "6.5.2"
reactorVersion = "3.7.8" reactorVersion = "3.7.8"
junitPlatformVersion = "1.12.2" junitPlatformVersion = "1.12.2"
onixbyteVersion = "3.3.0" onixbyteVersion = "3.4.0"
onixbyteCaptcha = "1.1.0" onixbyteCaptcha = "1.1.0"
onixbyteRegions = "2025.12.0" onixbyteRegions = "2025.12.0"
awsSdkVersion = "2.25.48" 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-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" } onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" } 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-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" } onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" } 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" } 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" } hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" } 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" } 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" } 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-core = { group = "org.flywaydb", name = "flyway-core" }
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" } flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" }
# Spring Boot Core & Web # Spring Boot Core & Web
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Entry point for the Delta Force Guide Server application.
*
* @author zihluwang
*/
@SpringBootApplication @SpringBootApplication
public class DeltaForceGuideApplication { public class DeltaForceGuideApplication {
@@ -12,6 +12,11 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* Client for generating and verifying JSON Web Tokens using the Auth0 java-jwt library.
*
* @author zihluwang
*/
@Component @Component
public class TokenClient { public class TokenClient {
@@ -1,10 +1,35 @@
package com.onixbyte.deltaforceguide.config; package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor;
import com.onixbyte.deltaforceguide.properties.AppProperties; import com.onixbyte.deltaforceguide.properties.AppProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration @Configuration
@EnableConfigurationProperties(AppProperties.class) @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");
}
} }
@@ -12,10 +12,21 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List; import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
/**
* Configuration for CORS (Cross-Origin Resource Sharing) policies.
*
* @author zihluwang
*/
@Configuration @Configuration
@EnableConfigurationProperties({CorsProperties.class}) @EnableConfigurationProperties({CorsProperties.class})
public class CorsConfig { public class CorsConfig {
/**
* Creates the CORS configuration source with properties from configuration.
*
* @param properties the CORS configuration properties
* @return the CORS configuration source
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource( public CorsConfigurationSource corsConfigurationSource(
CorsProperties properties CorsProperties properties
@@ -0,0 +1,21 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.filter.WebhookFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<WebhookFilter> webhookFilterBean(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhooks/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -6,6 +6,11 @@ import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilde
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/**
* Configuration for Jackson JSON serialisation and deserialisation settings.
*
* @author zihluwang
*/
@Configuration @Configuration
public class JacksonConfig { public class JacksonConfig {
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
import org.mybatis.spring.annotation.MapperScan; import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/**
* Configuration for MyBatis SQL mapping framework integration.
*
* @author zihluwang
*/
@Configuration @Configuration
@MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"}) @MapperScan(basePackages = {"com.onixbyte.deltaforceguide.mapper"})
public class MyBatisConfig { public class MyBatisConfig {
@@ -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 {
}
@@ -23,12 +23,23 @@ import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter; import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
/**
* Spring Security configuration defining authentication, authorisation, and filter chains.
*
* @author zihluwang
*/
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@EnableMethodSecurity @EnableMethodSecurity
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class}) @EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig { 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 @Bean
public SecurityFilterChain securityFilterChain( public SecurityFilterChain securityFilterChain(
HttpSecurity httpSecurity, HttpSecurity httpSecurity,
@@ -48,11 +59,20 @@ public class SecurityConfig {
.build(); .build();
} }
/**
* Provides the BCrypt password encoder for credential hashing.
* @return the BCrypt password encoder
*/
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
/**
* Provides the authentication manager for the security configuration.
*
* @return the authentication manager
*/
@Bean @Bean
public AuthenticationManager authenticationManager( public AuthenticationManager authenticationManager(
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.config;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* Configuration for Spring Data JPA auditing and repository settings.
*
* @author zihluwang
*/
@Configuration @Configuration
@EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"}) @EnableJpaRepositories(basePackages = {"com.onixbyte.deltaforceguide.repository"})
public class SpringDataConfig { public class SpringDataConfig {
@@ -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 {
}
@@ -18,7 +18,13 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime;
/**
* REST controller for user authentication endpoints (login, logout).
*
* @author zihluwang
*/
@Tag(name = "用户鉴权", description = "处理用户登录与退出功能") @Tag(name = "用户鉴权", description = "处理用户登录与退出功能")
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
@@ -38,12 +44,14 @@ public class AuthController {
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) { public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request); var user = authService.login(request);
var currentTime = LocalDateTime.now();
var accessToken = tokenClient.generateToken(user); var accessToken = tokenClient.generateToken(user);
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken); var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
var cookieMaxAge = accessTokenCookie.getMaxAge();
return ResponseEntity.ok() return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString()) .header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
.body(UserResponse.from(user)); .body(UserResponse.from(user, currentTime.plus(cookieMaxAge)));
} }
@RequiresAuth @RequiresAuth
@@ -8,6 +8,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/**
* REST controller for retrieving daily-generated passwords.
*
* @author zihluwang
*/
@Tag(name = "每日密码", description = "获取每日密码信息") @Tag(name = "每日密码", description = "获取每日密码信息")
@RestController @RestController
@RequestMapping("/daily-passwords") @RequestMapping("/daily-passwords")
@@ -15,6 +15,11 @@ import org.springframework.data.domain.Sort;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* REST controller for firearm CRUD operations.
*
* @author zihluwang
*/
@Tag(name = "武器管理", description = "与武器有关的操作") @Tag(name = "武器管理", description = "与武器有关的操作")
@RestController @RestController
@RequestMapping("/firearms") @RequestMapping("/firearms")
@@ -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<Void> 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();
}
}
@@ -6,6 +6,11 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* Global exception handler that translates exceptions into standard error responses.
*
* @author zihluwang
*/
@RestControllerAdvice @RestControllerAdvice
public class GlobalExceptionHandler { public class GlobalExceptionHandler {
@@ -27,6 +27,11 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
/**
* REST controller for modification CRUD operations, including batch creation and deletion.
*
* @author zihluwang
*/
@Tag(name = "改装管理", description = "对枪械改装的管理") @Tag(name = "改装管理", description = "对枪械改装的管理")
@RestController @RestController
@RequestMapping("/modifications") @RequestMapping("/modifications")
@@ -10,6 +10,11 @@ import com.onixbyte.deltaforceguide.service.ModificationService;
import java.util.List; import java.util.List;
/**
* REST controller for retrieving available modification tags.
*
* @author zihluwang
*/
@Tag(name = "标签管理", description = "管理标签信息") @Tag(name = "标签管理", description = "管理标签信息")
@RestController @RestController
@RequestMapping("/tags") @RequestMapping("/tags")
@@ -2,10 +2,12 @@ package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.service.AppService; import com.onixbyte.deltaforceguide.service.AppService;
import io.swagger.v3.oas.annotations.Operation; 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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@Tag(name = "版本信息")
@RestController @RestController
@RequestMapping("/versions") @RequestMapping("/versions")
public class VersionController { public class VersionController {
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.enumeration.FirearmType;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;
/**
* JPA attribute converter that maps {@link FirearmType} enum to/from its integer database representation.
*
* @author zihluwang
*/
@Converter @Converter
public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> { public class FirearmTypeConverter implements AttributeConverter<FirearmType, Integer> {
@@ -6,6 +6,11 @@ import jakarta.validation.constraints.NotBlank;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Request DTO for creating or updating an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryRequest( public record AccessoryRequest(
@NotBlank(message = "插槽名称不能为空") @NotBlank(message = "插槽名称不能为空")
String slotName, String slotName,
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import java.util.List; import java.util.List;
/**
* Response DTO for an accessory attached to a modification.
*
* @author zihluwang
*/
public record AccessoryResponse( public record AccessoryResponse(
String slotName, String slotName,
String accessoryName, String accessoryName,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto; package com.onixbyte.deltaforceguide.domain.dto;
/**
* DTO representing a single daily-generated password for a map.
*
* @author zihluwang
*/
public record DailyPassword( public record DailyPassword(
String mapName, String mapName,
String password String password
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/**
* DTO containing daily password data including update information and password list.
*
* @author zihluwang
*/
public record DailyPasswordData( public record DailyPasswordData(
String updateDate, String updateDate,
Integer totalCount, Integer totalCount,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto; package com.onixbyte.deltaforceguide.domain.dto;
/**
* DTO holding metadata about the daily password source and update tracking.
*
* @author zihluwang
*/
public record DailyPasswordMetadata( public record DailyPasswordMetadata(
String version, String version,
String author String author
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto; package com.onixbyte.deltaforceguide.domain.dto;
/**
* Response DTO wrapping daily password data with metadata.
*
* @author zihluwang
*/
public record DailyPasswordResponse( public record DailyPasswordResponse(
String status, String status,
String message, String message,
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.domain.dto; package com.onixbyte.deltaforceguide.domain.dto;
/**
* Standard error response body returned on API failures.
*
* @author zihluwang
*/
public record ErrorResponse( public record ErrorResponse(
String message String message
) { ) {
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.enumeration.FirearmType; import com.onixbyte.deltaforceguide.enumeration.FirearmType;
/**
* Request DTO for creating or updating a firearm.
*
* @author zihluwang
*/
public record FirearmRequest( public record FirearmRequest(
String name, String name,
FirearmType type, FirearmType type,
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.enumeration.FirearmType; import com.onixbyte.deltaforceguide.enumeration.FirearmType;
/**
* Response DTO for a firearm record, including associated modifications.
*
* @author zihluwang
*/
public record FirearmResponse( public record FirearmResponse(
Long id, Long id,
String name, String name,
@@ -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
) {
}
@@ -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<GitHubWebhookLabel> labels
) {
}
@@ -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
) {
}
@@ -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
) {
}
@@ -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
) {
}
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
/**
* Login request containing principle (username/email) and credential (password).
*
* @author zihluwang
*/
@Schema(description = "登录请求") @Schema(description = "登录请求")
public record LoginRequest( public record LoginRequest(
@NotBlank(message = "登录名称不能为空") @NotBlank(message = "登录名称不能为空")
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.NotEmpty;
import java.util.List; import java.util.List;
/**
* Request DTO for batch creation of modifications.
*
* @author zihluwang
*/
public record ModificationBatchCreateRequest( public record ModificationBatchCreateRequest(
@NotEmpty(message = "批量创建列表不能为空") @NotEmpty(message = "批量创建列表不能为空")
List<@Valid ModificationRequest> modifications List<@Valid ModificationRequest> modifications
@@ -5,6 +5,11 @@ import jakarta.validation.constraints.Positive;
import java.util.List; import java.util.List;
/**
* Request DTO for batch deletion of modifications by ID.
*
* @author zihluwang
*/
public record ModificationBatchDeleteRequest( public record ModificationBatchDeleteRequest(
@NotEmpty(message = "批量删除ID列表不能为空") @NotEmpty(message = "批量删除ID列表不能为空")
List<@Positive(message = "ID必须为正数") Long> ids List<@Positive(message = "ID必须为正数") Long> ids
@@ -8,6 +8,11 @@ import jakarta.validation.constraints.Positive;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Request DTO for creating or updating a modification.
*
* @author zihluwang
*/
public record ModificationRequest( public record ModificationRequest(
@NotNull(message = "武器ID不能为空") @NotNull(message = "武器ID不能为空")
@Positive(message = "武器ID必须为正数") @Positive(message = "武器ID必须为正数")
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.entity.Modification;
import java.util.List; import java.util.List;
/**
* Response DTO for a modification record including accessories and tags.
*
* @author zihluwang
*/
public record ModificationResponse( public record ModificationResponse(
Long id, Long id,
Long firearmId, Long firearmId,
@@ -4,6 +4,11 @@ import org.springframework.data.domain.Page;
import java.util.List; import java.util.List;
/**
* Generic paginated response wrapper for list endpoints.
*
* @author zihluwang
*/
public record PageResponse<T>( public record PageResponse<T>(
List<T> items, List<T> items,
int page, int page,
@@ -3,6 +3,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
/**
* Request DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningRequest( public record TuningRequest(
@NotBlank(message = "调校项名称不能为空") @NotBlank(message = "调校项名称不能为空")
String tuningName, String tuningName,
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.Tuning; import com.onixbyte.deltaforceguide.domain.entity.Tuning;
/**
* Response DTO for a tuning adjustment on an accessory.
*
* @author zihluwang
*/
public record TuningResponse( public record TuningResponse(
String tuningName, String tuningName,
Double tuningValue Double tuningValue
@@ -2,16 +2,25 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.User; 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( public record UserResponse(
Long id, Long id,
String username, 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( return new UserResponse(
user.getId(), user.getId(),
user.getUsername(), user.getUsername(),
user.getEmail() user.getEmail(),
expiration
); );
} }
} }
@@ -2,7 +2,13 @@ package com.onixbyte.deltaforceguide.domain.entity;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
/**
* Entity representing an accessory attached to a modification, stored as JSONB.
*
* @author zihluwang
*/
public class Accessory { public class Accessory {
private String slotName; private String slotName;
@@ -45,4 +51,22 @@ public class Accessory {
public void removeTuning(Tuning tuning) { public void removeTuning(Tuning tuning) {
this.tunings.remove(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);
}
} }
@@ -15,6 +15,11 @@ import jakarta.persistence.Table;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Entity representing a firearm in the Delta Force game.
*
* @author zihluwang
*/
@Entity @Entity
@Table(name = "firearm") @Table(name = "firearm")
public class Firearm { public class Firearm {
@@ -7,6 +7,11 @@ import org.hibernate.annotations.Type;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Entity representing a firearm modification or build configuration.
*
* @author zihluwang
*/
@Entity @Entity
@Table( @Table(
name = "modification", name = "modification",
@@ -1,5 +1,12 @@
package com.onixbyte.deltaforceguide.domain.entity; 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 { public class Tuning {
private String tuningName; private String tuningName;
@@ -23,4 +30,21 @@ public class Tuning {
public void setTuningValue(Double tuningValue) { public void setTuningValue(Double tuningValue) {
this.tuningValue = 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);
}
} }
@@ -12,6 +12,11 @@ import jakarta.persistence.Table;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Entity representing an application user with authentication credentials.
*
* @author zihluwang
*/
@Entity @Entity
@Table(name = "app_user") @Table(name = "app_user")
public class User { public class User {
@@ -12,6 +12,11 @@ import jakarta.persistence.MapsId;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
/**
* Entity representing a user credential linked to an authentication provider.
*
* @author zihluwang
*/
@Entity @Entity
@Table(name = "app_user_credential") @Table(name = "app_user_credential")
public class UserCredential { public class UserCredential {
@@ -28,7 +33,7 @@ public class UserCredential {
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user")) @JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_credential_user"))
private User user; private User user;
@Column(name = "credential", nullable = false, length = 255) @Column(name = "credential", nullable = false)
private String credential; private String credential;
public UserCredentialId getId() { public UserCredentialId getId() {
@@ -5,6 +5,11 @@ import jakarta.persistence.Embeddable;
import java.io.Serializable; import java.io.Serializable;
import java.util.Objects; import java.util.Objects;
/**
* Composite key for the UserCredential entity, combining user ID and provider.
*
* @author zihluwang
*/
@Embeddable @Embeddable
public class UserCredentialId implements Serializable { public class UserCredentialId implements Serializable {
@@ -1,5 +1,11 @@
package com.onixbyte.deltaforceguide.enumeration; 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 { public enum FirearmType {
RIFLE(0), RIFLE(0),
@@ -21,6 +27,13 @@ public enum FirearmType {
return code; 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) { public static FirearmType fromCode(Integer code) {
if (code == null) { if (code == null) {
return null; return null;
@@ -2,6 +2,11 @@ package com.onixbyte.deltaforceguide.exeption;
import org.springframework.http.HttpStatus; 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 { public class BizException extends RuntimeException {
/** /**
@@ -16,7 +16,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@@ -29,6 +28,11 @@ import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
/**
* Servlet filter that extracts and validates JWT tokens from httpOnly cookies for each request.
*
* @author zihluwang
*/
@Component @Component
public class TokenAuthenticationFilter extends OncePerRequestFilter { public class TokenAuthenticationFilter extends OncePerRequestFilter {
@@ -52,6 +56,13 @@ public class TokenAuthenticationFilter extends OncePerRequestFilter {
this.handlerExceptionResolver = handlerExceptionResolver; 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 @Override
protected void doFilterInternal( protected void doFilterInternal(
@NonNull HttpServletRequest request, @NonNull HttpServletRequest request,
@@ -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);
}
}
@@ -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.
*
* <p>Verification is skipped when no secret is configured. The signature format is
* {@code sha256=<hex-digest>} 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");
}
}
}
@@ -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();
}
}
@@ -18,6 +18,10 @@ public class AppManager {
* @return the version string of this application * @return the version string of this application
*/ */
public String getVersion() { public String getVersion() {
return appProperties.version(); return "v%s-%s by @%s".formatted(
appProperties.version(),
appProperties.channel(),
appProperties.vendor()
);
} }
} }
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Component;
import java.time.Duration; import java.time.Duration;
/**
* Manager providing cookie construction operations with configurable properties.
*
* @author zihluwang
*/
@Component @Component
public class CookieManager { public class CookieManager {
@@ -17,6 +17,11 @@ import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.Objects; import java.util.Objects;
/**
* Manager for daily password data access and caching coordination.
*
* @author zihluwang
*/
@Component @Component
public class DailyPasswordManager { public class DailyPasswordManager {
@@ -49,6 +54,10 @@ public class DailyPasswordManager {
this.redisTemplate = redisTemplate; this.redisTemplate = redisTemplate;
} }
/**
* Retrieves the daily password from cache or generates a new one.
* @return the daily password response
*/
public DailyPasswordResponse getDailyPassword() { public DailyPasswordResponse getDailyPassword() {
var key = CACHE_KEY_PREFIX + LocalDate.now(); var key = CACHE_KEY_PREFIX + LocalDate.now();
@@ -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<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
var firearmIds = requests.stream()
.map(ModificationRequest::firearmId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Map<Long, Firearm> 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<Accessory> toAccessories(List<AccessoryRequest> 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<Tuning> toTunings(List<TuningRequest> 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;
}
}
@@ -3,11 +3,15 @@ package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.domain.entity.UserCredential; import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository; import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* Manager for user credential persistence and authentication data access.
*
* @author zihluwang
*/
@Component @Component
public class UserCredentialManager { public class UserCredentialManager {
@@ -17,27 +21,52 @@ public class UserCredentialManager {
this.userCredentialRepository = userCredentialRepository; 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<UserCredential> findAllByUserId(Long userId) { public List<UserCredential> findAllByUserId(Long userId) {
return userCredentialRepository.findAllByUserId(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<UserCredential> findByUserIdAndProvider(Long userId, String provider) { public Optional<UserCredential> findByUserIdAndProvider(Long userId, String provider) {
return userCredentialRepository.findByUserIdAndProvider(userId, 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) { public UserCredential save(UserCredential userCredential) {
return userCredentialRepository.save(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) { public void deleteByUserIdAndProvider(Long userId, String provider) {
userCredentialRepository.deleteByUserIdAndProvider(userId, provider); userCredentialRepository.deleteByUserIdAndProvider(userId, provider);
} }
@Transactional /**
* Deletes all credentials belonging to a user.
*
* @param userId the user ID
*/
public void deleteAllByUserId(Long userId) { public void deleteAllByUserId(Long userId) {
userCredentialRepository.deleteAllByUserId(userId); userCredentialRepository.deleteAllByUserId(userId);
} }
@@ -8,6 +8,11 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* Manager for user entity persistence and query operations.
*
* @author zihluwang
*/
@Component @Component
public class UserManager { public class UserManager {
@@ -17,17 +22,30 @@ public class UserManager {
this.userRepository = userRepository; 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<User> findById(Long id) { public Optional<User> findById(Long id) {
return userRepository.findById(id); return userRepository.findById(id);
} }
@Transactional(readOnly = true) /**
* Retrieves all registered users.
* @return list of all users
*/
public List<User> findAll() { public List<User> findAll() {
return userRepository.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<User> findByUsername(String username) { public Optional<User> findByUsername(String username) {
return userRepository.findByUsername(username); return userRepository.findByUsername(username);
} }
@@ -37,16 +55,31 @@ public class UserManager {
return userRepository.findByEmail(email); 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) { public User save(User user) {
return userRepository.save(user); return userRepository.save(user);
} }
@Transactional /**
* Deletes a user by their ID.
*
* @param id the user ID to delete
*/
public void deleteById(Long id) { public void deleteById(Long id) {
userRepository.deleteById(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<User> findByUsernameOrEmail(String principal) { public Optional<User> findByUsernameOrEmail(String principal) {
return userRepository.findByUsernameOrEmail(principal); return userRepository.findByUsernameOrEmail(principal);
} }
@@ -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<String> allowedUsers() {
return gitHubWebhookProperties.allowedUsers();
}
}
@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common") @ConfigurationProperties(prefix = "app.common")
public record AppProperties( public record AppProperties(
String version String version,
String channel,
String vendor
) { ) {
} }
@@ -6,6 +6,16 @@ import org.springframework.boot.web.server.Cookie;
import java.time.Duration; 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") @ConfigurationProperties(prefix = "app.cookie")
public record CookieProperties( public record CookieProperties(
@DefaultValue("true") Boolean httpOnly, @DefaultValue("true") Boolean httpOnly,
@@ -6,6 +6,18 @@ import org.springframework.http.HttpMethod;
import java.time.Duration; 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") @ConfigurationProperties(prefix = "app.cors")
public record CorsProperties( public record CorsProperties(
@DefaultValue({"Content-Type", "Authorization"}) @DefaultValue({"Content-Type", "Authorization"})
@@ -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<String> allowedUsers
) {
}
@@ -4,6 +4,14 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration; 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") @ConfigurationProperties(prefix = "app.jwt")
public record TokenProperties( public record TokenProperties(
String issuer, String issuer,
@@ -7,9 +7,18 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Spring Data JPA repository for {@link Firearm} entity operations.
*
* @author zihluwang
*/
@Repository @Repository
public interface FirearmRepository extends JpaRepository<Firearm, Long> { public interface FirearmRepository extends JpaRepository<Firearm, Long> {
Page<Firearm> findAllByType(FirearmType type, Pageable pageable); Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
List<Firearm> findByName(String name);
} }
@@ -13,6 +13,12 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* Spring Data JPA repository for {@link Modification} entity operations,
* including native JSONB tag filtering for Postgres.
*
* @author zihluwang
*/
@Repository @Repository
public interface ModificationRepository extends JpaRepository<Modification, Long> { public interface ModificationRepository extends JpaRepository<Modification, Long> {
@@ -27,6 +33,14 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
@NonNull @NonNull
Optional<Modification> findById(@NonNull Long id); Optional<Modification> 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 = """ @Query(value = """
SELECT * FROM modification m SELECT * FROM modification m
WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId) WHERE (:firearmId IS NULL OR m.firearm_id = :firearmId)
@@ -40,6 +54,12 @@ public interface ModificationRepository extends JpaRepository<Modification, Long
nativeQuery = true) nativeQuery = true)
Page<Modification> pageQueryByFirearmAndTags(@Param("firearmId") Long firearmId, @Param("tagsJson") String tagsJson, Pageable pageable); Page<Modification> 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) @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<String> findAllTags(@Param("firearmId") Long firearmId); List<String> findAllTags(@Param("firearmId") Long firearmId);
} }
@@ -12,9 +12,20 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
/**
* Spring Data JPA repository for {@link UserCredential} entity operations.
*
* @author zihluwang
*/
@Repository @Repository
public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> { public interface UserCredentialRepository extends JpaRepository<UserCredential, UserCredentialId> {
/**
* Find all credentials belonging to a given user.
*
* @param userId the user ID
* @return list of matching credentials
*/
@EntityGraph(attributePaths = {"user"}) @EntityGraph(attributePaths = {"user"})
@Query(""" @Query("""
select uc select uc
@@ -23,6 +34,13 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""") """)
List<UserCredential> findAllByUserId(@Param("userId") Long userId); List<UserCredential> 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"}) @EntityGraph(attributePaths = {"user"})
@Query(""" @Query("""
select uc select uc
@@ -32,6 +50,12 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""") """)
Optional<UserCredential> findByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); Optional<UserCredential> 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 @Modifying
@Query(""" @Query("""
delete from UserCredential uc delete from UserCredential uc
@@ -40,6 +64,11 @@ public interface UserCredentialRepository extends JpaRepository<UserCredential,
""") """)
void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider); void deleteByUserIdAndProvider(@Param("userId") Long userId, @Param("provider") String provider);
/**
* Delete all credentials for a given user.
*
* @param userId the user ID
*/
@Modifying @Modifying
@Query(""" @Query("""
delete from UserCredential uc delete from UserCredential uc
@@ -10,6 +10,11 @@ import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
/**
* Spring Data JPA repository for {@link User} entity operations.
*
* @author zihluwang
*/
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
@@ -28,6 +33,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email); 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"}) @EntityGraph(attributePaths = {"credentials"})
@Query(""" @Query("""
select u select u
@@ -7,6 +7,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
/**
* Annotation to mark controller endpoints that require authentication.
*
* @author zihluwang
*/
@Target({ElementType.METHOD, ElementType.TYPE}) @Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("isAuthenticated()") @PreAuthorize("isAuthenticated()")
@@ -8,6 +8,11 @@ import org.springframework.security.core.GrantedAuthority;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
/**
* Custom authentication token for username/password-based login flows.
*
* @author zihluwang
*/
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer { public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
private final String username; private final String username;
private String password; private String password;
@@ -17,6 +17,11 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/**
* Authentication provider that validates username/password credentials against stored BCrypt hashes.
*
* @author zihluwang
*/
@Component @Component
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider { public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
@@ -36,6 +41,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
this.userCredentialRepository = userCredentialRepository; 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 @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException { public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) { if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
@@ -75,6 +86,12 @@ public class UsernamePasswordAuthenticationProvider implements AuthenticationPro
return usernamePasswordAuthentication; 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 @Override
public boolean supports(Class<?> authentication) { public boolean supports(Class<?> authentication) {
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication); return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
@@ -10,9 +10,11 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Objects; /**
import java.util.Optional; * Service handling user authentication, login, and session management.
*
* @author zihluwang
*/
@Service @Service
public class AuthService { public class AuthService {
@@ -23,6 +25,16 @@ public class AuthService {
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
} }
/**
* Authenticates a user with the given login credentials.
* <p>
* 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) { public User login(LoginRequest request) {
var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication var _authentication = authenticationManager.authenticate(UsernamePasswordAuthentication
.unauthenticated(request.principle(), request.credential())); .unauthenticated(request.principle(), request.credential()));
@@ -6,6 +6,11 @@ import org.springframework.stereotype.Service;
import java.time.Duration; import java.time.Duration;
/**
* Service for building HTTP cookies with configurable properties.
*
* @author zihluwang
*/
@Service @Service
public class CookieService { public class CookieService {
@@ -15,10 +20,25 @@ public class CookieService {
this.cookieManager = cookieManager; 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) { public ResponseCookie buildCookie(String cookieName, String value) {
return buildCookieInternal(cookieName, value, cookieManager.getMaxAge()); 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) { public ResponseCookie buildCookie(String cookieName, String value, Duration validDuration) {
return buildCookieInternal(cookieName, value, validDuration); return buildCookieInternal(cookieName, value, validDuration);
} }
@@ -4,6 +4,11 @@ import com.onixbyte.deltaforceguide.domain.dto.DailyPasswordResponse;
import com.onixbyte.deltaforceguide.manager.DailyPasswordManager; import com.onixbyte.deltaforceguide.manager.DailyPasswordManager;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* Service for generating and caching daily rotation passwords.
*
* @author zihluwang
*/
@Service @Service
public class DailyPasswordService { public class DailyPasswordService {
@@ -13,6 +18,10 @@ public class DailyPasswordService {
this.dailyPasswordManager = dailyPasswordManager; this.dailyPasswordManager = dailyPasswordManager;
} }
/**
* Retrieves the daily password for the current day.
* @return the daily password response
*/
public DailyPasswordResponse getDailyPassword() { public DailyPasswordResponse getDailyPassword() {
return dailyPasswordManager.getDailyPassword(); return dailyPasswordManager.getDailyPassword();
} }
@@ -11,9 +11,13 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
/**
* Service handling firearm business logic including CRUD operations and queries.
*
* @author zihluwang
*/
@Service @Service
public class FirearmService { public class FirearmService {
@@ -23,7 +27,13 @@ public class FirearmService {
this.firearmRepository = firearmRepository; 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<FirearmResponse> pageQuery(FirearmType type, Pageable pageable) { public PageResponse<FirearmResponse> pageQuery(FirearmType type, Pageable pageable) {
Page<Firearm> page = type == null Page<Firearm> page = type == null
? firearmRepository.findAll(pageable) ? firearmRepository.findAll(pageable)
@@ -32,13 +42,24 @@ public class FirearmService {
return PageResponse.from(page.map(FirearmResponse::from)); 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) { public FirearmResponse queryById(Long id) {
return firearmRepository.findById(id) return firearmRepository.findById(id)
.map(FirearmResponse::from) .map(FirearmResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); .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) { public FirearmResponse addFirearm(FirearmRequest request) {
var firearm = firearmRepository.save(Firearm.builder() var firearm = firearmRepository.save(Firearm.builder()
.name(request.name()) .name(request.name())
@@ -54,7 +75,13 @@ public class FirearmService {
return FirearmResponse.from(firearm); 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) { public FirearmResponse updateFirearm(Long id, FirearmRequest request) {
var firearm = firearmRepository.findById(id) var firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
@@ -71,7 +98,11 @@ public class FirearmService {
return FirearmResponse.from(firearmRepository.save(firearm)); 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) { public void deleteFirearm(Long id) {
Firearm firearm = firearmRepository.findById(id) Firearm firearm = firearmRepository.findById(id)
.orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id)); .orElseThrow(() -> new BizException(HttpStatus.NOT_FOUND, "Firearm not found: " + id));
@@ -9,6 +9,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm; import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification; import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.deltaforceguide.domain.entity.Tuning; import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.deltaforceguide.manager.ModificationManager;
import com.onixbyte.deltaforceguide.repository.FirearmRepository; import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import com.onixbyte.deltaforceguide.repository.ModificationRepository; import com.onixbyte.deltaforceguide.repository.ModificationRepository;
import com.fasterxml.jackson.core.JsonProcessingException; 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.data.domain.Pageable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
/**
* Service handling modification business logic including CRUD, batch operations, and tag filtering.
*
* @author zihluwang
*/
@Service @Service
public class ModificationService { public class ModificationService {
private final ModificationRepository modificationRepository; private final ModificationRepository modificationRepository;
private final FirearmRepository firearmRepository; private final FirearmRepository firearmRepository;
private final ModificationManager modificationManager;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
public ModificationService( public ModificationService(
ModificationRepository modificationRepository, ModificationRepository modificationRepository,
FirearmRepository firearmRepository, FirearmRepository firearmRepository,
ModificationManager modificationManager,
ObjectMapper objectMapper ObjectMapper objectMapper
) { ) {
this.modificationRepository = modificationRepository; this.modificationRepository = modificationRepository;
this.firearmRepository = firearmRepository; this.firearmRepository = firearmRepository;
this.modificationManager = modificationManager;
this.objectMapper = objectMapper; 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<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) { public PageResponse<ModificationResponse> pageQuery(Long firearmId, List<String> tags, Pageable pageable) {
String tagsJson = null; String tagsJson = null;
if (tags != null && !tags.isEmpty()) { if (tags != null && !tags.isEmpty()) {
@@ -65,53 +78,55 @@ public class ModificationService {
return PageResponse.from(page.map(ModificationResponse::from)); 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) { public ModificationResponse queryById(Long id) {
return modificationRepository.findById(id) return modificationRepository.findById(id)
.map(ModificationResponse::from) .map(ModificationResponse::from)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); .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<String> findAllTags(Long firearmId) { public List<String> findAllTags(Long firearmId) {
return modificationRepository.findAllTags(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) { public ModificationResponse create(ModificationRequest request) {
Firearm firearm = firearmRepository.findById(request.firearmId()) return modificationManager.create(request);
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId()));
Modification modification = toEntity(request, firearm);
return ModificationResponse.from(modificationRepository.save(modification));
} }
@Transactional /**
* Creates multiple modifications in a single batch operation.
*
* @param requests list of modification creation requests
* @return list of created modification responses
*/
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) { public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
Set<Long> firearmIds = requests.stream() return modificationManager.batchCreate(requests);
.map(ModificationRequest::firearmId)
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
Map<Long, Firearm> firearmMap = new HashMap<>();
firearmRepository.findAllById(firearmIds).forEach(firearm -> firearmMap.put(firearm.getId(), firearm));
if (firearmMap.size() != firearmIds.size()) {
List<Long> missingFirearmIds = firearmIds.stream()
.filter(id -> !firearmMap.containsKey(id))
.toList();
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + missingFirearmIds);
} }
List<Modification> modifications = requests.stream() /**
.map(request -> toEntity(request, firearmMap.get(request.firearmId()))) * Updates an existing modification identified by ID.
.toList(); *
return modificationRepository.saveAll(modifications) * @param id the modification ID
.stream() * @param request the updated modification data
.map(ModificationResponse::from) * @return the updated modification response
.toList(); */
}
@Transactional
public ModificationResponse update(Long id, ModificationRequest request) { public ModificationResponse update(Long id, ModificationRequest request) {
Modification modification = modificationRepository.findById(id) Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
@@ -130,14 +145,22 @@ public class ModificationService {
return ModificationResponse.from(modificationRepository.save(modification)); 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) { public void delete(Long id) {
Modification modification = modificationRepository.findById(id) Modification modification = modificationRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Modification not found: " + id));
modificationRepository.delete(modification); modificationRepository.delete(modification);
} }
@Transactional /**
* Deletes multiple modifications in a single batch operation.
*
* @param ids list of modification IDs to delete
*/
public void batchDelete(List<Long> ids) { public void batchDelete(List<Long> ids) {
Set<Long> uniqueIds = new LinkedHashSet<>(ids); Set<Long> uniqueIds = new LinkedHashSet<>(ids);
List<Modification> modifications = modificationRepository.findAllById(uniqueIds); List<Modification> modifications = modificationRepository.findAllById(uniqueIds);
@@ -155,19 +178,6 @@ public class ModificationService {
modificationRepository.deleteAllInBatch(modifications); 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<String> safeTags(List<String> tags) { private List<String> safeTags(List<String> tags) {
return tags == null ? new ArrayList<>() : tags; return tags == null ? new ArrayList<>() : tags;
} }
@@ -6,11 +6,15 @@ import com.onixbyte.deltaforceguide.manager.UserCredentialManager;
import com.onixbyte.deltaforceguide.manager.UserManager; import com.onixbyte.deltaforceguide.manager.UserManager;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
/**
* Service for user account management and profile operations.
*
* @author zihluwang
*/
@Service @Service
public class UserService { public class UserService {
@@ -22,29 +26,53 @@ public class UserService {
this.userCredentialManager = userCredentialManager; this.userCredentialManager = userCredentialManager;
} }
@Transactional(readOnly = true) /**
* Retrieves all registered users.
*
* @return list of all users
*/
public List<User> findAll() { public List<User> findAll() {
return userManager.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) { public User queryById(Long id) {
return userManager.findById(id) return userManager.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + 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) { public User queryByUsername(String username) {
return userManager.findByUsername(username) return userManager.findByUsername(username)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + 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) { public User create(User user) {
return userManager.save(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) { public User update(User user) {
if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) { if (user.getId() == null || userManager.findById(user.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId()); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found: " + user.getId());
@@ -52,13 +80,24 @@ public class UserService {
return userManager.save(user); 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<UserCredential> findCredentials(Long userId) { public List<UserCredential> findCredentials(Long userId) {
ensureUserExists(userId); ensureUserExists(userId);
return userCredentialManager.findAllByUserId(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) { public UserCredential queryCredential(Long userId, String provider) {
ensureUserExists(userId); ensureUserExists(userId);
return userCredentialManager.findByUserIdAndProvider(userId, provider) return userCredentialManager.findByUserIdAndProvider(userId, provider)
@@ -67,7 +106,14 @@ public class UserService {
"User credential not found: userId=" + userId + ", provider=" + provider)); "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) { public UserCredential upsertCredential(Long userId, String provider, String credential) {
User user = ensureUserExists(userId); User user = ensureUserExists(userId);
UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider) UserCredential userCredential = userCredentialManager.findByUserIdAndProvider(userId, provider)
@@ -78,13 +124,22 @@ public class UserService {
return userCredentialManager.save(userCredential); 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) { public void deleteCredential(Long userId, String provider) {
ensureUserExists(userId); ensureUserExists(userId);
userCredentialManager.deleteByUserIdAndProvider(userId, provider); userCredentialManager.deleteByUserIdAndProvider(userId, provider);
} }
@Transactional /**
* Deletes a user and all associated credentials.
*
* @param id the user ID to delete
*/
public void deleteById(Long id) { public void deleteById(Long id) {
ensureUserExists(id); ensureUserExists(id);
userCredentialManager.deleteAllByUserId(id); userCredentialManager.deleteAllByUserId(id);
@@ -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<String, Object> redisTemplate;
private final WebhookManager webhookManager;
private final Yaml yaml;
public WebhookService(
ModificationManager modificationManager,
RedisTemplate<String, Object> 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.<Map<String, Object>>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<String, Object> 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<String, Object> data) {
var list = (List<Map<String, Object>>) 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<String, Object> 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<String> tags = toStringList(data.get("tags"));
List<AccessoryRequest> 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<AccessoryRequest> mapAccessories(Object raw) {
if (!(raw instanceof List<?> list)) {
return new ArrayList<>();
}
var result = new ArrayList<AccessoryRequest>();
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<TuningRequest> mapTunings(Object raw) {
if (!(raw instanceof List<?> list)) {
return new ArrayList<>();
}
var result = new ArrayList<TuningRequest>();
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<String> 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<GitHubWebhookLabel> 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;
}
}
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.shared; package com.onixbyte.deltaforceguide.shared;
/**
* Constants for HTTP cookie names used for authentication tokens.
*
* @author zihluwang
*/
public class CookieName { public class CookieName {
public static final String ACCESS_TOKEN = "AccessToken"; public static final String ACCESS_TOKEN = "AccessToken";
@@ -1,5 +1,10 @@
package com.onixbyte.deltaforceguide.shared; package com.onixbyte.deltaforceguide.shared;
/**
* Constants for supported authentication provider identifiers.
*
* @author zihluwang
*/
public class CredentialProvider { public class CredentialProvider {
public static final String LOCAL = "LOCAL"; public static final String LOCAL = "LOCAL";
@@ -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 <a href="https://docs.github.com/en/webhooks/using-webhooks/securing-your-webhooks"
* >Validating webhook deliveries</a>.
*/
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";
}
@@ -13,6 +13,12 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime; 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 class JacksonModules {
public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule(); public static final SimpleModule DATE_TIME_MODULE = initialiseDateTimeModule();
@@ -4,6 +4,12 @@ import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; 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 class JacksonRedisSerialiser {
public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer(); public static final GenericJackson2JsonRedisSerializer INSTANCE = initialiseSerializer();
@@ -4,8 +4,19 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.ZoneId;
/**
* Utility class for date and time operations using system-default time zone.
*
* @author zihluwang
*/
public class DateTimeUtil { 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) { public static Instant asInstant(LocalDateTime ldt) {
return ldt.atZone(ZoneId.systemDefault()) return ldt.atZone(ZoneId.systemDefault())
.toInstant(); .toInstant();
@@ -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);
}
}
+6
View File
@@ -43,3 +43,9 @@ logging:
level: level:
org.hibernate: org.hibernate:
orm.connections.pooling: off orm.connections.pooling: off
app:
common:
version: ${appVersion}
channel: ${channel}
vendor: ${vendor}