Skip to content
Open
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
20 changes: 20 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
FROM eclipse-temurin:17-jdk-alpine AS build
WORKDIR /app

COPY pom.xml .
COPY src ./src

RUN apk add --no-cache maven && \
mvn clean package -DskipTests

FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

COPY --from=build /app/target/*.jar app.jar

EXPOSE 3000

ENV JAVA_OPTS="-Xmx512m -Xms256m"
ENV SERVER_PORT=3000

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
114 changes: 114 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>

<groupId>com.realworld</groupId>
<artifactId>realworld-api</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>realworld-api</name>
<description>RealWorld API - Spring Boot Backend</description>

<properties>
<java.version>17</java.version>
<jjwt.version>0.12.3</jjwt.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>

<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
12 changes: 12 additions & 0 deletions backend/src/main/java/com/realworld/RealWorldApplication.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.realworld;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RealWorldApplication {

public static void main(String[] args) {
SpringApplication.run(RealWorldApplication.class, args);
}
}
88 changes: 88 additions & 0 deletions backend/src/main/java/com/realworld/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.realworld.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.realworld.security.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;
import java.util.List;
import java.util.Map;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final ObjectMapper objectMapper;

public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, ObjectMapper objectMapper) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.objectMapper = objectMapper;
}

@Bean
public AuthenticationEntryPoint authenticationEntryPoint() {
return (request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, String> errorResponse = Map.of(
"status", "error",
"message", "missing authorization credentials"
);
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
};
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(ex -> ex.authenticationEntryPoint(authenticationEntryPoint()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users", "/api/users/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**", "/api/profiles/**", "/api/tags").permitAll()
.anyRequest().authenticated()
)
.headers(headers -> headers.frameOptions(frame -> frame.disable()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("Authorization"));

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10);
}
}
121 changes: 121 additions & 0 deletions backend/src/main/java/com/realworld/controller/ArticleController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.realworld.controller;

import com.realworld.dto.*;
import com.realworld.entity.User;
import com.realworld.service.ArticleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api")
public class ArticleController {

private final ArticleService articleService;

public ArticleController(ArticleService articleService) {
this.articleService = articleService;
}

@GetMapping("/articles")
public ResponseEntity<ArticlesResponse> getArticles(
@RequestParam(required = false) String tag,
@RequestParam(required = false) String author,
@RequestParam(required = false) String favorited,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "10") int limit,
@AuthenticationPrincipal User currentUser) {
Long userId = currentUser != null ? currentUser.getId() : null;
ArticlesResponse response = articleService.getArticles(tag, author, favorited, offset, limit, userId);
return ResponseEntity.ok(response);
}

@GetMapping("/articles/feed")
public ResponseEntity<ArticlesResponse> getFeed(
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "10") int limit,
@AuthenticationPrincipal User currentUser) {
ArticlesResponse response = articleService.getFeed(offset, limit, currentUser.getId());
return ResponseEntity.ok(response);
}

@PostMapping("/articles")
public ResponseEntity<ArticleResponse> createArticle(
@RequestBody CreateArticleRequest request,
@AuthenticationPrincipal User currentUser) {
ArticleDTO article = articleService.createArticle(request.getArticle(), currentUser.getId());
return new ResponseEntity<>(ArticleResponse.builder().article(article).build(), HttpStatus.CREATED);
}

@GetMapping("/articles/{slug}")
public ResponseEntity<ArticleResponse> getArticle(
@PathVariable String slug,
@AuthenticationPrincipal User currentUser) {
Long userId = currentUser != null ? currentUser.getId() : null;
ArticleDTO article = articleService.getArticle(slug, userId);
return ResponseEntity.ok(ArticleResponse.builder().article(article).build());
}

@PutMapping("/articles/{slug}")
public ResponseEntity<ArticleResponse> updateArticle(
@PathVariable String slug,
@RequestBody UpdateArticleRequest request,
@AuthenticationPrincipal User currentUser) {
ArticleDTO article = articleService.updateArticle(request.getArticle(), slug, currentUser.getId());
return ResponseEntity.ok(ArticleResponse.builder().article(article).build());
}

@DeleteMapping("/articles/{slug}")
public ResponseEntity<Void> deleteArticle(
@PathVariable String slug,
@AuthenticationPrincipal User currentUser) {
articleService.deleteArticle(slug, currentUser.getId());
return ResponseEntity.noContent().build();
}

@GetMapping("/articles/{slug}/comments")
public ResponseEntity<CommentsResponse> getComments(
@PathVariable String slug,
@AuthenticationPrincipal User currentUser) {
Long userId = currentUser != null ? currentUser.getId() : null;
List<CommentDTO> comments = articleService.getCommentsByArticle(slug, userId);
return ResponseEntity.ok(CommentsResponse.builder().comments(comments).build());
}

@PostMapping("/articles/{slug}/comments")
public ResponseEntity<CommentResponse> addComment(
@PathVariable String slug,
@RequestBody CreateCommentRequest request,
@AuthenticationPrincipal User currentUser) {
CommentDTO comment = articleService.addComment(request.getComment().getBody(), slug, currentUser.getId());
return ResponseEntity.ok(CommentResponse.builder().comment(comment).build());
}

@DeleteMapping("/articles/{slug}/comments/{id}")
public ResponseEntity<Object> deleteComment(
@PathVariable String slug,
@PathVariable Long id,
@AuthenticationPrincipal User currentUser) {
articleService.deleteComment(id, currentUser.getId());
return ResponseEntity.ok().body(java.util.Collections.emptyMap());
}

@PostMapping("/articles/{slug}/favorite")
public ResponseEntity<ArticleResponse> favoriteArticle(
@PathVariable String slug,
@AuthenticationPrincipal User currentUser) {
ArticleDTO article = articleService.favoriteArticle(slug, currentUser.getId());
return ResponseEntity.ok(ArticleResponse.builder().article(article).build());
}

@DeleteMapping("/articles/{slug}/favorite")
public ResponseEntity<ArticleResponse> unfavoriteArticle(
@PathVariable String slug,
@AuthenticationPrincipal User currentUser) {
ArticleDTO article = articleService.unfavoriteArticle(slug, currentUser.getId());
return ResponseEntity.ok(ArticleResponse.builder().article(article).build());
}
}
Loading