Skip to content

Commit 70ed14d

Browse files
authored
Merge pull request #154 from prgrms-aibe-devcourse/feature/pr
feat: S3 연결
2 parents a40b28b + ec47c25 commit 70ed14d

15 files changed

Lines changed: 249 additions & 104 deletions

File tree

.dockerignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
build/
2+
.gradle/
3+
.idea/
4+
*.iml
5+
out/
6+
src/test/
7+
.env
8+
.docs/

.github/workflows/cd.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,16 @@ jobs:
4747
push: true
4848
tags: |
4949
${{ secrets.DOCKERHUB_USERNAME }}/rootin-be:latest
50-
${{ secrets.DOCKERHUB_USERNAME }}/rootin-be:${{ github.sha }}
50+
${{ secrets.DOCKERHUB_USERNAME }}/rootin-be:${{ github.sha }}
51+
52+
- name: Deploy to EC2
53+
uses: appleboy/ssh-action@v1.0.3
54+
with:
55+
host: ${{ secrets.EC2_HOST }}
56+
username: ubuntu
57+
key: ${{ secrets.EC2_SSH_KEY }}
58+
script: |
59+
cd ~/rootin
60+
docker compose -f docker-compose.yml pull be
61+
docker compose -f docker-compose.yml up -d --no-deps be
62+
docker image prune -f

src/main/java/com/Rootin/domain/garden/service/PotService.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,14 @@ public void deletePot(Long potId, Long userId) {
262262
// 4. 화분 데이터 삭제
263263
potRepository.delete(pot);
264264
}
265+
266+
// S3에서 사용하기 위한 Validation
267+
public void validateOwnership(Long userId, Long potId) {
268+
Pot pot = potRepository.findById(potId)
269+
.orElseThrow(() -> CustomException.notFound("존재하지 않는 화분입니다. ID: " + potId));
270+
271+
if (!pot.getUserId().equals(userId)) {
272+
throw CustomException.forbidden("해당 화분에 접근할 권한이 없습니다.");
273+
}
274+
}
265275
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
package com.Rootin.domain.s3.controller;
2+
3+
import com.Rootin.domain.garden.service.PotService;
4+
import com.Rootin.global.common.ApiResponse;
5+
import com.Rootin.global.exception.CustomException;
6+
import com.Rootin.global.jwt.JwtUserDetails;
7+
import com.Rootin.domain.s3.dto.PresignedUrlRequest;
8+
import com.Rootin.domain.s3.dto.PresignedUrlResponse;
9+
import com.Rootin.global.s3.S3Service;
10+
import jakarta.validation.Valid;
11+
import lombok.RequiredArgsConstructor;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.web.bind.annotation.PostMapping;
15+
import org.springframework.web.bind.annotation.RequestBody;
16+
import org.springframework.web.bind.annotation.RestController;
17+
18+
import java.util.UUID;
19+
20+
@RestController
21+
@RequiredArgsConstructor
22+
public class PresignedUrlController {
23+
24+
private final S3Service s3Service;
25+
private final PotService potService;
26+
27+
@PostMapping("/tils/image/presigned-url")
28+
public ResponseEntity<ApiResponse<PresignedUrlResponse>> getPresignedUrl(
29+
@AuthenticationPrincipal JwtUserDetails userDetails,
30+
@Valid @RequestBody PresignedUrlRequest request
31+
) {
32+
Long userId = userDetails.getUserId();
33+
Long potId = request.getPotId();
34+
35+
// DB에서 이 화분이 이 사용자의 것인지 검증하는 절차
36+
potService.validateOwnership(userId, potId);
37+
38+
// TIL 본문의 이미지 저장Key
39+
String ext = extractExtension(request.getContentType());
40+
String objectKey = String.format("til-images/%d/%d/%s.%s",userId, potId, UUID.randomUUID(), ext);
41+
42+
// S3에 접근하도록 임시 URL 제공 및 이미지 URL
43+
String presignedUrl = s3Service.generatePresignedPutUrl(objectKey, request.getContentType());
44+
String imageUrl = s3Service.getFileUrl(objectKey);
45+
46+
return ResponseEntity.ok(ApiResponse.success(
47+
new PresignedUrlResponse(imageUrl, presignedUrl)
48+
));
49+
}
50+
51+
private String extractExtension(String contentType) {
52+
if (contentType == null) {
53+
throw CustomException.badRequest("contentType이 없습니다.");
54+
}
55+
56+
return switch (contentType) {
57+
case "image/png" -> "png";
58+
case "image/jpeg" -> "jpg";
59+
case "image/webp" -> "webp";
60+
default -> throw CustomException.badRequest("지원하지 않는 이미지 형식입니다: " + contentType);
61+
};
62+
}
63+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.Rootin.domain.s3.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.NotNull;
5+
import lombok.*;
6+
7+
@Getter
8+
@Setter
9+
public class PresignedUrlRequest {
10+
@NotBlank(message = "contentType은 필수입니다.")
11+
private String contentType;
12+
13+
@NotNull(message = "화분 ID는 필수입니다.")
14+
private Long potId;
15+
16+
private String fileName;
17+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.Rootin.domain.s3.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class PresignedUrlResponse {
9+
private String imageUrl; // 업로드 후 TIL에 넣을 URL
10+
private String presignedUrl; //
11+
}

src/main/java/com/Rootin/global/config/S3Config.java

Lines changed: 0 additions & 54 deletions
This file was deleted.

src/main/java/com/Rootin/global/config/SecurityConfig.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import jakarta.servlet.http.HttpServletResponse;
66
import lombok.RequiredArgsConstructor;
77
import org.springframework.beans.factory.ObjectProvider;
8+
import org.springframework.beans.factory.annotation.Value;
89
import org.springframework.context.annotation.Bean;
910
import org.springframework.context.annotation.Configuration;
1011
import org.springframework.security.authentication.AuthenticationManager;
@@ -21,6 +22,7 @@
2122
import org.springframework.web.cors.CorsConfigurationSource;
2223
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
2324

25+
import java.util.Arrays;
2426
import java.util.List;
2527
import java.util.Map;
2628

@@ -35,6 +37,9 @@
3537
public class SecurityConfig {
3638
private final ObjectProvider<JwtAuthenticationFilter> jwtAuthenticationFilterProvider;
3739

40+
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:5173}")
41+
private String[] allowedOrigins;
42+
3843
@Bean
3944
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
4045
http
@@ -130,14 +135,11 @@ public AuthenticationManager authenticationManager(
130135
return authenticationConfiguration.getAuthenticationManager();
131136
}
132137

133-
// CORS — 프론트엔드 개발 서버 허용
138+
// CORS — CORS_ALLOWED_ORIGINS 환경변수로 허용 origin 관리 (쉼표 구분)
134139
@Bean
135140
public CorsConfigurationSource corsConfigurationSource() {
136141
CorsConfiguration configuration = new CorsConfiguration();
137-
configuration.setAllowedOrigins(List.of(
138-
"http://localhost:3000", // React 기본 포트
139-
"http://localhost:5173" // Vite 기본 포트
140-
));
142+
configuration.setAllowedOrigins(Arrays.asList(allowedOrigins));
141143
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
142144
configuration.setAllowedHeaders(List.of("*"));
143145
configuration.setAllowCredentials(true);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.Rootin.global.s3;
2+
3+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
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.DefaultCredentialsProvider;
8+
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
9+
import software.amazon.awssdk.regions.Region;
10+
import software.amazon.awssdk.services.s3.S3Configuration;
11+
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
12+
13+
import java.net.URI;
14+
15+
@Configuration
16+
@EnableConfigurationProperties(S3Properties.class)
17+
public class S3Config {
18+
19+
@Bean
20+
public S3Presigner s3Presigner(S3Properties props) {
21+
String region = props.getRegion();
22+
String accessKey = props.getCredentials().getAccessKey();
23+
String secretKey = props.getCredentials().getSecretKey();
24+
String endpoint = props.getS3().getEndpoint();
25+
26+
S3Presigner.Builder builder = S3Presigner.builder()
27+
.region(Region.of(region));
28+
29+
// 명시적 자격증명이 있으면 사용, 없으면 EC2 IAM 역할(DefaultCredentialsProvider) 사용
30+
if (accessKey != null && !accessKey.isBlank()
31+
&& secretKey != null && !secretKey.isBlank()) {
32+
builder.credentialsProvider(
33+
StaticCredentialsProvider.create(
34+
AwsBasicCredentials.create(accessKey, secretKey)
35+
)
36+
);
37+
} else {
38+
builder.credentialsProvider(DefaultCredentialsProvider.create());
39+
}
40+
41+
if (endpoint != null && !endpoint.isBlank()) {
42+
builder.endpointOverride(URI.create(endpoint))
43+
.serviceConfiguration(S3Configuration.builder()
44+
.pathStyleAccessEnabled(true)
45+
.build());
46+
}
47+
48+
return builder.build();
49+
}
50+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.Rootin.global.s3;
2+
3+
import lombok.Getter;
4+
import lombok.Setter;
5+
import org.springframework.boot.context.properties.ConfigurationProperties;
6+
7+
// ───────── Application의 Cloud.aws 설정값 ───────────────────────────
8+
// 설정(리전, 버킷, 키, 엔드포인트)을 담는 그릇
9+
// 비어있는 칸은 S3 Config에서 IAM Role을 사용
10+
11+
@ConfigurationProperties(prefix = "cloud.aws")
12+
@Getter
13+
@Setter
14+
public class S3Properties {
15+
16+
private String region = "ap-northeast-2";
17+
private Credentials credentials = new Credentials();
18+
private S3 s3 = new S3();
19+
20+
@Getter
21+
@Setter
22+
public static class Credentials {
23+
private String accessKey = "";
24+
private String secretKey = "";
25+
}
26+
27+
@Getter
28+
@Setter
29+
public static class S3 {
30+
private String bucket;
31+
private String endpoint = "";
32+
}
33+
}

0 commit comments

Comments
 (0)