diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..1c33407
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,108 @@
+# ๐ค Contributing to Spring E-Learning & CBT Platform
+
+Thank you for your interest in contributing to this project! We welcome contributions from the community to help improve this open-source platform.
+
+---
+
+## ๐ ๏ธ Development Setup
+
+### Prerequisites
+- **Java 21 LTS** (Eclipse Temurin recommended)
+- **Node.js 20+** and **npm**
+- **Docker & Docker Compose**
+- **PostgreSQL 16** (or run via Docker)
+- **Redis 7** (or run via Docker)
+
+### Quick Start with Docker
+The easiest way to spin up the local development environment is with Docker Compose:
+
+```bash
+# 1. Clone the repository
+git clone https://github.com/mfarim/spring-elearning-react.git
+cd spring-elearning-react
+
+# 2. Start PostgreSQL, Redis, MinIO, Backend, and Frontend
+docker compose up -d
+
+# 3. Check health status
+docker compose ps
+curl http://localhost:8080/actuator/health
+```
+
+### Running Backend Locally
+```bash
+cd backend
+
+# Ensure PostgreSQL and Redis are running
+./mvnw clean spring-boot:run
+```
+
+API documentation will be accessible at:
+- **Swagger UI**: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html)
+- **Health Check**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health)
+
+### Running Frontend Locally
+```bash
+cd frontend
+
+npm install
+npm run dev
+```
+Frontend will be accessible at [http://localhost:3000](http://localhost:3000).
+
+---
+
+## ๐ Seed Demo Data
+To populate the database with realistic sample users, classrooms, subjects, learning materials, and CBT examinations:
+
+```bash
+# Connect to PostgreSQL container and load the seed script:
+docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql
+```
+
+Default credentials:
+- **Admin**: `admin@elearning.com` / `password`
+- **Teacher**: `teacher@elearning.com` / `password`
+- **Student**: `student@elearning.com` / `password`
+
+---
+
+## ๐งช Testing & Code Quality
+
+Before opening a pull request, ensure that your code complies with the project's formatting and testing checks:
+
+### Backend Tests
+```bash
+cd backend
+./mvnw clean test
+```
+
+### Frontend Lint & Build
+```bash
+cd frontend
+npm run lint
+npm run build
+```
+
+---
+
+## ๐ Pull Request Guidelines
+
+1. **Fork** the repository and create your branch from `main`:
+ ```bash
+ git checkout -b feature/your-feature-name
+ ```
+2. **Commit** your changes with clear, descriptive commit messages:
+ ```bash
+ git commit -m "feat(auth): implement refresh token rotation"
+ ```
+3. **Push** to your fork:
+ ```bash
+ git push origin feature/your-feature-name
+ ```
+4. **Open a Pull Request** describing the problem, solution, and screenshots if UI changes are involved.
+
+---
+
+## ๐ก๏ธ Security Vulnerabilities
+If you discover a potential security vulnerability, please report it privately to the maintainers rather than opening a public issue.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0a20c7e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 mfarim
+
+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.
diff --git a/README.id.md b/README.id.md
index 601e240..63df701 100644
--- a/README.id.md
+++ b/README.id.md
@@ -178,13 +178,19 @@ cd spring-elearning-react
# Jalankan seluruh stack (PostgreSQL 16, Redis 7, Spring Boot backend, dan React frontend)
docker compose up -d
+
+# (Opsional) Muat data demo siap pakai (guru, kelas, mata pelajaran, ujian CBT)
+docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql
```
Akses layanan:
- **Portal Web Frontend**: [http://localhost:3000](http://localhost:3000)
- **Backend API Spring Boot**: [http://localhost:8080](http://localhost:8080)
+- **Actuator Health Probe**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health)
- **Dokumentasi Swagger UI Interaktif**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
+Panduan kontribusi dan standar kode open source dapat dilihat di [CONTRIBUTING.md](CONTRIBUTING.md).
+
---
### Setup Manual (Pengembangan Lokal)
diff --git a/README.md b/README.md
index cc37d5d..0c2db5a 100644
--- a/README.md
+++ b/README.md
@@ -179,13 +179,19 @@ cd spring-elearning-react
# Launch full stack (PostgreSQL 16, Redis 7, Spring Boot backend, and React frontend)
docker compose up -d
+
+# (Optional) Seed realistic demo data (teachers, classrooms, subjects, exams)
+docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql
```
Access the services:
- **Frontend Web Portal**: [http://localhost:3000](http://localhost:3000)
- **Spring Boot Backend API**: [http://localhost:8080](http://localhost:8080)
+- **Actuator Health Probe**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health)
- **Interactive Swagger UI Documentation**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html)
+For contribution guidelines and coding standards, see [CONTRIBUTING.md](CONTRIBUTING.md).
+
---
### Manual Setup (Local Development)
diff --git a/backend/pom.xml b/backend/pom.xml
index a375341..156f152 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -40,6 +40,10 @@
org.springframework.boot
spring-boot-starter-websocket
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
diff --git a/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java
index fbda12e..207b564 100644
--- a/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java
+++ b/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java
@@ -1,6 +1,7 @@
package com.elearning.common.exception;
import com.elearning.common.response.ApiResponse;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@@ -14,6 +15,7 @@
import java.util.Map;
@RestControllerAdvice
+@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@@ -46,6 +48,12 @@ public ResponseEntity> handleAccessDenied(AccessDeniedExceptio
.body(ApiResponse.error(ex.getMessage() != null ? ex.getMessage() : "Access denied"));
}
+ @ExceptionHandler(SecurityException.class)
+ public ResponseEntity> handleSecurityException(SecurityException ex) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body(ApiResponse.error(ex.getMessage()));
+ }
+
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
@@ -54,7 +62,8 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentEx
@ExceptionHandler(Exception.class)
public ResponseEntity> handleGenericException(Exception ex) {
+ log.error("Unhandled server exception: ", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
- .body(ApiResponse.error("An unexpected error occurred: " + ex.getMessage()));
+ .body(ApiResponse.error("An unexpected error occurred. Please contact the administrator."));
}
}
diff --git a/backend/src/main/java/com/elearning/common/service/FileStorageService.java b/backend/src/main/java/com/elearning/common/service/FileStorageService.java
index 0859931..17ca9a3 100644
--- a/backend/src/main/java/com/elearning/common/service/FileStorageService.java
+++ b/backend/src/main/java/com/elearning/common/service/FileStorageService.java
@@ -29,6 +29,10 @@ public FileStorageService(@Value("${app.storage.upload-dir:./storage/uploads}")
public String storeFile(MultipartFile file, String subDirectory) {
String originalFileName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
+ if (originalFileName.contains("..")) {
+ throw new IllegalArgumentException("Filename contains invalid path sequence " + originalFileName);
+ }
+
String extension = "";
int i = originalFileName.lastIndexOf('.');
if (i >= 0) {
@@ -38,17 +42,19 @@ public String storeFile(MultipartFile file, String subDirectory) {
String fileName = UUID.randomUUID() + extension;
try {
- if (originalFileName.contains("..")) {
- throw new IllegalArgumentException("Filename contains invalid path sequence " + originalFileName);
- }
-
Path targetDir = this.fileStorageLocation;
if (subDirectory != null && !subDirectory.trim().isEmpty()) {
targetDir = this.fileStorageLocation.resolve(subDirectory).normalize();
+ if (!targetDir.startsWith(this.fileStorageLocation)) {
+ throw new SecurityException("Target sub-directory is outside base upload directory: " + subDirectory);
+ }
Files.createDirectories(targetDir);
}
- Path targetLocation = targetDir.resolve(fileName);
+ Path targetLocation = targetDir.resolve(fileName).normalize();
+ if (!targetLocation.startsWith(this.fileStorageLocation)) {
+ throw new SecurityException("Target file location is outside base upload directory");
+ }
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
return (subDirectory != null && !subDirectory.trim().isEmpty() ? subDirectory + "/" : "") + fileName;
@@ -60,6 +66,9 @@ public String storeFile(MultipartFile file, String subDirectory) {
public Resource loadFileAsResource(String filePath) {
try {
Path file = this.fileStorageLocation.resolve(filePath).normalize();
+ if (!file.startsWith(this.fileStorageLocation)) {
+ throw new SecurityException("Access to path outside upload directory is forbidden: " + filePath);
+ }
Resource resource = new UrlResource(file.toUri());
if (resource.exists() && resource.isReadable()) {
return resource;
@@ -75,6 +84,9 @@ public void deleteFile(String filePath) {
if (filePath == null || filePath.trim().isEmpty()) return;
try {
Path file = this.fileStorageLocation.resolve(filePath).normalize();
+ if (!file.startsWith(this.fileStorageLocation)) {
+ throw new SecurityException("Cannot delete file outside storage directory");
+ }
Files.deleteIfExists(file);
} catch (IOException ignored) {
}
diff --git a/backend/src/main/java/com/elearning/config/SecurityConfig.java b/backend/src/main/java/com/elearning/config/SecurityConfig.java
index 1f88629..3276741 100644
--- a/backend/src/main/java/com/elearning/config/SecurityConfig.java
+++ b/backend/src/main/java/com/elearning/config/SecurityConfig.java
@@ -48,9 +48,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/v1/auth/**",
"/api/v1/files/**",
"/swagger-ui/**",
- "/swagger-ui.html",
"/api-docs/**",
"/v3/api-docs/**",
+ "/actuator/health/**",
+ "/actuator/info",
"/ws/**"
).permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
diff --git a/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java b/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java
index 2e5ce36..71bef3a 100644
--- a/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java
+++ b/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java
@@ -29,6 +29,13 @@ public ResponseEntity> login(@Valid @RequestBody Login
return ResponseEntity.ok(ApiResponse.ok(response, "Login successful"));
}
+ @PostMapping("/auth/refresh")
+ @Operation(summary = "Refresh JWT Token", description = "Obtain a fresh access token using a valid refresh token")
+ public ResponseEntity> refreshToken(@Valid @RequestBody RefreshTokenRequest request) {
+ AuthResponse response = authService.refreshToken(request);
+ return ResponseEntity.ok(ApiResponse.ok(response, "Token refreshed successfully"));
+ }
+
@PostMapping("/auth/register")
@Operation(summary = "Student Registration", description = "Register a new student account")
public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) {
diff --git a/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java b/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java
new file mode 100644
index 0000000..5516c11
--- /dev/null
+++ b/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java
@@ -0,0 +1,20 @@
+package com.elearning.modules.auth.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@Schema(description = "Refresh token request payload")
+public class RefreshTokenRequest {
+
+ @NotBlank(message = "Refresh token is required")
+ @Schema(description = "Valid JWT refresh token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
+ private String refreshToken;
+}
diff --git a/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java b/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java
index 8726e90..506b0cd 100644
--- a/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java
+++ b/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java
@@ -15,6 +15,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
@@ -62,6 +63,42 @@ public AuthResponse login(LoginRequest request) {
.build();
}
+ @Transactional(readOnly = true)
+ public AuthResponse refreshToken(RefreshTokenRequest request) {
+ String token = request.getRefreshToken();
+ if (token == null || !jwtProvider.validateToken(token)) {
+ throw new BadCredentialsException("Invalid or expired refresh token");
+ }
+
+ Long userId = jwtProvider.getUserIdFromToken(token);
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new BadCredentialsException("User associated with refresh token not found"));
+
+ if (!user.isActive()) {
+ throw new AccessDeniedException("User account is inactive");
+ }
+
+ Long impersonatorAdminId = jwtProvider.getImpersonatorAdminIdFromToken(token);
+ UserPrincipal principal = UserPrincipal.create(user, impersonatorAdminId);
+
+ String newAccessToken = jwtProvider.generateAccessToken(principal);
+ String newRefreshToken = jwtProvider.generateRefreshToken(principal);
+
+ List roles = principal.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .collect(Collectors.toList());
+
+ return AuthResponse.builder()
+ .accessToken(newAccessToken)
+ .refreshToken(newRefreshToken)
+ .userId(user.getId())
+ .name(user.getName())
+ .email(user.getEmail())
+ .roles(roles)
+ .impersonatorAdminId(impersonatorAdminId)
+ .build();
+ }
+
@Transactional
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.getEmail())) {
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
index 2eee5be..9ec254b 100644
--- a/backend/src/main/resources/application.yml
+++ b/backend/src/main/resources/application.yml
@@ -64,6 +64,17 @@ springdoc:
path: /swagger-ui.html
operationsSorter: method
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info
+ endpoint:
+ health:
+ show-details: when-authorized
+ probes:
+ enabled: true
+
logging:
level:
com.elearning: DEBUG
diff --git a/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java b/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java
new file mode 100644
index 0000000..e6f7817
--- /dev/null
+++ b/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java
@@ -0,0 +1,85 @@
+package com.elearning.common.service;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.core.io.Resource;
+import org.springframework.mock.web.MockMultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class FileStorageServiceTest {
+
+ @TempDir
+ Path tempUploadDir;
+
+ private FileStorageService fileStorageService;
+
+ @BeforeEach
+ void setUp() {
+ fileStorageService = new FileStorageService(tempUploadDir.toString());
+ }
+
+ @Test
+ void testStoreAndLoadFile_Success() throws IOException {
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "sample-material.pdf",
+ "application/pdf",
+ "dummy content for testing".getBytes()
+ );
+
+ String storedPath = fileStorageService.storeFile(file, "materials");
+ assertNotNull(storedPath);
+ assertTrue(storedPath.startsWith("materials/"));
+
+ Resource resource = fileStorageService.loadFileAsResource(storedPath);
+ assertTrue(resource.exists());
+ assertTrue(resource.isReadable());
+ }
+
+ @Test
+ void testStoreFile_PathTraversalInFilename_ThrowsException() {
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "../../etc/passwd.txt",
+ "text/plain",
+ "malicious content".getBytes()
+ );
+
+ assertThrows(IllegalArgumentException.class, () ->
+ fileStorageService.storeFile(file, "materials")
+ );
+ }
+
+ @Test
+ void testStoreFile_PathTraversalInSubDirectory_ThrowsSecurityException() {
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "normal.pdf",
+ "application/pdf",
+ "hello".getBytes()
+ );
+
+ assertThrows(SecurityException.class, () ->
+ fileStorageService.storeFile(file, "../../outside")
+ );
+ }
+
+ @Test
+ void testLoadFile_PathTraversal_ThrowsSecurityException() {
+ assertThrows(SecurityException.class, () ->
+ fileStorageService.loadFileAsResource("../../application.yml")
+ );
+ }
+
+ @Test
+ void testDeleteFile_PathTraversal_ThrowsSecurityException() {
+ assertThrows(SecurityException.class, () ->
+ fileStorageService.deleteFile("../../secret.txt")
+ );
+ }
+}
diff --git a/backend/src/test/java/com/elearning/security/JwtProviderTest.java b/backend/src/test/java/com/elearning/security/JwtProviderTest.java
new file mode 100644
index 0000000..59e3184
--- /dev/null
+++ b/backend/src/test/java/com/elearning/security/JwtProviderTest.java
@@ -0,0 +1,63 @@
+package com.elearning.security;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class JwtProviderTest {
+
+ private static final String BASE64_SECRET = "dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw==";
+ private JwtProvider jwtProvider;
+
+ @BeforeEach
+ void setUp() {
+ jwtProvider = new JwtProvider(BASE64_SECRET, 900000, 604800000);
+ }
+
+ @Test
+ void testGenerateAndValidateAccessToken() {
+ UserPrincipal principal = new UserPrincipal(
+ 1L,
+ "Admin User",
+ "admin@elearning.com",
+ "password",
+ true,
+ List.of(new SimpleGrantedAuthority("ROLE_ADMIN")),
+ null
+ );
+
+ String accessToken = jwtProvider.generateAccessToken(principal);
+ assertNotNull(accessToken);
+ assertTrue(jwtProvider.validateToken(accessToken));
+ assertEquals(1L, jwtProvider.getUserIdFromToken(accessToken));
+ assertNull(jwtProvider.getImpersonatorAdminIdFromToken(accessToken));
+ }
+
+ @Test
+ void testGenerateAndValidateRefreshToken() {
+ UserPrincipal principal = new UserPrincipal(
+ 2L,
+ "Student User",
+ "student@elearning.com",
+ "password",
+ true,
+ List.of(new SimpleGrantedAuthority("ROLE_STUDENT")),
+ 1L // impersonated by admin 1
+ );
+
+ String refreshToken = jwtProvider.generateRefreshToken(principal);
+ assertNotNull(refreshToken);
+ assertTrue(jwtProvider.validateToken(refreshToken));
+ assertEquals(2L, jwtProvider.getUserIdFromToken(refreshToken));
+ }
+
+ @Test
+ void testValidateToken_InvalidSignature_ReturnsFalse() {
+ String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.invalidSignature12345";
+ assertFalse(jwtProvider.validateToken(invalidToken));
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index ee03b40..bcd9e88 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -57,9 +57,19 @@ services:
SPRING_DATASOURCE_PASSWORD: postgres
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
- APP_JWT_SECRET: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
+ JWT_SECRET: dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw==
+ APP_JWT_SECRET: dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw==
+ STORAGE_UPLOAD_DIR: /app/storage/uploads
ports:
- "8080:8080"
+ volumes:
+ - backend_storage:/app/storage/uploads
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 30s
depends_on:
postgres:
condition: service_healthy
@@ -75,9 +85,11 @@ services:
ports:
- "80:80"
depends_on:
- - backend
+ backend:
+ condition: service_healthy
volumes:
postgres_data:
redis_data:
minio_data:
+ backend_storage:
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 26aca86..1722238 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -16,6 +16,14 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
+ location /actuator/ {
+ proxy_pass http://backend:8080/actuator/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
location /ws/ {
proxy_pass http://backend:8080/ws/;
proxy_http_version 1.1;
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 670991e..bdd0255 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -18,16 +18,100 @@ api.interceptors.request.use(
(error) => Promise.reject(error)
);
+let isRefreshing = false;
+let failedQueue: Array<{
+ resolve: (token: string) => void;
+ reject: (err: any) => void;
+}> = [];
+
+const processQueue = (error: any, token: string | null = null) => {
+ failedQueue.forEach((prom) => {
+ if (error) {
+ prom.reject(error);
+ } else {
+ prom.resolve(token!);
+ }
+ });
+ failedQueue = [];
+};
+
api.interceptors.response.use(
(response) => response,
- (error) => {
- if (error.response?.status === 401) {
- localStorage.removeItem('token');
- localStorage.removeItem('user');
- if (window.location.pathname !== '/login') {
- window.location.href = '/login';
+ async (error) => {
+ const originalRequest = error.config;
+
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ // Do not try to refresh if the 401 was from login, register, or refresh itself
+ if (
+ originalRequest.url?.includes('/auth/login') ||
+ originalRequest.url?.includes('/auth/register') ||
+ originalRequest.url?.includes('/auth/refresh')
+ ) {
+ return Promise.reject(error);
+ }
+
+ const refreshToken = localStorage.getItem('refreshToken');
+ if (!refreshToken) {
+ localStorage.removeItem('token');
+ localStorage.removeItem('refreshToken');
+ localStorage.removeItem('user');
+ if (window.location.pathname !== '/login') {
+ window.location.href = '/login';
+ }
+ return Promise.reject(error);
+ }
+
+ if (isRefreshing) {
+ return new Promise((resolve, reject) => {
+ failedQueue.push({ resolve, reject });
+ })
+ .then((newToken) => {
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
+ return api(originalRequest);
+ })
+ .catch((err) => Promise.reject(err));
+ }
+
+ originalRequest._retry = true;
+ isRefreshing = true;
+
+ try {
+ const response = await axios.post('/api/v1/auth/refresh', {
+ refreshToken,
+ });
+
+ const data = response.data?.data;
+ const newAccessToken = data?.accessToken;
+ const newRefreshToken = data?.refreshToken;
+
+ if (newAccessToken) {
+ localStorage.setItem('token', newAccessToken);
+ if (newRefreshToken) {
+ localStorage.setItem('refreshToken', newRefreshToken);
+ }
+
+ api.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
+ processQueue(null, newAccessToken);
+
+ originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
+ return api(originalRequest);
+ } else {
+ throw new Error('Refresh token response missing access token');
+ }
+ } catch (refreshErr) {
+ processQueue(refreshErr, null);
+ localStorage.removeItem('token');
+ localStorage.removeItem('refreshToken');
+ localStorage.removeItem('user');
+ if (window.location.pathname !== '/login') {
+ window.location.href = '/login';
+ }
+ return Promise.reject(refreshErr);
+ } finally {
+ isRefreshing = false;
}
}
+
return Promise.reject(error);
}
);
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
index b63559e..32b89ae 100644
--- a/frontend/src/store/authStore.ts
+++ b/frontend/src/store/authStore.ts
@@ -30,6 +30,7 @@ export const useAuthStore = create((set) => ({
set({ user, token, isAuthenticated: true, isLoading: false });
} catch {
localStorage.removeItem('token');
+ localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
set({ user: null, token: null, isAuthenticated: false, isLoading: false });
}
@@ -54,6 +55,9 @@ export const useAuthStore = create((set) => ({
};
localStorage.setItem('token', data.accessToken);
+ if (data.refreshToken) {
+ localStorage.setItem('refreshToken', data.refreshToken);
+ }
localStorage.setItem('user', JSON.stringify(user));
set({ user, token: data.accessToken, isAuthenticated: true, isLoading: false });
@@ -65,6 +69,7 @@ export const useAuthStore = create((set) => ({
logout: () => {
localStorage.removeItem('token');
+ localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
set({ user: null, token: null, isAuthenticated: false });
window.location.href = '/login';
@@ -84,6 +89,9 @@ export const useAuthStore = create((set) => ({
};
localStorage.setItem('token', data.accessToken);
+ if (data.refreshToken) {
+ localStorage.setItem('refreshToken', data.refreshToken);
+ }
localStorage.setItem('user', JSON.stringify(user));
set({ user, token: data.accessToken });
@@ -104,6 +112,9 @@ export const useAuthStore = create((set) => ({
};
localStorage.setItem('token', data.accessToken);
+ if (data.refreshToken) {
+ localStorage.setItem('refreshToken', data.refreshToken);
+ }
localStorage.setItem('user', JSON.stringify(user));
set({ user, token: data.accessToken });