Compare commits

..

36 Commits

Author SHA1 Message Date
siujamo 20bc18d416 fix: rename misnamed loggingFilter bean to webhookFilter 2026-06-01 16:59:05 +08:00
siujamo 4ee741d307 feat: add firearm name lookup for webhook YAML parsing
When firearmId is absent from the YAML block, resolveFirearmId falls back
to firearmName lookup via FirearmRepository.findByName(). If both are
present, firearmId takes precedence.
2026-06-01 16:34:37 +08:00
siujamo 0530c1f633 feat: add allowed-users sender filtering for GitHub webhook
Only issues submitted by users in the allowed-users list are processed.
An empty or null list allows all senders (no filtering). Checks
sender.login from the webhook payload against the configured list.
2026-06-01 16:30:46 +08:00
siujamo 8a9cf110af chore: remove deprecated GitLab webhook code
GitLab webhook has been superseded by the GitHub webhook implementation.
Remove WebhookController (formerly GitLabWebhookController),
GitLabWebhookRequest DTO, and GitLabWebhookInterceptor.
2026-06-01 15:37:06 +08:00
siujamo c30b5701e4 feat: implement GitHub webhook controller and processing service
WebhookService parses YAML-fenced issue body, filters by "weapon-mod"
label, deduplicates via Redis SETNX with 12h TTL, and delegates to
ModificationManager for single/batch creation.

GitHubWebhookController verifies X-GitHub-Event=issues and action=opened
before delegating to the service.

Register GitHubWebhookInterceptor for /webhooks/github in AppConfig.
2026-06-01 15:30:30 +08:00
siujamo 7fafa0d903 refactor: extract ModificationManager for modification creation
Move create/batchCreate transactional logic from ModificationService into
a dedicated ModificationManager. Both ModificationService and WebhookService
delegate to the manager, respecting the Controller -> Service -> Manager
layering rule.
2026-06-01 15:30:14 +08:00
siujamo 8c8ca58b74 feat: implement GitHub webhook HMAC-SHA256 signature verification
Verify X-Hub-Signature-256 header using CryptoUtil.hmacSha256 from
onixbyte crypto-toolbox. Signature check is skipped when no secret is
configured. Uses MessageDigest.isEqual for constant-time comparison.
2026-06-01 15:29:32 +08:00
siujamo 12469f1b27 feat: replace GitLab webhook properties with GitHub webhook config
Replace GitLabWebhookProperties with GitHubWebhookProperties (secret, allowed-users).
Update WebhookProperties and WebhookManager to use GitHub-only configuration.
Add app.webhook.github defaults to application.yaml.
2026-06-01 15:29:19 +08:00
siujamo 44271eeec4 feat: add GitHub webhook DTOs with snake_case mapping and header constants
Replace GitHubIssueLabeledWebhookRequest with GitHubIssueRequest.
Add number and repository fields for dedup key construction.
Add Jackson @JsonNaming/@JsonIgnoreProperties for GitHub payload deserialisation.
Add GitHubWebhookHeader constants for webhook header names.
2026-06-01 15:27:35 +08:00
siujamo f9c210c8b3 chore: bump onixbyte toolbox to v3.4.0 2026-06-01 13:58:03 +08:00
siujamo ce330bca87 feat: create GitHub Webhook request object 2026-05-29 15:10:48 +08:00
siujamo 7032343487 feat: update URI to match standard in GitLab issues 2026-05-28 15:35:22 +08:00
siujamo 243283b788 docs: reformatted javadocs 2026-05-28 15:28:53 +08:00
siujamo 4810ef2b1f refactor: migrate properties accessing to access via manager 2026-05-28 15:24:42 +08:00
siujamo 72ec875802 docs: add Javadoc for GitLabWebhookInterceptor 2026-05-28 15:22:36 +08:00
siujamo 6240ec1016 Merge branch 'develop' into feature/gitlab-webhook
# Conflicts:
#	src/main/java/com/onixbyte/deltaforceguide/config/AppConfig.java
2026-05-28 15:20:05 +08:00
siujamo 8d24b6082d feat: add gitlab webhook http entrypoint 2026-05-28 15:18:25 +08:00
siujamo 9bc70d5370 feat: add web traffic logger 2026-05-28 15:17:36 +08:00
siujamo d44f5f74fe chore: ignore frp client config 2026-05-28 13:55:22 +08:00
siujamo f866d93fb4 feat: add gitlab webhook verification 2026-05-28 13:54:30 +08:00
siujamo 66b37ec20d fix: add equals and hashCode to Accessory and Tuning entities 2026-05-28 13:51:24 +08:00
siujamo 0d70b27653 feat: add OpenAPI definition with title, contact, and licence 2026-05-26 14:24:56 +08:00
siujamo 673ba03f2b Merge remote-tracking branch 'origin/develop' into develop 2026-05-26 11:20:29 +08:00
siujamo f6255d396c Merge branch 'develop' into 'main'
fix: revert CI branch guard, keep --provenance removal

See merge request onixbyte/delta-force-guide-server!5
2026-05-26 11:00:16 +08:00
siujamo 26cea1db82 chore: add gradle.properties to .gitignore 2026-05-26 10:58:24 +08:00
siujamo 49f9b59b99 chore: add gradle.properties to .gitignore 2026-05-26 10:58:22 +08:00
siujamo 1f42921689 fix: revert CI branch guard, keep --provenance removal
Revert the main-branch-only rule since $CI_COMMIT_BRANCH is unset for tag
pipelines. The --provenance=false removal remains as the fix for legacy builder.
2026-05-26 10:56:51 +08:00
siujamo 8f102f54c7 Merge branch 'develop' into 'main'
fix: restrict GitLab CI to main branch and drop --provenance flag

See merge request onixbyte/delta-force-guide-server!4
2026-05-26 10:48:38 +08:00
siujamo 9fe292963c fix: restrict GitLab CI to main branch and drop --provenance flag
- Add main branch guard to pipeline rules
- Remove unsupported --provenance=false flag incompatible with legacy builder
2026-05-26 10:47:28 +08:00
siujamo d3681916b2 Merge branch 'develop' into 'main'
v1.3.1: Versioning and Daily Password

See merge request onixbyte/delta-force-guide-server!3
2026-05-26 10:43:12 +08:00
siujamo d27f6455d8 docs: add README, LICENCE, and production config template
- Add MIT LICENCE file
- Add comprehensive README with tech stack, API overview, and architecture docs
- Add example production configuration template
- Remove gradle.properties in favour of build-time version injection
@
2026-05-26 10:42:01 +08:00
siujamo 0671937ecd feat: add versioning entrypoint 2026-05-26 10:18:27 +08:00
siujamo e2a40795c5 chore: opt-in to Node.js 24 for GitHub Actions to clear deprecation warning
Set the environment variable FORCE_JAVASCRIPT_ACTIONS_TO_NODE24 to true
to force the workflow and runner to execute all JavaScript actions using
Node.js 24. This resolves the future deprecation warning for Node.js 20.
2026-05-25 16:01:19 +08:00
siujamo a8ff1cabad chore: rewrite GitHub Actions to build, publish release JAR, and push image to GHCR
Update build-and-deploy.yml workflow to:
1. Run single job 'build-and-release' to bypass artifact transfers.
2. Build JAR with -PartefactVersion parameter.
3. Upload the compiled JAR asset directly into GitHub Releases.
4. Build and push the Docker image directly to GitHub Container Registry (ghcr.io).
2026-05-25 15:52:54 +08:00
siujamo e4dca61f98 chore: merge CI stages into a single release job to optimize speed
Merge build, package, and deploy stages into a single 'release' job. By building
the jar and running docker commands in the same container using local docker socket,
we completely bypass the need for GitLab artifact uploading/downloading. This significantly
reduces network overhead and speeds up release deployment.
2026-05-25 15:43:23 +08:00
siujamo e7da3a76b7 ci: recover artefact uploading 2026-05-25 14:45:26 +08:00
38 changed files with 1259 additions and 145 deletions
+34 -75
View File
@@ -6,15 +6,17 @@ on:
env:
APP_NAME: delta-force-guide-server
IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }}
IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }}
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ================================================================
# Job 1 — Package: build the JAR with Gradle
# Single Job: Build, Upload JAR to Release, and Push to GHCR
# ================================================================
package:
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
@@ -28,54 +30,47 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
# 使用 Release Tag 做为 Gradle 属性传入
- name: Build with Gradle
run: ./gradlew build
- name: Upload JAR artifact
uses: actions/upload-artifact@v4
with:
name: app-jar
path: build/libs/delta-force-guide-server-*.jar
retention-days: 1
# ================================================================
# Job 2 — Build & push Docker image to GitHub Container Registry
# ================================================================
build-and-push:
needs: package
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Download JAR artifact
uses: actions/download-artifact@v4
with:
name: app-jar
path: build/libs
run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}"
- name: Resolve JAR file path
id: jar
run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT"
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
- name: Log in to GitLab Container Registry
# 登录到 GitHub Container Registry (GHCR)
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ vars.GITLAB_REGISTRY_USER }}
password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }}
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 镜像打标签准备
- name: Generate image tags
id: meta
run: |
echo "version=${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "latest=${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
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:
@@ -84,44 +79,8 @@ jobs:
build-args: JAR_FILE=${{ steps.jar.outputs.file }}
push: true
tags: |
${{ steps.meta.outputs.version }}
${{ steps.meta.outputs.latest }}
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
# ================================================================
# Job 3 — Deploy on the target server via SSH
# ================================================================
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
script: |
set -e
echo '=== Pulling image ==='
echo '${{ secrets.GITLAB_REGISTRY_PASSWORD }}' | docker login ${{ env.IMAGE_REGISTRY }} \
-u ${{ vars.GITLAB_REGISTRY_USER }} --password-stdin
docker pull ${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Stopping old container ==='
docker stop ${{ env.APP_NAME }} || true
docker rm ${{ env.APP_NAME }} || true
echo '=== Starting new container ==='
docker run -d \
--name ${{ env.APP_NAME }} \
--restart unless-stopped \
-p ${DEPLOY_PORT:-8080}:8080 \
${{ env.IMAGE_NAME }}:${{ github.event.release.tag_name }}
echo '=== Cleaning up old images ==='
docker image prune -f
echo '=== Deployment complete ==='
+5
View File
@@ -155,3 +155,8 @@ gradle-app.setting
.project
# JDT-specific (Eclipse Java Development Tools)
.classpath
gradle.properties
# frp config
frpc.toml
+4 -24
View File
@@ -3,12 +3,10 @@ variables:
DOCKER_HOST: unix:///var/run/docker.sock
stages:
- build
- package
- deploy
- release
build:
stage: build
release:
stage: release
image: amazoncorretto:21-alpine
cache:
key: gradle
@@ -17,35 +15,17 @@ build:
- .gradle/caches
before_script:
- chmod +x gradlew
- apk add --no-cache docker-cli
script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
rules:
- if: $CI_COMMIT_TAG
package:
stage: package
image: docker:27
needs:
- build
script:
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build
--provenance=false
-f Dockerfile.ci
--build-arg JAR_FILE="$JAR_FILE"
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
.
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
deploy:
stage: deploy
image: docker:27
needs:
- package
script:
- echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
+21
View File
@@ -0,0 +1,21 @@
MIT Licence
Copyright (c) 2026 OnixByte
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+150
View File
@@ -0,0 +1,150 @@
# Delta Force Guide Server
REST API backend for managing **Delta Force** game firearm builds and modifications. Provides endpoints to browse, create, and share weapon configurations including attachments, tuning setups, tags, and build reviews.
## Tech Stack
| Layer | Technology |
|-----------|------------------------------------------------------------|
| Language | Java 21 (Amazon Corretto) |
| Framework | Spring Boot 3.x (Web, Security, Data JPA, Cache, Actuator) |
| Build | Gradle (Kotlin DSL) |
| Database | PostgreSQL (Flyway migrations, JSONB columns) |
| Cache | Redis (2-hour TTL) |
| Auth | Custom JWT via httpOnly cookies + BCrypt |
| API Docs | springdoc-openapi (Swagger UI, dev profile only) |
| Container | Multi-stage Docker image (Amazon Corretto 21 → Alpine) |
| CI/CD | GitHub Actions — builds on release publish |
## Quick Start
### Prerequisites
- JDK 21 (Amazon Corretto recommended)
- PostgreSQL
- Redis
### Configure
Copy and customise the development config:
```bash
cp config/application-prod.yaml.example \
config/application-dev.yaml
```
Fill in your datasource and Redis connection details, then place the production config at `config/`, then enable it by environment variable `SPRING_PROFILES_ACTIVE`.
### Build
```bash
# Compile (skip tests)
./gradlew build -x test
# Run all tests
./gradlew test
# Build executable JAR
./gradlew bootJar
```
### Run
```bash
SPRING_PROFILES_ACTIVE=prod java -jar build/libs/delta-force-guide-server-$version.jar
```
Swagger UI is available at `http://localhost:8080/swagger-ui.html` when the `dev` profile is active.
## Data Model
- **Firearm** — weapon base stats (name, type, level, calibre, fire rate, armour/body damage, review)
- **Modification** — a build attached to a firearm, with name, code, tags (JSONB), accessories including nested tuning objects (JSONB), author notes, and video links
- **App User** — registered user (username, email) with hashed credentials via BCrypt
Tags and accessories are stored as PostgreSQL JSONB columns using [Hypersistence Utils](https://github.com/vladmihalcea/hypersistence-utils), enabling flexible per-build metadata and filtering with the `@>` operator.
## Architecture
```
Controller → Service → Manager → Repository / Mapper
(HTTP) (logic) (@Transactional) (data access)
```
The call chain is strictly enforced — skipping layers is not permitted. All request/response objects are Java records with static `from()` factory methods for entity-to-DTO conversion.
## Docker
```bash
# Build image
docker pull registry.onixbyte.cn/onixbyte/delta-force-guide-server:latest
# Run container
docker run -p 8080:8080 \
-v /path/to/config:/app/config \
-e SPRING_PROFILES_ACTIVE=$your_active_profiles
delta-force-guide-server
```
Pre-built images are published to **Self-hosted GitLab Container Registry** (`registry.onixbyte.cn/onixbyte/delta-force-guide-server`) on every release.
## CI/CD
GitLab CI triggers on **tags**. The pipeline:
1. Builds the boot JAR with the release tag as the version
2. Uploads the JAR as a release asset
3. Builds and pushes a multi-arch Docker image to GHCR tagged with both `:latest` and `:<version>`
No tests are run in CI by design — tests are expected to pass locally before a release is cut.
## Profiles
| Profile | Purpose |
|---------|-----------------------------------------------------------------------|
| `dev` | Enables Swagger UI, connects to dev DB/Redis at `dfguide.onixbyte.cn` |
| default | Production mode, no Swagger, uses production datasource |
## Project Structure
```
src/main/java/com/onixbyte/deltaforceguide/
├── client/ External service HTTP clients
├── config/ Spring bean definitions (Security, CORS, Cache, Jackson, MyBatis)
├── controller/ REST controllers
├── domain/
│ ├── converter/ JPA attribute converters
│ ├── dto/ Request/response records
│ └── entity/ JPA entities
├── enumeration/ Enums (FirearmType)
├── exception/ Custom BizException with HTTP status mapping
├── filter/ TokenAuthenticationFilter (JWT through OncePerRequestFilter)
├── manager/ @Transactional wrappers around repositories
├── mapper/ MyBatis mapper interfaces (reserved for future use)
├── properties/ @ConfigurationProperties records
├── repository/ Spring Data JPA repositories
├── security/
│ ├── authentication/ Custom UsernamePasswordAuthentication
│ └── provider/ UsernamePasswordAuthenticationProvider
├── service/ Business logic
├── shared/ Constants and utility classes
└── utils/ General-purpose helpers
```
## Versioning
The application exposes its version at `/versions` (GET).
## Licence
This project is licensed under the [MIT Licence](LICENCE). Copyright &copy; 2026 OnixByte.
+1
View File
@@ -36,6 +36,7 @@ dependencies {
implementation(libs.onixbyte.identityGenerator)
implementation(libs.onixbyte.captcha)
implementation(libs.onixbyte.regions)
implementation(libs.onixbyte.cryptoToolbox)
implementation(libs.jwt.core)
implementation(libs.spring.boot.configurationProcessor)
implementation(libs.spring.boot.actuator)
+55
View File
@@ -0,0 +1,55 @@
spring:
datasource:
url: jdbc:postgresql://localhost:5432/dfguide_dev
username: postgres
password: 123456
driver-class-name: org.postgresql.Driver
data:
redis:
host: localhost
port: 6379
database: 0
# password: 6hLFVqfGPviTYukn # Uncomment if password is necessary
logging:
pattern:
# dateformat: dd MMM yyyy HH:mm:ss.SSS # Modify this for custom date format.
app:
common:
version: 1.3.0.8-dev # Application version, you can change to any version you like, used for communication with frontend.
cors:
allowed-origins: # Cross-origin allowed origins
- "http://localhost:5173" # Dev server for vite.
- "http://localhost:4173" # Preview server for vite.
allow-credentials: true # Must be set to `true` since we are using cookie. You can change it only when you have modified how this application executing authentication.
allow-private-network: false
allowed-headers:
- "Content-Type"
- "Authorization"
allowed-methods:
- GET
- POST
- PUT
- PATCH
- DELETE
exposed-headers:
- "Content-Type"
- "Authorization"
max-age: PT2H
jwt:
issuer: dfguide.local # Issuer host
secret: qwertyuiopasdfghjklzxcvbnm123456 # JWT singing secret, a 32-byte long or longer string is recommended
valid-time: PT2H # JWT valid duration
cookie: # Cookie settings.
http-only: true
secure: false
same-site: lax
path: '/'
max-age: PT2H
springdoc:
api-docs:
enabled: true # Set to `false` if you do not need api docs (recommended in production mode).
swagger-ui:
enabled: true # Set to `false` if you do not need swagger ui (recommended in production mode).
-1
View File
@@ -1 +0,0 @@
artefactVersion = 1.2.0
+2 -4
View File
@@ -4,10 +4,9 @@ javaJwtVersion = "4.5.1"
postgresDriverVersion = "42.7.9"
h2Version = "2.2.224"
springBootVersion = "3.5.13"
springSecurityVersion = "6.5.2"
reactorVersion = "3.7.8"
junitPlatformVersion = "1.12.2"
onixbyteVersion = "3.3.0"
onixbyteVersion = "3.4.0"
onixbyteCaptcha = "1.1.0"
onixbyteRegions = "2025.12.0"
awsSdkVersion = "2.25.48"
@@ -32,6 +31,7 @@ jackson-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-dat
onixbyte-versionCatalogue = { group = "com.onixbyte", name = "version-catalogue", version.ref = "onixbyteVersion" }
onixbyte-tuple = { group = "com.onixbyte", name = "tuple" }
onixbyte-commonToolbox = { group = "com.onixbyte", name = "common-toolbox" }
onixbyte-cryptoToolbox = { group = "com.onixbyte", name = "crypto-toolbox", version.ref = "onixbyteVersion" }
onixbyte-mathToolbox = { group = "com.onixbyte", name = "math-toolbox" }
onixbyte-identityGenerator = { group = "com.onixbyte", name = "identity-generator" }
onixbyte-captcha = { group = "com.onixbyte", name = "captcha", version.ref = "onixbyteCaptcha" }
@@ -42,12 +42,10 @@ mybatis-starter-core = { group = "org.mybatis.spring.boot", name = "mybatis-spri
spring-boot-starter-jpa = { group = "org.springframework.boot", name = "spring-boot-starter-data-jpa" }
hypersistence-core = { group = "io.hypersistence", name = "hypersistence-utils-hibernate-63", version.ref = "hypersistenceVersion" }
postgres-driver = { group = "org.postgresql", name = "postgresql", version.ref = "postgresDriverVersion" }
mysql-driver = { group = "com.mysql", name = "mysql-connector-j" }
h2-database = { group = "com.h2database", name = "h2", version.ref = "h2Version" }
spring-boot-starter-redis = { group = "org.springframework.boot", name = "spring-boot-starter-data-redis", version.ref = "springBootVersion" }
flyway-core = { group = "org.flywaydb", name = "flyway-core" }
flyway-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql" }
flyway-mysql = { group = "org.flywaydb", name = "flyway-mysql" }
# Spring Boot Core & Web
spring-boot-starter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" }
@@ -0,0 +1,35 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.interceptor.GitHubWebhookInterceptor;
import com.onixbyte.deltaforceguide.interceptor.TrafficInterceptor;
import com.onixbyte.deltaforceguide.properties.AppProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig 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");
}
}
@@ -0,0 +1,21 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.filter.WebhookFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<WebhookFilter> webhookFilter(WebhookFilter webhookFilter) {
var registrationBean = new FilterRegistrationBean<WebhookFilter>();
registrationBean.setFilter(webhookFilter);
registrationBean.addUrlPatterns("/webhooks/*");
registrationBean.setOrder(1);
return registrationBean;
}
}
@@ -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 {
}
@@ -0,0 +1,10 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(WebhookProperties.class)
public class WebhookConfig {
}
@@ -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();
}
}
@@ -0,0 +1,26 @@
package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.service.AppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "版本信息")
@RestController
@RequestMapping("/versions")
public class VersionController {
private final AppService appService;
public VersionController(AppService appService) {
this.appService = appService;
}
@Operation(description = "获取当前应用版本号")
@GetMapping
public String getVersion() {
return appService.getVersion();
}
}
@@ -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
) {
}
@@ -2,6 +2,7 @@ package com.onixbyte.deltaforceguide.domain.entity;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class Accessory {
@@ -45,4 +46,22 @@ public class Accessory {
public void removeTuning(Tuning tuning) {
this.tunings.remove(tuning);
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Accessory accessory)) {
return false;
}
return Objects.equals(slotName, accessory.slotName)
&& Objects.equals(accessoryName, accessory.accessoryName)
&& Objects.equals(tunings, accessory.tunings);
}
@Override
public int hashCode() {
return Objects.hash(slotName, accessoryName, tunings);
}
}
@@ -1,5 +1,7 @@
package com.onixbyte.deltaforceguide.domain.entity;
import java.util.Objects;
public class Tuning {
private String tuningName;
@@ -23,4 +25,21 @@ public class Tuning {
public void setTuningValue(Double tuningValue) {
this.tuningValue = tuningValue;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Tuning tuning)) {
return false;
}
return Objects.equals(tuningName, tuning.tuningName)
&& Objects.equals(tuningValue, tuning.tuningValue);
}
@Override
public int hashCode() {
return Objects.hash(tuningName, tuningValue);
}
}
@@ -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,85 @@
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 javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
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.github().secret();
if (secret == null || secret.isBlank()) {
log.debug("No GitHub webhook secret configured, skipping signature verification");
return true;
}
var signatureHeader = req.getHeader(GitHubWebhookHeader.SIGNATURE_256);
if (signatureHeader == null || signatureHeader.isBlank()) {
log.warn("Missing {} header from ip={}",
GitHubWebhookHeader.SIGNATURE_256, request.getRemoteAddr());
throw new BizException(HttpStatus.UNAUTHORIZED,
"Missing webhook signature header");
}
var body = req.getBodyString();
try {
var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body);
if (!MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8))) {
log.warn("Invalid webhook signature from ip={}", request.getRemoteAddr());
throw new BizException(HttpStatus.UNAUTHORIZED, "Invalid webhook signature");
}
return true;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to compute HMAC-SHA256", e);
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR,
"Failed to verify webhook signature");
}
}
}
@@ -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.info("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();
}
}
@@ -0,0 +1,23 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.properties.AppProperties;
import org.springframework.stereotype.Component;
@Component
public class AppManager {
private final AppProperties appProperties;
public AppManager(AppProperties appProperties) {
this.appProperties = appProperties;
}
/**
* Retrieves the application version.
*
* @return the version string of this application
*/
public String getVersion() {
return appProperties.version();
}
}
@@ -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;
}
}
@@ -0,0 +1,21 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component
public class WebhookManager {
private final WebhookProperties webhookProperties;
@Autowired
public WebhookManager(WebhookProperties webhookProperties) {
this.webhookProperties = webhookProperties;
}
public GitHubWebhookProperties github() {
return webhookProperties.github();
}
}
@@ -0,0 +1,9 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common")
public record AppProperties(
String version
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.deltaforceguide.properties;
import java.util.List;
public record GitHubWebhookProperties(
String secret,
List<String> allowedUsers
) {
}
@@ -0,0 +1,9 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.webhook")
public record WebhookProperties(
GitHubWebhookProperties github
) {
}
@@ -7,9 +7,13 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface FirearmRepository extends JpaRepository<Firearm, Long> {
Page<Firearm> findAllByType(FirearmType type, Pageable pageable);
List<Firearm> findByName(String name);
}
@@ -0,0 +1,18 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.manager.AppManager;
import org.springframework.stereotype.Service;
@Service
public class AppService {
private final AppManager appManager;
public AppService(AppManager appManager) {
this.appManager = appManager;
}
public String getVersion() {
return appManager.getVersion();
}
}
@@ -9,6 +9,7 @@ import com.onixbyte.deltaforceguide.domain.entity.Accessory;
import com.onixbyte.deltaforceguide.domain.entity.Firearm;
import com.onixbyte.deltaforceguide.domain.entity.Modification;
import com.onixbyte.deltaforceguide.domain.entity.Tuning;
import com.onixbyte.deltaforceguide.manager.ModificationManager;
import com.onixbyte.deltaforceguide.repository.FirearmRepository;
import com.onixbyte.deltaforceguide.repository.ModificationRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
@@ -21,10 +22,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@@ -32,15 +31,18 @@ public class ModificationService {
private final ModificationRepository modificationRepository;
private final FirearmRepository firearmRepository;
private final ModificationManager modificationManager;
private final ObjectMapper objectMapper;
public ModificationService(
ModificationRepository modificationRepository,
FirearmRepository firearmRepository,
ModificationManager modificationManager,
ObjectMapper objectMapper
) {
this.modificationRepository = modificationRepository;
this.firearmRepository = firearmRepository;
this.modificationManager = modificationManager;
this.objectMapper = objectMapper;
}
@@ -79,36 +81,12 @@ public class ModificationService {
@Transactional
public ModificationResponse create(ModificationRequest request) {
Firearm firearm = firearmRepository.findById(request.firearmId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Firearm not found: " + request.firearmId()));
Modification modification = toEntity(request, firearm);
return ModificationResponse.from(modificationRepository.save(modification));
return modificationManager.create(request);
}
@Transactional
public List<ModificationResponse> batchCreate(List<ModificationRequest> requests) {
Set<Long> 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()) {
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())))
.toList();
return modificationRepository.saveAll(modifications)
.stream()
.map(ModificationResponse::from)
.toList();
return modificationManager.batchCreate(requests);
}
@Transactional
@@ -155,19 +133,6 @@ public class ModificationService {
modificationRepository.deleteAllInBatch(modifications);
}
private Modification toEntity(ModificationRequest request, Firearm firearm) {
return Modification.builder()
.firearm(firearm)
.name(request.name())
.code(request.code())
.tags(safeTags(request.tags()))
.accessories(toAccessories(request.accessories()))
.note(request.note())
.author(request.author())
.videoUrl(request.videoUrl())
.build();
}
private List<String> safeTags(List<String> tags) {
return tags == null ? new ArrayList<>() : tags;
}
@@ -0,0 +1,217 @@
package com.onixbyte.deltaforceguide.service;
import com.onixbyte.deltaforceguide.domain.dto.AccessoryRequest;
import com.onixbyte.deltaforceguide.domain.dto.GitHubIssueRequest;
import com.onixbyte.deltaforceguide.domain.dto.ModificationRequest;
import com.onixbyte.deltaforceguide.domain.dto.TuningRequest;
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.stereotype.Service;
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*\\n(.*?)```", 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;
}
@SuppressWarnings("unchecked")
var data = (Map<String, Object>) yaml.load(parsedYaml);
if (data == null) {
log.warn("Empty YAML block in issue #{}", issue.number());
return;
}
if (data.containsKey("modifications")) {
processBatch(issue.number(), data);
} else {
processSingle(issue.number(), data);
}
}
private void processSingle(Long issueNumber, Map<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"));
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(
com.onixbyte.deltaforceguide.domain.dto.GitHubWebhookSender sender
) {
var allowedUsers = webhookManager.github().allowedUsers();
if (allowedUsers == null || allowedUsers.isEmpty()) {
return true;
}
if (sender == null || sender.login() == null) {
return false;
}
return allowedUsers.contains(sender.login());
}
private boolean hasTriggerLabel(List<com.onixbyte.deltaforceguide.domain.dto.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;
}
}
@@ -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";
}
@@ -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
@@ -39,6 +39,12 @@ mybatis:
type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler
mapper-locations: classpath:/mapper/*.xml
app:
webhook:
github:
secret: ${GITHUB_WEBHOOK_SECRET:}
allowed-users: []
logging:
level:
org.hibernate: