Compare commits

...

11 Commits

Author SHA1 Message Date
siujamo 0aeaac17a4 feat: custom 401/403 error handling for unauthenticated requests
Replace default Spring Security 403 responses with custom 401 (not logged in)
and 403 (insufficient permissions) JSON error responses via exception handling
and global exception handler. Also bump API version to 1.4.0 and add Swagger
annotations for the GitHub webhook controller.
2026-06-29 09:38:12 +08:00
siujamo a2469c7573 docs: update application configuration example
Build and Deploy / build-and-release (release) Failing after 5m14s
2026-06-08 13:32:16 +08:00
siujamo 62fcce3005 feat: add OCI labels and README push for Docker Hub
Add OCI standard labels to Dockerfile.ci and push README.md to
Docker Hub image page via docker-pushrm in CI workflow.
2026-06-05 10:20:15 +08:00
siujamo c18a108a2b ci: fix build error
Build and Deploy / build-and-release (release) Successful in 14m20s
2026-06-05 09:40:12 +08:00
siujamo fb2732508a ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m24s
2026-06-05 09:30:28 +08:00
siujamo ceac8a3f8c ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m22s
2026-06-05 09:14:37 +08:00
siujamo f8c0e3e8b6 ci: fix build error
Build and Deploy / build-and-release (release) Failing after 11m36s
2026-06-05 08:58:55 +08:00
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
21 changed files with 209 additions and 167 deletions
+85
View File
@@ -0,0 +1,85 @@
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
-Pvendor=${{ vars.VENDOR }}
- 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: ${{ vars.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Generate image tags
id: meta
run: |
DOCKERHUB_USER="${{ vars.DOCKER_HUB_USERNAME }}"
echo "tag_version=${DOCKERHUB_USER}/${APP_NAME}:${{ gitea.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_latest=${DOCKERHUB_USER}/${APP_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 }}
IMAGE_VERSION=${{ gitea.event.release.tag_name }}
IMAGE_VENDOR=${{ vars.VENDOR }}
BUILD_DATE=${{ gitea.event.release.published_at }}
push: true
tags: |
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
- name: Push README to Docker Hub
run: >
docker run --rm
-v "$(pwd)/README.md:/workspace/README.md:ro"
-e DOCKER_USER="${{ vars.DOCKER_HUB_USERNAME }}"
-e DOCKER_PASS="${{ secrets.DOCKER_HUB_TOKEN }}"
chko/docker-pushrm:latest
"${{ vars.DOCKER_HUB_USERNAME }}/${{ env.APP_NAME }}"
-86
View File
@@ -1,86 +0,0 @@
name: Build and Deploy
on:
release:
types: [published]
env:
APP_NAME: delta-force-guide-server
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ================================================================
# Single Job: Build, Upload JAR to Release, and Push to GHCR
# ================================================================
build-and-release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21 (Corretto)
uses: actions/setup-java@v4
with:
java-version: 21
distribution: corretto
cache: gradle
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4
# 使用 Release Tag 做为 Gradle 属性传入
- name: Build with Gradle
run: ./gradlew bootJar -x test -PartefactVersion="${{ github.event.release.tag_name }}"
- name: Resolve JAR file path
id: jar
run: |
JAR_PATH=$(find build/libs -name '*.jar' | head -1)
echo "file=$JAR_PATH" >> "$GITHUB_OUTPUT"
# 上传 JAR 包到 GitHub Release 中
- name: Upload JAR to GitHub Release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.jar.outputs.file }}
asset_name: ${{ github.event.repository.name }}-${{ github.event.release.tag_name }}.jar
tag: ${{ github.event.release.tag_name }}
overwrite: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录到 GitHub Container Registry (GHCR)
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 镜像打标签准备
- name: Generate image tags
id: meta
run: |
OWNER_LC=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
REPO_LC=$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
echo "tag_version=ghcr.io/$OWNER_LC/$REPO_LC:${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
echo "tag_latest=ghcr.io/$OWNER_LC/$REPO_LC:latest" >> "$GITHUB_OUTPUT"
# 构建并上传镜像到 GHCR
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.ci
build-args: JAR_FILE=${{ steps.jar.outputs.file }}
push: true
tags: |
${{ steps.meta.outputs.tag_version }}
${{ steps.meta.outputs.tag_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
-34
View File
@@ -1,34 +0,0 @@
variables:
GRADLE_OPTS: -Dorg.gradle.daemon=false
DOCKER_HOST: unix:///var/run/docker.sock
stages:
- release
release:
stage: release
image: amazoncorretto:21-alpine
cache:
key: gradle
paths:
- .gradle/wrapper
- .gradle/caches
before_script:
- chmod +x gradlew
- apk add --no-cache docker-cli
script:
- ./gradlew bootJar -x test -PartefactVersion="$CI_COMMIT_TAG"
- JAR_FILE=$(find build/libs -name '*.jar' | head -1)
- echo "Building Docker image for tag $CI_COMMIT_TAG with JAR $JAR_FILE"
- docker build
-f Dockerfile.ci
--build-arg JAR_FILE="$JAR_FILE"
-t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
.
- docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" "$CI_REGISTRY_IMAGE:latest"
- echo "Pushing image $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD"
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: $CI_COMMIT_TAG
+12
View File
@@ -2,6 +2,18 @@ FROM amazoncorretto:21-alpine
WORKDIR /app
ARG JAR_FILE
ARG IMAGE_VERSION=dev
ARG IMAGE_VENDOR=OnixByte
ARG BUILD_DATE
COPY ${JAR_FILE} app.jar
LABEL org.opencontainers.image.title="delta-force-guide-server" \
org.opencontainers.image.description="REST API backend for managing Delta Force game firearm builds and modifications" \
org.opencontainers.image.version="${IMAGE_VERSION}" \
org.opencontainers.image.vendor="${IMAGE_VENDOR}" \
org.opencontainers.image.source="https://git.onixbyte.com/onixbyte/delta-force-guide-server" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.created="${BUILD_DATE}"
ENTRYPOINT ["java", "-jar", "app.jar"]
+13
View File
@@ -5,6 +5,8 @@ plugins {
}
val artefactVersion: String by project
val buildChannel: String by project
val vendor: String by project
group = "com.onixbyte.helix"
version = artefactVersion
@@ -61,6 +63,17 @@ dependencies {
testRuntimeOnly(libs.junit.launcher)
}
tasks.processResources {
filesMatching("application.yaml") {
println("appVersion = ${artefactVersion}, channel = ${buildChannel}, vendor = ${vendor}")
expand(
"appVersion" to artefactVersion,
"channel" to buildChannel,
"vendor" to vendor
)
}
}
tasks.test {
useJUnitPlatform()
}
+11 -9
View File
@@ -9,15 +9,19 @@ spring:
host: localhost
port: 6379
database: 0
# password: 6hLFVqfGPviTYukn # Uncomment if password is necessary
# password: qwerty # 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.
cookie:
http-only: true
secure: false
same-site: none
path: '/'
max-age: P1D
cors:
allowed-origins: # Cross-origin allowed origins
- "http://localhost:5173" # Dev server for vite.
@@ -41,12 +45,10 @@ app:
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
webhook:
github:
secret: 123456
allowed-users: [ octotcat, onixbyte ]
springdoc:
api-docs:
@@ -10,14 +10,14 @@ import org.springframework.context.annotation.Configuration;
info = @Info(
title = "Delta Force Guide Server",
description = "API for managing Delta Force game firearm builds",
version = "1.3.4",
version = "1.4.0",
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"
url = "https://onixbyte.dev/onixbyte/delta-force-guide-server/raw/branch/main/LICENCE"
)
)
)
@@ -7,6 +7,8 @@ import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
import com.onixbyte.deltaforceguide.properties.CookieProperties;
import com.onixbyte.deltaforceguide.properties.TokenProperties;
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -19,6 +21,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import com.onixbyte.deltaforceguide.exeption.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.web.cors.CorsConfigurationSource;
@@ -34,6 +38,8 @@ import org.springframework.web.cors.CorsConfigurationSource;
@EnableConfigurationProperties({TokenProperties.class, CookieProperties.class})
public class SecurityConfig {
private static final Logger log = LoggerFactory.getLogger(SecurityConfig.class);
/**
* Configures the HTTP security filter chain including endpoint authorisation and JWT filter.
*
@@ -55,6 +61,16 @@ public class SecurityConfig {
.authorizeHttpRequests((customiser) -> customiser
.anyRequest().permitAll()
)
.exceptionHandling(customiser -> customiser
.authenticationEntryPoint((request, response, authException) -> {
log.error("Unauthenticated request: {}", request, authException);
throw new BizException(HttpStatus.UNAUTHORIZED, "请先登录");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
log.error("Denied request: {}", request, accessDeniedException);
throw new BizException(HttpStatus.FORBIDDEN, "权限不足");
})
)
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
.build();
}
@@ -1,10 +1,10 @@
package com.onixbyte.deltaforceguide.config;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableConfigurationProperties(WebhookProperties.class)
@EnableConfigurationProperties({GitHubWebhookProperties.class})
public class WebhookConfig {
}
@@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* REST controller for user authentication endpoints (login, logout).
@@ -43,12 +44,14 @@ public class AuthController {
@PostMapping("/login")
public ResponseEntity<UserResponse> login(@Validated @RequestBody LoginRequest request) {
var user = authService.login(request);
var currentTime = LocalDateTime.now();
var accessToken = tokenClient.generateToken(user);
var accessTokenCookie = cookieService.buildCookie(CookieName.ACCESS_TOKEN, accessToken);
var cookieMaxAge = accessTokenCookie.getMaxAge();
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, accessTokenCookie.toString())
.body(UserResponse.from(user));
.body(UserResponse.from(user, currentTime.plus(cookieMaxAge)));
}
@RequiresAuth
@@ -3,6 +3,8 @@ 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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "GitHub WebHook")
@RestController
@RequestMapping("/webhooks/github")
public class GitHubWebhookController {
@@ -24,6 +27,7 @@ public class GitHubWebhookController {
this.webhookService = webhookService;
}
@Operation(description = "GitHub WebHook 接口")
@PostMapping
public ResponseEntity<Void> handleWebhook(
@RequestHeader(GitHubWebhookHeader.EVENT) String event,
@@ -2,7 +2,11 @@ package com.onixbyte.deltaforceguide.controller;
import com.onixbyte.deltaforceguide.domain.dto.ErrorResponse;
import com.onixbyte.deltaforceguide.exeption.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@@ -20,5 +24,22 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(status)
.body(new ErrorResponse(exception.getMessage()));
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationException(AuthenticationException exception) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse("请先登录"));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException exception) {
var authentication = SecurityContextHolder.getContext().getAuthentication();
var httpStatus = authentication == null || !authentication.isAuthenticated()
? HttpStatus.UNAUTHORIZED
: HttpStatus.FORBIDDEN;
var message = httpStatus == HttpStatus.UNAUTHORIZED ? "请先登录" : "权限不足";
return ResponseEntity.status(httpStatus)
.body(new ErrorResponse(message));
}
}
@@ -2,6 +2,8 @@ package com.onixbyte.deltaforceguide.domain.dto;
import com.onixbyte.deltaforceguide.domain.entity.User;
import java.time.LocalDateTime;
/**
* Response DTO for a user account, including associated credentials.
*
@@ -10,13 +12,15 @@ import com.onixbyte.deltaforceguide.domain.entity.User;
public record UserResponse(
Long id,
String username,
String email
String email,
LocalDateTime expiration
) {
public static UserResponse from(User user) {
public static UserResponse from(User user, LocalDateTime expiration) {
return new UserResponse(
user.getId(),
user.getUsername(),
user.getEmail()
user.getEmail(),
expiration
);
}
}
@@ -48,7 +48,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor {
"Request body is not readable");
}
var secret = webhookManager.github().secret();
var secret = webhookManager.secret();
if (secret == null || secret.isBlank()) {
log.debug("No GitHub webhook secret configured, skipping signature verification");
return true;
@@ -64,7 +64,7 @@ public class GitHubWebhookInterceptor implements HandlerInterceptor {
var body = req.getBodyString();
try {
var computed = "sha256=" + CryptoUtil.hmacSha256(secret, body);
var computed = "sha256=" + CryptoUtil.hmacSha256(body, secret);
if (!MessageDigest.isEqual(
computed.getBytes(StandardCharsets.UTF_8),
@@ -18,6 +18,10 @@ public class AppManager {
* @return the version string of this application
*/
public String getVersion() {
return appProperties.version();
return "v%s-%s by @%s".formatted(
appProperties.version(),
appProperties.channel(),
appProperties.vendor()
);
}
}
@@ -1,21 +1,24 @@
package com.onixbyte.deltaforceguide.manager;
import com.onixbyte.deltaforceguide.properties.GitHubWebhookProperties;
import com.onixbyte.deltaforceguide.properties.WebhookProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class WebhookManager {
private final WebhookProperties webhookProperties;
private final GitHubWebhookProperties gitHubWebhookProperties;
@Autowired
public WebhookManager(WebhookProperties webhookProperties) {
this.webhookProperties = webhookProperties;
public WebhookManager(GitHubWebhookProperties gitHubWebhookProperties) {
this.gitHubWebhookProperties = gitHubWebhookProperties;
}
public GitHubWebhookProperties github() {
return webhookProperties.github();
public String secret() {
return gitHubWebhookProperties.secret();
}
public List<String> allowedUsers() {
return gitHubWebhookProperties.allowedUsers();
}
}
@@ -4,6 +4,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.common")
public record AppProperties(
String version
String version,
String channel,
String vendor
) {
}
@@ -1,7 +1,10 @@
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
@@ -1,9 +0,0 @@
package com.onixbyte.deltaforceguide.properties;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.webhook")
public record WebhookProperties(
GitHubWebhookProperties github
) {
}
@@ -73,8 +73,7 @@ public class WebhookService {
return;
}
@SuppressWarnings("unchecked")
var data = (Map<String, Object>) yaml.load(parsedYaml);
var data = yaml.<Map<String, Object>>load(parsedYaml);
if (data == null) {
log.warn("Empty YAML block in issue #{}", issue.number());
return;
@@ -173,7 +172,7 @@ public class WebhookService {
private boolean isAllowedSender(
GitHubWebhookSender sender
) {
var allowedUsers = webhookManager.github().allowedUsers();
var allowedUsers = webhookManager.allowedUsers();
if (allowedUsers == null || allowedUsers.isEmpty()) {
return true;
}
+6 -6
View File
@@ -39,13 +39,13 @@ mybatis:
type-handlers-package: com.onixbyte.deltaforceguide.mapper.handler
mapper-locations: classpath:/mapper/*.xml
app:
webhook:
github:
secret: ${GITHUB_WEBHOOK_SECRET:}
allowed-users: []
logging:
level:
org.hibernate:
orm.connections.pooling: off
app:
common:
version: ${appVersion}
channel: ${channel}
vendor: ${vendor}