diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 42c41a71..6bcb3472 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -9,21 +9,53 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
- build-and-deploy:
+ build-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
- id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
- - name: Set image names (lowercase)
+ - name: Set image name (lowercase)
run: |
OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3.3.0
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Build and push backend image
+ uses: docker/build-push-action@v6
+ with:
+ context: ./backend
+ push: true
+ tags: ${{ env.BACKEND_IMAGE }}:latest
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ build-frontend:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4.2.2
+
+ - name: Set image name (lowercase)
+ run: |
+ OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to GitHub Container Registry
@@ -33,12 +65,8 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - name: Build and push backend image
- uses: docker/build-push-action@v6
- with:
- context: ./backend
- push: true
- tags: ${{ env.BACKEND_IMAGE }}:latest
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- name: Build and push frontend image
uses: docker/build-push-action@v6
@@ -48,6 +76,22 @@ jobs:
tags: ${{ env.FRONTEND_IMAGE }}:latest
build-args: |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: [build-backend, build-frontend]
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Set image names (lowercase)
+ run: |
+ OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
+ echo "BACKEND_IMAGE=ghcr.io/${OWNER}/petshop-backend" >> $GITHUB_ENV
+ echo "FRONTEND_IMAGE=ghcr.io/${OWNER}/petshop-web" >> $GITHUB_ENV
- name: Log in to Azure
uses: azure/login@v2
diff --git a/backend/Dockerfile b/backend/Dockerfile
index ed3552be..e868cdd6 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -10,4 +10,4 @@ FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
-ENTRYPOINT ["java","-jar","app.jar"]
+ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-XX:+UseG1GC", "-jar", "app.jar"]
diff --git a/backend/pom.xml b/backend/pom.xml
index a87811c2..ad292bdb 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -38,6 +38,16 @@
spring-boot-starter-security
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+
+
org.springframework.boot
spring-boot-starter-validation
diff --git a/backend/src/main/java/com/petshop/backend/BackendApplication.java b/backend/src/main/java/com/petshop/backend/BackendApplication.java
index f933eb9d..26d18f3c 100644
--- a/backend/src/main/java/com/petshop/backend/BackendApplication.java
+++ b/backend/src/main/java/com/petshop/backend/BackendApplication.java
@@ -4,10 +4,12 @@ import com.petshop.backend.config.FlywayContextInitializer;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
+import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
+@EnableAsync
@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO)
public class BackendApplication {
public static void main(String[] args) {
diff --git a/backend/src/main/java/com/petshop/backend/config/CacheConfig.java b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java
new file mode 100644
index 00000000..4a8bc6b0
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/config/CacheConfig.java
@@ -0,0 +1,24 @@
+package com.petshop.backend.config;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.caffeine.CaffeineCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ @Bean
+ public CacheManager cacheManager() {
+ CaffeineCacheManager manager = new CaffeineCacheManager("userAuthCache");
+ manager.setCaffeine(Caffeine.newBuilder()
+ .expireAfterWrite(60, TimeUnit.SECONDS)
+ .maximumSize(1000));
+ return manager;
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/controller/AuthController.java b/backend/src/main/java/com/petshop/backend/controller/AuthController.java
index e278e206..1b4ed0bf 100644
--- a/backend/src/main/java/com/petshop/backend/controller/AuthController.java
+++ b/backend/src/main/java/com/petshop/backend/controller/AuthController.java
@@ -15,6 +15,7 @@ import com.petshop.backend.entity.StoreLocation;
import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import com.petshop.backend.security.JwtUtil;
+import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.service.ActivityLogService;
import com.petshop.backend.service.AvatarStorageService;
import com.petshop.backend.service.EmailService;
@@ -58,8 +59,9 @@ public class AuthController {
private final ActivityLogService activityLogService;
private final PasswordResetService passwordResetService;
private final EmailService emailService;
+ private final UserAuthCacheService userAuthCacheService;
- public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService) {
+ public AuthController(AuthenticationManager authenticationManager, UserRepository userRepository, JwtUtil jwtUtil, PasswordEncoder passwordEncoder, AvatarStorageService avatarStorageService, ActivityLogService activityLogService, PasswordResetService passwordResetService, EmailService emailService, UserAuthCacheService userAuthCacheService) {
this.authenticationManager = authenticationManager;
this.userRepository = userRepository;
this.jwtUtil = jwtUtil;
@@ -68,6 +70,7 @@ public class AuthController {
this.activityLogService = activityLogService;
this.passwordResetService = passwordResetService;
this.emailService = emailService;
+ this.userAuthCacheService = userAuthCacheService;
}
@PostMapping("/register")
@@ -263,6 +266,7 @@ public class AuthController {
error.put("message", "Username, email, or phone already exists");
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
+ userAuthCacheService.evict(updatedUser.getId());
return ResponseEntity.ok(toUserInfoResponse(updatedUser));
}
diff --git a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java
index a4caaaef..db7d47fc 100644
--- a/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java
+++ b/backend/src/main/java/com/petshop/backend/security/JwtAuthenticationFilter.java
@@ -2,7 +2,6 @@ package com.petshop.backend.security;
import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ApiErrorResponder;
-import com.petshop.backend.repository.UserRepository;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -16,16 +15,18 @@ import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
+import java.util.Date;
+
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
- private final UserRepository userRepository;
+ private final UserAuthCacheService userAuthCacheService;
private final ApiErrorResponder apiErrorResponder;
- public JwtAuthenticationFilter(JwtUtil jwtUtil, UserRepository userRepository, ApiErrorResponder apiErrorResponder) {
+ public JwtAuthenticationFilter(JwtUtil jwtUtil, UserAuthCacheService userAuthCacheService, ApiErrorResponder apiErrorResponder) {
this.jwtUtil = jwtUtil;
- this.userRepository = userRepository;
+ this.userAuthCacheService = userAuthCacheService;
this.apiErrorResponder = apiErrorResponder;
}
@@ -44,30 +45,44 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
jwt = authHeader.substring(7);
Long userId;
+ String username;
+ String roleStr;
+ Integer jwtTokenVersion;
try {
userId = jwtUtil.extractUserId(jwt);
+ username = jwtUtil.extractUsername(jwt);
+ roleStr = jwtUtil.extractRole(jwt);
+ jwtTokenVersion = jwtUtil.extractTokenVersion(jwt);
} catch (JwtException | IllegalArgumentException ex) {
writeUnauthorized(request, response, "Invalid or expired token", ex);
return;
}
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
- User user = userRepository.findById(userId).orElse(null);
- if (user == null || user.getActive() == null || !user.getActive()) {
- writeUnauthorized(request, response, "User account is inactive", null);
- return;
- }
- if (!jwtUtil.validateToken(jwt, user)) {
+ if (jwtUtil.extractExpiration(jwt).before(new Date())) {
writeUnauthorized(request, response, "Invalid or expired token", null);
return;
}
- AppPrincipal principal = new AppPrincipal(
- user.getId(),
- user.getUsername(),
- user.getRole(),
- user.getTokenVersion()
- );
+ UserAuthCacheService.UserAuthData authData = userAuthCacheService.loadAuthData(userId);
+ if (authData == null || !Boolean.TRUE.equals(authData.active())) {
+ writeUnauthorized(request, response, "User account is inactive", null);
+ return;
+ }
+ if (!authData.tokenVersion().equals(jwtTokenVersion)) {
+ writeUnauthorized(request, response, "Invalid or expired token", null);
+ return;
+ }
+
+ User.Role role;
+ try {
+ role = User.Role.valueOf(roleStr);
+ } catch (IllegalArgumentException ex) {
+ writeUnauthorized(request, response, "Invalid or expired token", ex);
+ return;
+ }
+
+ AppPrincipal principal = new AppPrincipal(userId, username, role, jwtTokenVersion);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
principal,
null,
diff --git a/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java
new file mode 100644
index 00000000..69d39a9d
--- /dev/null
+++ b/backend/src/main/java/com/petshop/backend/security/UserAuthCacheService.java
@@ -0,0 +1,29 @@
+package com.petshop.backend.security;
+
+import com.petshop.backend.repository.UserRepository;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+@Service
+public class UserAuthCacheService {
+
+ private final UserRepository userRepository;
+
+ public UserAuthCacheService(UserRepository userRepository) {
+ this.userRepository = userRepository;
+ }
+
+ public record UserAuthData(Boolean active, Integer tokenVersion) {}
+
+ @Cacheable(value = "userAuthCache", key = "#userId")
+ public UserAuthData loadAuthData(Long userId) {
+ return userRepository.findById(userId)
+ .map(u -> new UserAuthData(u.getActive(), u.getTokenVersion()))
+ .orElse(null);
+ }
+
+ @CacheEvict(value = "userAuthCache", key = "#userId")
+ public void evict(Long userId) {
+ }
+}
diff --git a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java
index 540b9383..c57378a8 100644
--- a/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java
+++ b/backend/src/main/java/com/petshop/backend/service/ActivityLogService.java
@@ -5,6 +5,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
@@ -18,6 +19,7 @@ public class ActivityLogService {
this.userRepository = userRepository;
}
+ @Async
public void record(Long userId, String activity) {
if (userId == null || activity == null || activity.isBlank()) {
return;
@@ -36,6 +38,7 @@ public class ActivityLogService {
}
}
+ @Async
public void record(User user, String activity) {
if (user == null) {
return;
diff --git a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java
index 2ecee374..f90cf6d5 100644
--- a/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java
+++ b/backend/src/main/java/com/petshop/backend/service/PasswordResetService.java
@@ -7,6 +7,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.BusinessException;
import com.petshop.backend.repository.PasswordResetTokenRepository;
import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.security.UserAuthCacheService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -29,16 +30,19 @@ public class PasswordResetService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final EmailService emailService;
+ private final UserAuthCacheService userAuthCacheService;
private final SecureRandom secureRandom = new SecureRandom();
public PasswordResetService(PasswordResetTokenRepository passwordResetTokenRepository,
UserRepository userRepository,
PasswordEncoder passwordEncoder,
- EmailService emailService) {
+ EmailService emailService,
+ UserAuthCacheService userAuthCacheService) {
this.passwordResetTokenRepository = passwordResetTokenRepository;
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.emailService = emailService;
+ this.userAuthCacheService = userAuthCacheService;
}
@Transactional
@@ -97,6 +101,7 @@ public class PasswordResetService {
user.setPassword(passwordEncoder.encode(newPassword));
user.setTokenVersion(user.getTokenVersion() + 1);
userRepository.save(user);
+ userAuthCacheService.evict(user.getId());
token.setUsedAt(now);
passwordResetTokenRepository.save(token);
diff --git a/backend/src/main/java/com/petshop/backend/service/UserService.java b/backend/src/main/java/com/petshop/backend/service/UserService.java
index 15a57c80..75966114 100644
--- a/backend/src/main/java/com/petshop/backend/service/UserService.java
+++ b/backend/src/main/java/com/petshop/backend/service/UserService.java
@@ -8,6 +8,7 @@ import com.petshop.backend.entity.User;
import com.petshop.backend.exception.ResourceNotFoundException;
import com.petshop.backend.repository.StoreRepository;
import com.petshop.backend.repository.UserRepository;
+import com.petshop.backend.security.UserAuthCacheService;
import com.petshop.backend.util.AuthenticationHelper;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -33,11 +34,13 @@ public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final StoreRepository storeRepository;
+ private final UserAuthCacheService userAuthCacheService;
- public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository) {
+ public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder, StoreRepository storeRepository, UserAuthCacheService userAuthCacheService) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.storeRepository = storeRepository;
+ this.userAuthCacheService = userAuthCacheService;
}
public Page getAllUsers(String query, String role, Pageable pageable) {
@@ -147,6 +150,7 @@ public class UserService {
}
user = userRepository.save(user);
+ userAuthCacheService.evict(user.getId());
return mapToResponse(user);
}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 016775af..58312155 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -18,6 +18,10 @@ spring:
username: ${SPRING_DATASOURCE_USERNAME:petshop}
password: ${SPRING_DATASOURCE_PASSWORD:petshop}
driver-class-name: com.mysql.cj.jdbc.Driver
+ hikari:
+ maximum-pool-size: 20
+ minimum-idle: 5
+ connection-timeout: 30000
sql:
init:
@@ -42,6 +46,10 @@ server:
address: ${SERVER_ADDRESS:0.0.0.0}
servlet:
context-path: /
+ compression:
+ enabled: true
+ mime-types: application/json,application/javascript,text/css,text/plain
+ min-response-size: 1024
springdoc:
api-docs: