Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions README.id.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<!-- Persistence & Cache -->
<dependency>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +15,7 @@
import java.util.Map;

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
Expand Down Expand Up @@ -46,6 +48,12 @@ public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedExceptio
.body(ApiResponse.error(ex.getMessage() != null ? ex.getMessage() : "Access denied"));
}

@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<Void>> handleSecurityException(SecurityException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(ex.getMessage()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
Expand All @@ -54,7 +62,8 @@ public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentEx

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> 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."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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) {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ public ResponseEntity<ApiResponse<AuthResponse>> 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<ApiResponse<AuthResponse>> 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<ApiResponse<AuthResponse>> register(@Valid @RequestBody RegisterRequest request) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> 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())) {
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading