Compare commits

..

56 Commits

Author SHA1 Message Date
siujamo a065b60cae chore: migrate CI from GitHub Actions to Gitea Actions
Replace GitHub Actions workflow with Gitea Actions, and switch
container registry from GHCR to Docker Hub.
2026-06-04 17:36:43 +08:00
siujamo 17cd87c702 feat: inject build-time variables via Gradle processResources
Replace hardcoded AppProperties values with Gradle ${} placeholders,
allowing version/channel/vendor to be configured via gradle.properties
or -P flags at build time.

Also refactor webhook configuration to flatten the properties hierarchy
by removing the intermediate WebhookProperties wrapper.
2026-06-04 17:12:48 +08:00
siujamo 4e2da0debc feat: add expire time into login response 2026-06-04 14:47:45 +08:00
siujamo 0815d1d618 chore: optimise code style 2026-06-04 14:42:14 +08:00
zihluwang d323e4f8f7 chore: change traffic interceptor log level from info to debug 2026-06-02 01:46:49 +08:00
siujamo eb2d9b3369 fix: rename webhook filter bean to avoid name collision with WebhookFilter class 2026-06-01 17:18:42 +08:00
siujamo a0d54cc12d Merge pull request #2 from onixbyte/feature/github-webhook
feat: GitHub webhook — create modifications from labelled issues
2026-06-01 17:04:21 +08:00
siujamo 4eafb3ade7 Merge branch 'develop' into feature/github-webhook 2026-06-01 17:03:48 +08:00
siujamo 9594efe716 fix: use BizException instead of ResponseStatusException for firearmId validation 2026-06-01 17:01:50 +08:00
siujamo 5b5062aae9 fix: validate firearmId is present before constructing ModificationRequest 2026-06-01 17:00:03 +08:00
siujamo b0c41e08ea fix: use \R instead of \n in YAML fence regex for cross-platform linebreaks 2026-06-01 16:59:47 +08:00
siujamo ed2a0f4ae0 style: replace fully qualified type names and clean up imports 2026-06-01 16:59:40 +08:00
siujamo de61e1feb7 refactor: remove redundant @Transactional from delegated service methods 2026-06-01 16:59:12 +08:00
siujamo 3616ad9eab chore: remove unused imports in GitHubWebhookInterceptor 2026-06-01 16:59:08 +08:00
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
zihluwang bd4fe65b03 Merge remote-tracking branch 'origin/develop' into develop 2026-05-29 00:42:34 +08:00
zihluwang eb22b3c4bb docs: add Javadocs 2026-05-29 00:42:27 +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
siujamo 5cea825bc0 chore: remove gitlab artifacts to avoid slow uploads
Remove artifacts uploading from the build stage. Since we use a shared
docker socket on the same runner host, the package stage can access the
locally built jar file directly without needing gitlab coordinator upload/download.
2026-05-25 14:39:33 +08:00
siujamo bd2748e25c fix: disable provenance in docker build to fix GitLab Registry 0B display
Add `--provenance=false` flag to `docker build` command. This stops Docker BuildKit
from generating OCI Referrers/attestations, which are not correctly parsed by GitLab
Container Registry and cause the UI to display 0B size and "missing manifest digest" errors.
2026-05-25 13:58:14 +08:00
siujamo 0f1093774f fix: use GitLab predefined environment variables for container registry
Replace custom registry variables with GitLab's predefined CI_REGISTRY,
CI_REGISTRY_IMAGE, and CI_REGISTRY_USER to ensure the built-in CI_JOB_TOKEN
has correct push permissions.
2026-05-25 11:47:22 +08:00
siujamo d19b7f5563 fix: allow jar files to be copied in Docker build context
Add '!build/libs/*.jar' to .dockerignore so that Docker build can access the build
artifacts in the package stage.
2026-05-25 11:05:25 +08:00
102 changed files with 2020 additions and 253 deletions
+2
View File
@@ -1,4 +1,6 @@
delta-force-guide-server.iml delta-force-guide-server.iml
build/ build/
!.gitlab-ci.yml
!build/libs/*.jar
.idea/ .idea/
.gradle .gradle
+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 }}
-127
View File
@@ -1,127 +0,0 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
IMAGE_REGISTRY: ${{ vars.GITLAB_REGISTRY }}
IMAGE_NAME: ${{ vars.GITLAB_IMAGE_NAME }}
jobs:
# ================================================================
# Job 1 — Package: build the JAR with Gradle
# ================================================================
package:
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: Set up Gradle
uses: gradle/actions/setup-gradle@v4
- 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
- name: Resolve JAR file path
id: jar
run: echo "file=$(ls build/libs/delta-force-guide-server-*.jar | head -1)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitLab Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.IMAGE_REGISTRY }}
username: ${{ vars.GITLAB_REGISTRY_USER }}
password: ${{ secrets.GITLAB_REGISTRY_PASSWORD }}
- 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"
- 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.version }}
${{ steps.meta.outputs.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 .project
# JDT-specific (Eclipse Java Development Tools) # JDT-specific (Eclipse Java Development Tools)
.classpath .classpath
gradle.properties
# frp config
frpc.toml
+10 -35
View File
@@ -1,16 +1,12 @@
variables: variables:
REGISTRY: registry.onixbyte.cn
IMAGE_NAME: delta-force-guide
GRADLE_OPTS: -Dorg.gradle.daemon=false GRADLE_OPTS: -Dorg.gradle.daemon=false
DOCKER_HOST: unix:///var/run/docker.sock DOCKER_HOST: unix:///var/run/docker.sock
stages: stages:
- build - release
- package
- deploy
build: release:
stage: build stage: release
image: amazoncorretto:21-alpine image: amazoncorretto:21-alpine
cache: cache:
key: gradle key: gradle
@@ -19,41 +15,20 @@ build:
- .gradle/caches - .gradle/caches
before_script: before_script:
- chmod +x gradlew - chmod +x gradlew
- apk add --no-cache docker-cli
script: script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG" - ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
artifacts:
paths:
- build/libs/*.jar
expire_in: 30 min
rules:
- if: $CI_COMMIT_TAG
package:
stage: package
image: docker:27
needs:
- build
script:
- JAR_FILE=$(find build/libs -name '*.jar' | head -1) - JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE" - echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build - docker build
-f Dockerfile.ci -f Dockerfile.ci
--build-arg JAR_FILE="$JAR_FILE" --build-arg JAR_FILE="$JAR_FILE"
-t "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
. .
- docker tag "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG" "$REGISTRY/$IMAGE_NAME:latest" - docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest"
rules: - echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- if: $CI_COMMIT_TAG - docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
deploy: - docker push "$CI_REGISTRY_IMAGE:latest"
stage: deploy
image: docker:27
needs:
- package
script:
- echo "Pushing image $REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG"
- docker login "$REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$REGISTRY/$IMAGE_NAME:$CI_COMMIT_TAG"
- docker push "$REGISTRY/$IMAGE_NAME:latest"
rules: rules:
- if: $CI_COMMIT_TAG - if: $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.
+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()
} }
+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" 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 {
@@ -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");
}
}
@@ -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")
@@ -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();
}
}
@@ -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();
}
}
@@ -0,0 +1,27 @@
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 "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();
}
}
@@ -0,0 +1,11 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common")
public record AppProperties(
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);
@@ -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();
}
}
@@ -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())))
.toList();
return modificationRepository.saveAll(modifications)
.stream()
.map(ModificationResponse::from)
.toList();
} }
@Transactional /**
* Updates an existing modification identified by ID.
*
* @param id the modification ID
* @param request the updated modification data
* @return the updated modification response
*/
public ModificationResponse update(Long id, ModificationRequest request) { 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();

Some files were not shown because too many files have changed in this diff Show More