Skip to content

Commit 8a949d0

Browse files
authored
Merge pull request #27 from DMU-DebugVisual/feat/s3-integration
feat: S3를 이용한 파일 업로드 기능 구현(#26)
2 parents dc68188 + eb50a09 commit 8a949d0

File tree

7 files changed

+178
-55
lines changed

7 files changed

+178
-55
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,6 @@ out/
3636

3737
### VS Code ###
3838
.vscode/
39+
40+
# Secret properties file
41+
application-secret.properties

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ dependencies {
4040
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
4141
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
4242

43-
// implementation 'software.amazon.awssdk:s3'
43+
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3:3.1.1'
4444
}
4545

4646
tasks.named('test') {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.dmu.debug_visual.config;
2+
3+
import org.springframework.beans.factory.annotation.Value;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
7+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
8+
import software.amazon.awssdk.regions.Region;
9+
import software.amazon.awssdk.services.s3.S3Client;
10+
11+
@Configuration
12+
public class S3Config {
13+
14+
@Value("${spring.cloud.aws.credentials.access-key}")
15+
private String accessKey;
16+
17+
@Value("${spring.cloud.aws.credentials.secret-key}")
18+
private String secretKey;
19+
20+
@Value("${spring.cloud.aws.region.static}")
21+
private String region;
22+
23+
@Bean
24+
public S3Client s3Client() {
25+
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
26+
27+
return S3Client.builder()
28+
.region(Region.of(region))
29+
.credentialsProvider(StaticCredentialsProvider.create(credentials))
30+
.build();
31+
}
32+
}

src/main/java/com/dmu/debug_visual/config/SecurityConfig.java

Lines changed: 51 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
import org.springframework.context.annotation.Bean;
77
import org.springframework.context.annotation.Configuration;
88
import org.springframework.context.annotation.Profile;
9-
import org.springframework.core.annotation.Order;
109
import org.springframework.http.HttpHeaders;
10+
import org.springframework.http.HttpMethod;
1111
import org.springframework.http.MediaType;
1212
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
1313
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
1414
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
15+
import org.springframework.security.config.http.SessionCreationPolicy;
1516
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
1617
import org.springframework.security.crypto.password.PasswordEncoder;
1718
import org.springframework.security.web.SecurityFilterChain;
@@ -35,85 +36,81 @@ public class SecurityConfig {
3536
@Value("${compiler.python.url}")
3637
private String compilerPythonUrl;
3738

38-
// 1. JWT 인증이 필요한 API를 위한 필터 체인 (우선순위 1)
39+
/**
40+
* "dev" 프로파일 (개발 환경)을 위한 보안 설정
41+
* ADMIN 권한 체크를 제외하여 USER 권한으로도 ADMIN API 테스트가 가능합니다.
42+
*/
3943
@Bean
40-
@Order(1)
41-
@Profile("!dev")
42-
public SecurityFilterChain jwtFilterChain(HttpSecurity http) throws Exception {
44+
@Profile("dev")
45+
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
4346
http
44-
.securityMatcher("/api/posts/**", "/api/notifications/**", "/api/admin/**", "/api/report/**", "/api/comments/**") // 이 경로들에 대해서만 이 필터 체인을 적용
4547
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
4648
.csrf(AbstractHttpConfigurer::disable)
49+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
50+
.formLogin(AbstractHttpConfigurer::disable)
51+
.httpBasic(AbstractHttpConfigurer::disable)
4752
.authorizeHttpRequests(auth -> auth
53+
// 1. 누구나 접근 가능한 경로
54+
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
55+
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
56+
.requestMatchers("/api/code/**").permitAll()
4857
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
49-
.requestMatchers("/api/admin/**").hasRole("ADMIN")
50-
.requestMatchers("/api/posts/**").hasAnyRole("USER", "ADMIN")
51-
.requestMatchers("/api/notifications/**").hasAnyRole("USER", "ADMIN")
52-
.requestMatchers("/api/report/**").hasAnyRole("USER", "ADMIN")
53-
.requestMatchers("/api/comments/**").hasAnyRole("USER", "ADMIN")
58+
59+
// 2. USER 권한이 필요한 경로
60+
.requestMatchers("/api/posts/**").hasRole("USER")
61+
.requestMatchers("/api/notifications/**").hasRole("USER")
62+
.requestMatchers("/api/report/**").hasRole("USER")
63+
.requestMatchers("/api/comments/**").hasRole("USER")
64+
.requestMatchers("/api/files/upload").hasRole("USER")
65+
66+
// 3. 나머지 모든 요청은 인증된 사용자만 접근 가능 (ADMIN 경로 포함)
5467
.anyRequest().authenticated()
5568
)
5669
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, userRepository),
57-
UsernamePasswordAuthenticationFilter.class)
58-
.formLogin(AbstractHttpConfigurer::disable)
59-
.httpBasic(AbstractHttpConfigurer::disable);
60-
70+
UsernamePasswordAuthenticationFilter.class);
6171
return http.build();
6272
}
6373

64-
// 2. JWT 인증이 필요 없는 API 및 기타 경로를 위한 필터 체인 (우선순위 2)
74+
/**
75+
* "prod", "default" 등 운영 환경을 위한 보안 설정
76+
* ADMIN 경로는 ADMIN 권한이 있는 사용자만 접근 가능합니다.
77+
*/
6578
@Bean
66-
@Order(2)
6779
@Profile("!dev")
68-
public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
80+
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
6981
http
70-
// securityMatcher를 지정하지 않으면 나머지 모든 요청을 처리합니다.
7182
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
7283
.csrf(AbstractHttpConfigurer::disable)
84+
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
85+
.formLogin(AbstractHttpConfigurer::disable)
86+
.httpBasic(AbstractHttpConfigurer::disable)
7387
.authorizeHttpRequests(auth -> auth
74-
// 로그인, 회원가입, Swagger 등 인증이 필요 없는 경로는 여기서 permitAll() 처리
75-
.requestMatchers(
76-
"/api/users/login",
77-
"/api/users/signup",
78-
"/swagger-ui/**",
79-
"/v3/api-docs/**",
80-
"/api/code/**"
81-
).permitAll()
88+
// 1. 누구나 접근 가능한 경로
89+
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
90+
.requestMatchers("/api/users/login", "/api/users/signup").permitAll()
91+
.requestMatchers("/api/code/**").permitAll()
8292
.requestMatchers(HttpMethod.GET, "/api/posts/**", "/api/comments/**").permitAll()
83-
.anyRequest().permitAll()
84-
);
8593

86-
return http.build();
87-
}
94+
// 2. ADMIN 권한이 필요한 경로
95+
.requestMatchers("/api/admin/**").hasRole("ADMIN")
8896

89-
// "dev" 프로파일에서 사용할 보안 설정
90-
@Bean
91-
@Profile("dev")
92-
public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception {
93-
http
94-
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
95-
.csrf(AbstractHttpConfigurer::disable)
96-
.authorizeHttpRequests(auth -> auth
97-
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS preflight 허용
98-
// dev 환경에서 인증 없이 접근을 허용할 경로
99-
.requestMatchers(
100-
"/api/admin/**",
101-
"/api/users/login",
102-
"/api/users/signup",
103-
"/swagger-ui/**",
104-
"/v3/api-docs/**",
105-
"/api/code/**"
106-
).permitAll()
107-
// 그 외 모든 요청은 인증을 요구하도록 설정 (!dev 환경과 유사하게)
97+
// 3. USER 권한이 필요한 경로
98+
.requestMatchers("/api/posts/**").hasRole("USER")
99+
.requestMatchers("/api/notifications/**").hasRole("USER")
100+
.requestMatchers("/api/report/**").hasRole("USER")
101+
.requestMatchers("/api/comments/**").hasRole("USER")
102+
.requestMatchers("/api/files/upload").hasRole("USER")
103+
104+
105+
// 4. 나머지 모든 요청은 인증된 사용자만 접근 가능
108106
.anyRequest().authenticated()
109107
)
110108
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, userRepository),
111-
UsernamePasswordAuthenticationFilter.class)
112-
.formLogin(AbstractHttpConfigurer::disable)
113-
.httpBasic(AbstractHttpConfigurer::disable);
109+
UsernamePasswordAuthenticationFilter.class);
114110
return http.build();
115111
}
116112

113+
// --- 공통 Bean 설정 ---
117114
@Bean
118115
public PasswordEncoder passwordEncoder() {
119116
return new BCryptPasswordEncoder();
@@ -139,4 +136,4 @@ public CorsConfigurationSource corsConfigurationSource() {
139136
source.registerCorsConfiguration("/**", config);
140137
return source;
141138
}
142-
}
139+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.dmu.debug_visual.file_upload;
2+
3+
import com.dmu.debug_visual.security.CustomUserDetails;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.http.HttpStatus;
7+
import org.springframework.http.MediaType;
8+
import org.springframework.http.ResponseEntity;
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
10+
import org.springframework.web.bind.annotation.PostMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RequestParam;
13+
import org.springframework.web.bind.annotation.RestController;
14+
import org.springframework.web.multipart.MultipartFile;
15+
16+
import java.io.IOException;
17+
18+
@RestController
19+
@RequiredArgsConstructor
20+
@RequestMapping("/api/files") // 공통되는 URL 경로 설정
21+
public class FileController {
22+
23+
private final S3Uploader s3Uploader;
24+
25+
@Operation(summary = "파일 업로드", description = "form-data 형식으로 파일을 업로드합니다.")
26+
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
27+
public ResponseEntity<String> uploadFile(
28+
@RequestParam("file") MultipartFile file,
29+
@AuthenticationPrincipal CustomUserDetails userDetails) { // <-- String 대신 CustomUserDetails로 변경
30+
31+
// userDetails 객체에서 userId를 직접 꺼내서 사용합니다.
32+
String userId = userDetails.getUsername(); // 또는 userDetails.getUser().getUserId()
33+
34+
try {
35+
String fileUrl = s3Uploader.upload(file, userId + "-codes");
36+
return ResponseEntity.ok(fileUrl);
37+
} catch (IOException e) {
38+
e.printStackTrace();
39+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("파일 업로드에 실패했습니다.");
40+
}
41+
}
42+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.dmu.debug_visual.file_upload;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.beans.factory.annotation.Value;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.web.multipart.MultipartFile;
7+
import software.amazon.awssdk.core.sync.RequestBody;
8+
import software.amazon.awssdk.services.s3.S3Client;
9+
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
10+
11+
import java.io.IOException;
12+
import java.util.UUID;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
public class S3Uploader {
17+
18+
private final S3Client s3Client;
19+
20+
@Value("${spring.cloud.aws.s3.bucket}")
21+
private String bucket;
22+
23+
public String upload(MultipartFile file, String dirName) throws IOException {
24+
// 1. 고유한 파일 이름 생성
25+
String originalFilename = file.getOriginalFilename();
26+
String uniqueFileName = dirName + "/" + UUID.randomUUID().toString() + "_" + originalFilename;
27+
28+
// 2. S3에 업로드할 요청 객체 생성
29+
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
30+
.bucket(bucket)
31+
.key(uniqueFileName)
32+
.contentType(file.getContentType())
33+
.contentLength(file.getSize())
34+
.build();
35+
36+
// 3. 파일의 InputStream을 RequestBody로 만들어 S3에 업로드
37+
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
38+
39+
// 4. 업로드된 파일의 URL 반환
40+
return s3Client.utilities().getUrl(builder -> builder.bucket(bucket).key(uniqueFileName)).toString();
41+
}
42+
}

src/main/resources/application.properties

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,10 @@ jwt.secret=bnTweUtRuSmcelFon7OFd2Px/ZaVWgEhMpAzyl/+LnEQLG8bKe+F5nA4UJTWwT0iM627y
3535
jwt.expiration=300000
3636

3737
compiler.python.url=http://flask-server:5050/run
38+
39+
# AWS S3-related Properties
40+
spring.cloud.aws.region.static=ap-northeast-2
41+
spring.cloud.aws.s3.bucket=zivorp-storage
42+
43+
# Include secret properties
44+
spring.profiles.include=secret

0 commit comments

Comments
 (0)