Skip to content

Commit

Permalink
[feat/CK-239]� Amazon S3 접근 시 credential 정보를 이용하도록 수정한다 (#200)
Browse files Browse the repository at this point in the history
* [feat/CK-179] 로드맵 카테고리 생성을 구현한다 (#141)

* feat: 로드맵 카테고리 생성 인수테스트 작성

* feat: 로드맵 카테고리 생성 서비스 구현

* feat: 로드맵 카테고리 생성 api 구현

* docs: 로드맵 카테고리 생성 docs 작성

* refactor: 인수테스트 카테고리 생성 api로 수정

* feat: 로드맵 카테고리 앞뒤 공백 제거하도록 수정

* [feat/CK-190] 골룸 생성 시 투두리스트를 필수로 입력받는 로직을 제거한다 (#145)

* feat: 골룸 생성 시 투두리스트 필수 입력 로직 제거

* refactor: 사용하지 않는 메서드 제거

* [feat/CK-189] 네이버 OAuth 로그인 기능을 구현한다 (#144)

* feat: 회원 전화번호, 생년월일 삭제, 이메일 추가

* feat: naver oauth 구현 및 member field 변경

* feat: 네이버 로그인 uri 변경

* docs: 네이버 로그인 API 명세 작성

* feat: NaverOauthService 테스트코드 작성

* test: oauth 기능 테스트 코드 추가

* test: NaverOauthNetworkService 테스트 수정

* chore: git submodule 업데이트

---------

Co-authored-by: ChoiYoungHoon <[email protected]>
Co-authored-by: Ohjintaek <[email protected]>

* feat: MemberProfile email not null 및 Member salt nullable 설정

* feat: oauth 회원가입 시 아이디에 uuid 붙히도록 수정

* design: 네이버 로그인 버튼 구현

* feat: naver oauth API 통신 구현

* feat: 네이버 로그인 주소 반환 시 응답 코드 200으로 변경

* feat: 네이버 oauth 로그인 시 콜백 변경 및 auth code 받도록 수정

* feat: 네이버 oauth api 수정

* fix: 시작 날짜가 오늘 이전이면서 모집 중인 골룸도 자동으로 시작하도록 수정

* feat: 이미지 파일 get url 요청 시 cloud front로 내려주도록 수정

* fix: 로드맵 태그 여러개일시 데이터 조회 중복 문제 해결

* fix: 파일 경로 생성시 한글과 공백은 인코딩한다

* test: test coverage 수정

* fix: file 확장자 오류를 해결한다

* fix: file이름을 저장할때 uuid로만 저장한다

* feat: 로드맵 태그에 BatchSize 적용

* feat: 골룸 노드 목록 조회 시 노드 설명과 이미지 반환하도록 수정

* [feat/CK-227] 로드맵 골룸 조회 시 최신순일때는 모든 상태를, 마감임박 순일 땐 모집 중인 상태만 반환한다 (#185)

* chore: 패키지 경로 수정

* feat: 조건에 따른 로드맵 골룸 조회 시 쿼리 수정

* refactor: 1:N 문제 방지를 위해 @batchsize 설정

* feat: 로드맵 골룸 응답에 골룸 상태 추가

* chore: 서브모듈 업데이트

* chore: 패키지 경로 수정

* refactor: 메서드 네이밍 수정

* refactor: BaseEntity의 CreatedAt 스프링 의존성 끊기 (#191)

* refactor: MemberIdentifierArgumentResolver에서 Authenticated 어노테이션 확인하도록 수정 (#193)

* [feat/CK-232] 전역적으로 사용하는 Exception을 분리하고 AOP로 예외를 변환하는 기능을 구현한다. (#194)

* refactor: exception 패키지를 service 패키지 하위로 이동

* feat: 도메인 Exception을 서비스 Exception으로 변환해주는 AOP 구현

* [feat/CK-222] Redis를 도입하고 Refresh Token을 Redis에 저장하도록 변경한다 (#190)

* build: spring data redis 및 testcontainers 의존성 추가

* feat: RefreshToken 레디스에 저장하도록 변경

* test: Redis 테스트 설정 및 RefreshTokenRedisRepository 테스트

* chore: 서브모듈 업데이트

* refactor: JwtTokenProvider에서 RefreshToken 생성해서 반환하도록 수정

* refactor: 리뷰 반영

* refactor: RedisTemplate으로 변경

* test: JwtTokenProvider 테스트 추가

* test: test config에 redis 설정 추가

* test: test 추가

* chore: 기존 refresh token table을 drop하는 쿼리 추가

* refactor: ttl을 초단위로 변경하는 로직 변수 추출

* refactor: 변수명 변경

* refactor: 레디스 테스트 간 격리

* chore: git submodule 업데이트

* [feat/CK-235] Redis 캐시를 적용한다 (#197)

* test: test container에 의존하지 않도록 변경

* feat: redis cache 적용

* feat: redis config 설정 수정

* fix: RedisConfig를 test에서 비활성화 시킨다

* test: CacheKeyGenerator test code 작성

* feat: 수정 요구사항 반영

* chore: 서브모듈 최신화

* chore: 서브모듈 최신화

* chore: flyway v4 파일 제거

* chore: flyway v4 파일 복구

* [feat/CK-237] 골룸 참여 시 발생하는 동시성 이슈를 해결한다 (#199)

* refactor: 골룸 참여 시 발생하는 동시성 이슈를 비관적 락으로 해결

* test: 테스트 코드 수정

* [feat/CK-236] JdbcTemplate을 이용하여 bulk insert를 적용한다 (#198)

* refactor: 기존 saveAll, deleteAll을 bulk insert로 개선

* refactor: Dao 대신 Repository 계층에 의존하도록 수정 - JdbcRepository 추상화

* feat: amazon s3 접근 시 credentials 사용하도록 수정

---------

Co-authored-by: Miseong Kim <[email protected]>
Co-authored-by: Ohjintaek <[email protected]>
Co-authored-by: Miseong Kim <[email protected]>
Co-authored-by: Jungwoo <[email protected]>
Co-authored-by: 부엉이 <[email protected]>
Co-authored-by: Ohjintaek <[email protected]>
  • Loading branch information
7 people authored Dec 3, 2023
1 parent f794212 commit 3f5cbe3
Show file tree
Hide file tree
Showing 245 changed files with 3,100 additions and 1,601 deletions.
9 changes: 7 additions & 2 deletions backend/kirikiri/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ jacocoTestReport {
"**/*Request*",
"**/*Response*",
"**/*Interceptor*",
"**/*Exception*"
"**/*Exception*",
"**/*Mapper*"
] + QDomains)
})
)
Expand Down Expand Up @@ -122,7 +123,8 @@ jacocoTestCoverageVerification {
"**.*Request*",
"**.*Response*",
"**.*Interceptor*",
"**.*Exception*"
"**.*Exception*",
"**.*Mapper*"
] + QDomains
}
}
Expand Down Expand Up @@ -168,6 +170,9 @@ dependencies {
// flyway
implementation 'org.flywaydb:flyway-mysql'

// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// test
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
11 changes: 11 additions & 0 deletions backend/kirikiri/src/docs/asciidoc/auth.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,14 @@ operation::auth-create-api-test/토큰_재발행_시_토큰이_만료_됐을_때
=== *2-4* 실패 - 토큰 재발행 시 토큰이 유효하지 않을 때

operation::auth-create-api-test/토큰_재발행_시_토큰이_유효하지_않을_때_예외를_던진다[snippets='http-request,http-response']

[[네이버OAuth로그인-API]]
== *3. 네이버 OAuth 로그인*

=== *3-1* 성공 - 네이버 로그인 페이지 리다이렉트를 성공한다

operation::auth-create-api-test/네이버_로그인_페이지를_정상적으로_반환한다[snippets='http-request,http-response,response-fields']

=== *3-2* 성공 - 네이버 사용자 정보 요청을 성공한다

operation::auth-create-api-test/네이버에서_콜백요청을_받아_사용자_정보를_반환한다[snippets='http-request,http-response']
16 changes: 4 additions & 12 deletions backend/kirikiri/src/docs/asciidoc/goalroom.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -45,27 +45,19 @@ operation::goal-room-create-api-test/골룸_생성_시_삭제된_로드맵_경

operation::goal-room-create-api-test/골룸_생성_시_존재하지_않는_회원일_경우[snippets='http-request,http-response']

=== *1-8* 실패 - 골룸 생성 시 골룸 투두의 시작 날짜가 오늘보다 전일 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_투두의_시작_날짜가_오늘보다_전일_경우[snippets='http-request,http-response']

=== *1-9* 실패 - 골룸 생성 시 골룸 투두의 시작 날짜보다 종료 날짜가 빠른 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_투두의_시작_날짜보다_종료_날짜가_빠른_경우[snippets='http-request,http-response']

=== *1-10* 실패 - 골룸 생성 시 골룸 노드의 시작 날짜가 오늘보다 전일 경우
=== *1-9* 실패 - 골룸 생성 시 골룸 노드의 시작 날짜가 오늘보다 전일 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_노드의_시작_날짜가_오늘보다_전일_경우[snippets='http-request,http-response']

=== *1-11* 실패 - 골룸 생성 시 골룸 노드의 시작 날짜보다 종료 날짜가 빠른 경우
=== *1-10* 실패 - 골룸 생성 시 골룸 노드의 시작 날짜보다 종료 날짜가 빠른 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_노드의_시작_날짜보다_종료_날짜가_빠른_경우[snippets='http-request,http-response']

=== *1-12* 실패 - 골룸 생성 시 골룸 노드의 인증 횟수가 0보다 작을 경우
=== *1-11* 실패 - 골룸 생성 시 골룸 노드의 인증 횟수가 0보다 작을 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_노드의_인증_횟수가_0보다_작을_경우[snippets='http-request,http-response']

=== *1-13* 실패 - 골룸 생성 시 골룸 노드의 인증 횟수가 기간보다 클 경우
=== *1-12* 실패 - 골룸 생성 시 골룸 노드의 인증 횟수가 기간보다 클 경우

operation::goal-room-create-api-test/골룸_생성_시_골룸_노드의_인증_횟수가_기간보다_클_경우[snippets='http-request,http-response']

Expand Down
22 changes: 7 additions & 15 deletions backend/kirikiri/src/docs/asciidoc/member.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -32,37 +32,29 @@ operation::member-create-api-test/회원가입_시_비밀번호가_형식에_맞

operation::member-create-api-test/회원가입_시_닉네임이_형식에_맞지않을때[snippets='http-request,http-response']

=== *1-5* 실패 - 전화번호 형식에 맞지 않을 때

operation::member-create-api-test/회원가입_시_전화번호_형식에_맞지않을때[snippets='http-request,http-response']

=== *1-6* 실패 - 중복된 아이디일 때
=== *1-5* 실패 - 중복된 아이디일 때

operation::member-create-api-test/회원가입_시_중복된_닉네임일_때[snippets='http-request,http-response']

=== *1-7* 실패 - 중복된 닉네임일 때
=== *1-6* 실패 - 중복된 닉네임일 때

operation::member-create-api-test/회원가입_시_중복된_아이디일_때[snippets='http-request,http-response']

=== *1-8* 실패 - 회원가입 시 아이디에 빈값이 들어올 때
=== *1-7* 실패 - 회원가입 시 아이디에 빈값이 들어올 때

operation::member-create-api-test/회원가입_시_아이디에_빈값이_들어올_때[snippets='http-request,http-response']

=== *1-9* 실패 - 회원가입 시 비밀번호에 빈값이 들어올 때
=== *1-8* 실패 - 회원가입 시 비밀번호에 빈값이 들어올 때

operation::member-create-api-test/회원가입_시_비밀번호에_빈값이_들어올_때[snippets='http-request,http-response']

=== *1-10* 실패 - 회원가입 할 때 닉네임에 빈값이 들어올 때
=== *1-9* 실패 - 회원가입 할 때 닉네임에 빈값이 들어올 때

operation::member-create-api-test/회원가입_시_닉네임에_빈값이_들어올_때[snippets='http-request,http-response']

=== *1-11* 실패 - 회원가입 시 전화번호에 빈값이 들어올 때

operation::member-create-api-test/회원가입_시_전화번호에_빈값이_들어올_때[snippets='http-request,http-response']

=== *1-12* 실패 - 회원가입 시 아이디 비밀번호 닉네임 전화번호 필드에 빈값이 들어올 때
=== *1-10* 실패 - 회원가입_시_아이디_비밀번호_닉네임_이메일_필드에_빈값이_들어올_때

operation::member-create-api-test/회원가입_시_아이디_비밀번호_닉네임_전화번호_필드에_빈값이_들어올_때[snippets='http-request,http-response']
operation::member-create-api-test/회원가입_시_아이디_비밀번호_닉네임_이메일_필드에_빈값이_들어올_때[snippets='http-request,http-response']

[[사용자정보조회-API]]
== *2. 사용자 정보 조회*
Expand Down
20 changes: 0 additions & 20 deletions backend/kirikiri/src/docs/asciidoc/overview.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,6 @@

|===

[[overview-page-response]]
=== *Page Response Data*

|===
| Field Name | Type | Description

| currentPage
| int
| 현재 페이지 값

| totalPage
| int
| 총 페이지 값

| data
| Object
| 데이터 리스트

|===

[[overview-error-response]]
=== *Error Response Data*

Expand Down
19 changes: 19 additions & 0 deletions backend/kirikiri/src/docs/asciidoc/roadmap.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,22 @@ operation::roadmap-create-api-test/로드맵_삭제시_존재하지_않는_로
=== *10-3* 실패 - 자신이 생성한 로드맵이 아닌 경우

operation::roadmap-create-api-test/로드맵_삭제시_자신이_생성한_로드맵이_아닌_경우_예외가_발생한다[snippets='http-request,http-response,response-fields']

[[로드맵카테고리생성-API]]
== *11. 로드맵 카테고리 생성 API*

=== *11-1* 성공

operation::roadmap-create-api-test/정상적으로_로드맵_카테고리를_생성한다[snippets='http-request,request-fields,http-response']

=== *11-2* 로드맵 카테고리 생성 시 카테고리 이름이 빈값일 경우

operation::roadmap-create-api-test/로드맵_카테고리_생성_시_카테고리_이름이_빈값일_경우[snippets='http-request,request-fields,http-response']

=== *11-3* 로드맵 카테고리 생성 시 카테고리 이름이 10자 초과일 경우

operation::roadmap-create-api-test/로드맵_카테고리_생성_시_카테고리_이름이_10자_초과일_경우[snippets='http-request,request-fields,http-response']

=== *11-4* 로드맵 카테고리 생성 시 카테고리 이름이 중복될 경우

operation::roadmap-create-api-test/로드맵_카테고리_생성_시_카테고리_이름이_중복될_경우[snippets='http-request,request-fields,http-response']
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package co.kirikiri.common.config;

import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
Expand All @@ -11,15 +13,23 @@
public class AWSConfig {

private final Regions region;
private final String accessKey;
private final String secretKey;

public AWSConfig(@Value("${cloud.aws.region.static}") final String region) {
public AWSConfig(@Value("${cloud.aws.region.static}") final String region,
@Value("${cloud.aws.credentials.access-key}") final String accessKey,
@Value("${cloud.aws.credentials.secret-key}") final String secretKey) {
this.region = Regions.fromName(region);
this.accessKey = accessKey;
this.secretKey = secretKey;
}

@Bean
public AmazonS3 amazonS3() {
final BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.build();
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package co.kirikiri.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class NetworkConfig {

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package co.kirikiri.common.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.Duration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
@EnableCaching
@Profile(value = {"prod", "dev", "local"})
public class RedisConfig {

@Bean
public RedisTemplate<String, String> redisTemplate(final RedisConnectionFactory redisConnectionFactory) {
final RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}

@Bean
public CacheManager redisCacheManager(final RedisConnectionFactory redisConnectionFactory) {
final ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.EVERYTHING);

final RedisSerializer<Object> serializer = new GenericJackson2JsonRedisSerializer(objectMapper);

final RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30L))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer));

return RedisCacheManager.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
import co.kirikiri.common.interceptor.AuthInterceptor;
import co.kirikiri.common.resolver.MemberIdentifierArgumentResolver;
import co.kirikiri.common.resolver.RoadmapSaveArgumentResolver;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;

@Configuration
@RequiredArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package co.kirikiri.common.interceptor;

import co.kirikiri.exception.AuthenticationException;
import co.kirikiri.service.AuthService;
import co.kirikiri.service.auth.AuthService;
import co.kirikiri.service.exception.AuthenticationException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package co.kirikiri.common.resolver;

import co.kirikiri.exception.AuthenticationException;
import co.kirikiri.service.AuthService;
import co.kirikiri.common.interceptor.Authenticated;
import co.kirikiri.service.auth.AuthService;
import co.kirikiri.service.exception.AuthenticationException;
import co.kirikiri.service.exception.ServerException;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpHeaders;
Expand All @@ -21,6 +23,9 @@ public class MemberIdentifierArgumentResolver implements HandlerMethodArgumentRe

@Override
public boolean supportsParameter(final MethodParameter parameter) {
if (!parameter.hasMethodAnnotation(Authenticated.class)) {
throw new ServerException("MemberIdentifier는 인증된 사용자만 사용 가능합니다. (@Authenticated)");
}
return parameter.getParameterType().equals(String.class)
&& parameter.hasParameterAnnotation(MemberIdentifier.class);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package co.kirikiri.common.resolver;

import co.kirikiri.exception.BadRequestException;
import co.kirikiri.service.dto.roadmap.request.RoadmapNodeSaveRequest;
import co.kirikiri.service.dto.roadmap.request.RoadmapSaveRequest;
import co.kirikiri.service.exception.BadRequestException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
package co.kirikiri.controller;

import co.kirikiri.service.AuthService;
import co.kirikiri.service.auth.AuthService;
import co.kirikiri.service.auth.NaverOauthService;
import co.kirikiri.service.dto.auth.OauthRedirectResponse;
import co.kirikiri.service.dto.auth.request.LoginRequest;
import co.kirikiri.service.dto.auth.request.ReissueTokenRequest;
import co.kirikiri.service.dto.auth.response.AuthenticationResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthService authService;
private final NaverOauthService naverOauthService;

@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(@RequestBody @Valid final LoginRequest request) {
Expand All @@ -31,4 +37,22 @@ public ResponseEntity<AuthenticationResponse> reissue(@RequestBody @Valid final
return ResponseEntity.ok(response);
}

@GetMapping("/oauth/naver")
public ResponseEntity<OauthRedirectResponse> loginOauth() {
final OauthRedirectResponse oauthRedirectResponse = naverOauthService.makeOauthUrl();
return ResponseEntity.ok(oauthRedirectResponse);
}

@GetMapping("/login/oauth")
public ResponseEntity<AuthenticationResponse> loginOauth(
@RequestParam(value = "code") final String code,
@RequestParam("state") final String state) {
final Map<String, String> queryParams = Map.of(
"code", code,
"state", state,
"grant_type", "authorization_code"
);
final AuthenticationResponse response = naverOauthService.login(queryParams);
return ResponseEntity.ok(response);
}
}
Loading

0 comments on commit 3f5cbe3

Please sign in to comment.