- 앱 배포 요청 받은후 request ID 저장 로직 추가

- authorization token 판별 로직 추가
This commit is contained in:
hyunjujeong 2023-08-17 18:32:20 +09:00
parent d32521e412
commit a3a7ce77cf
20 changed files with 370 additions and 36 deletions

View File

@ -15,6 +15,10 @@ jar {
enabled = false enabled = false
} }
ext {
jjwtVersion = "0.11.5"
}
repositories { repositories {
mavenCentral() mavenCentral()
} }
@ -26,6 +30,9 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-amqp") implementation("org.springframework.boot:spring-boot-starter-amqp")
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.integration:spring-integration-mqtt") implementation("org.springframework.integration:spring-integration-mqtt")
implementation("io.jsonwebtoken:jjwt-api:${jjwtVersion}")
runtimeOnly("io.jsonwebtoken:jjwt-impl:${jjwtVersion}")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:${jjwtVersion}")
runtimeOnly("org.postgresql:postgresql") runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" annotationProcessor "org.springframework.boot:spring-boot-configuration-processor"

View File

@ -3,6 +3,7 @@ package inc.sdt.blokworks.devicedeployer.application;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import inc.sdt.blokworks.devicedeployer.domain.AssetApp; import inc.sdt.blokworks.devicedeployer.domain.AssetApp;
import inc.sdt.blokworks.devicedeployer.domain.DeployRequest;
import inc.sdt.blokworks.devicedeployer.domain.OutboundMessage; import inc.sdt.blokworks.devicedeployer.domain.OutboundMessage;
import inc.sdt.blokworks.devicedeployer.domain.OperationType; import inc.sdt.blokworks.devicedeployer.domain.OperationType;
import inc.sdt.blokworks.devicedeployer.infrastructure.mqtt.InboundDeployMessagePayload; import inc.sdt.blokworks.devicedeployer.infrastructure.mqtt.InboundDeployMessagePayload;
@ -14,33 +15,42 @@ import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage; import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Optional;
@Service @Service
public class DefaultDeployerService implements DeployerService{ public class DefaultDeployerService implements DeployerService{
private final Logger log; private final Logger log;
private final IMqttClient mqttClient; private final IMqttClient mqttClient;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final DeployerRepositoryDelegate deployerRepositoryDelegate; private final DeployerRepositoryDelegate deployerRepositoryDelegate;
private String requestId; private final DeployRequestRepositoryDelegate requestRepositoryDelegate;
private final String filePath;
public DefaultDeployerService(IMqttClient mqttClient, public DefaultDeployerService(IMqttClient mqttClient,
ObjectMapper objectMapper, ObjectMapper objectMapper,
DeployerRepositoryDelegate deployerRepositoryDelegate) { DeployerRepositoryDelegate deployerRepositoryDelegate,
DeployRequestRepositoryDelegate requestRepositoryDelegate,
@Value("${stackbase.api.host}") String filePath) {
this.log = LoggerFactory.getLogger(this.getClass()); this.log = LoggerFactory.getLogger(this.getClass());
this.mqttClient = mqttClient; this.mqttClient = mqttClient;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.deployerRepositoryDelegate = deployerRepositoryDelegate; this.deployerRepositoryDelegate = deployerRepositoryDelegate;
this.requestRepositoryDelegate = requestRepositoryDelegate;
this.filePath = filePath;
} }
@Override @Override
public void publish(OutboundMessage deployMessage, String assetCode) { public void publish(OutboundMessage deployMessage, String assetCode) {
log.info("[publish]"); log.info("[publish] deployMessage = {}, assetCode = {}", deployMessage, assetCode);
final String url = filePath + deployMessage.getFileId();
try { try {
OutboundMessagePayload payload = new OutboundMessagePayload( OutboundMessagePayload payload = new OutboundMessagePayload(
deployMessage.getFileId(), url,
deployMessage.getName(), deployMessage.getName(),
deployMessage.getCommand(), deployMessage.getCommand(),
deployMessage.getEnv(), deployMessage.getEnv(),
@ -53,24 +63,29 @@ public class DefaultDeployerService implements DeployerService{
message.setPayload(bytes); message.setPayload(bytes);
mqttClient.publish("/assets/"+assetCode+"/apps/deploy", message); mqttClient.publish("/assets/"+assetCode+"/apps/deploy", message);
requestId = deployMessage.getRequestId();
log.info("[publish] message = {}", message); log.info("[publish] message = {}", message);
}catch (JsonProcessingException | MqttException e) { }catch (JsonProcessingException | MqttException e) {
throw new IllegalArgumentException(); throw new IllegalArgumentException();
} }
} }
@Override
public DeployRequest save(DeployRequest deployRequest) {
log.info("[save] deployRequest = {}", deployRequest);
requestRepositoryDelegate.save(deployRequest);
return deployRequest;
}
@Override @Override
public Mono<Void> apply(InboundDeployMessagePayload payload) { public Mono<Void> apply(InboundDeployMessagePayload payload) {
log.info("[apply] inboundDeployMessagePayload = {}", payload); log.info("[apply] payload = {}", payload);
// 배포된 앱 정보 저장 Optional<DeployRequest> deployRequest = requestRepositoryDelegate.findByRequestId(payload.requestId());
// request Id 판별 if(deployRequest.isPresent()) {
if(requestId.equals(payload.requestId())) {
return Mono.just(payload) return Mono.just(payload)
.doOnNext(deployerRepositoryDelegate) .doOnNext(deployerRepositoryDelegate)
.then(); .then();
}else { }else {
throw new ConflictException("This process is already exists."); throw new IllegalArgumentException();
} }
} }

View File

@ -0,0 +1,10 @@
package inc.sdt.blokworks.devicedeployer.application;
import inc.sdt.blokworks.devicedeployer.domain.DeployRequest;
import java.util.Optional;
public interface DeployRequestRepositoryDelegate {
DeployRequest save(DeployRequest deployRequest);
Optional<DeployRequest> findByRequestId(String requestId);
}

View File

@ -1,6 +1,7 @@
package inc.sdt.blokworks.devicedeployer.application; package inc.sdt.blokworks.devicedeployer.application;
import inc.sdt.blokworks.devicedeployer.domain.AssetApp; import inc.sdt.blokworks.devicedeployer.domain.AssetApp;
import inc.sdt.blokworks.devicedeployer.domain.DeployRequest;
import inc.sdt.blokworks.devicedeployer.domain.OutboundMessage; import inc.sdt.blokworks.devicedeployer.domain.OutboundMessage;
import inc.sdt.blokworks.devicedeployer.infrastructure.mqtt.InboundDeployMessagePayload; import inc.sdt.blokworks.devicedeployer.infrastructure.mqtt.InboundDeployMessagePayload;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -10,5 +11,6 @@ import java.util.function.Function;
public interface DeployerService extends Function<InboundDeployMessagePayload, Mono<Void>> { public interface DeployerService extends Function<InboundDeployMessagePayload, Mono<Void>> {
void publish(OutboundMessage assetApp, String assetCode); void publish(OutboundMessage assetApp, String assetCode);
DeployRequest save(DeployRequest deployRequest);
Page<AssetApp> getAll(String assetCode, int page, int size); Page<AssetApp> getAll(String assetCode, int page, int size);
} }

View File

@ -0,0 +1,83 @@
package inc.sdt.blokworks.devicedeployer.domain;
public class DeployRequest {
private String requestId;
private String assetCode;
private String appName;
private OperationType operationType;
protected DeployRequest() {}
public DeployRequest(String requestId, String assetCode, String appName, OperationType operationType) {
this.requestId = requestId;
this.assetCode = assetCode;
this.appName = appName;
this.operationType = operationType;
}
public String getRequestId() {
return requestId;
}
public String getAssetCode() {
return assetCode;
}
public String getAppName() {
return appName;
}
public OperationType getOperationType() {
return operationType;
}
@Override
public String toString() {
return "DeployRequest{" +
"requestId='" + requestId + '\'' +
", assetCode='" + assetCode + '\'' +
", appName='" + appName + '\'' +
", operationType='" + operationType + '\'' +
'}';
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String requestId;
private String assetCode;
private String appName;
private OperationType operationType;
public Builder requestId(String requestId) {
this.requestId = requestId;
return this;
}
public Builder assetCode(String assetCode) {
this.assetCode = assetCode;
return this;
}
public Builder appName(String appName) {
this.appName = appName;
return this;
}
public Builder operationType(OperationType operationType) {
this.operationType = operationType;
return this;
}
public DeployRequest build() {
DeployRequest deployRequest = new DeployRequest();
deployRequest.requestId = this.requestId;
deployRequest.assetCode = this.assetCode;
deployRequest.appName = this.appName;
deployRequest.operationType = this.operationType;
return deployRequest;
}
}
}

View File

@ -8,7 +8,6 @@ public class OutboundMessage {
private String name; private String name;
private HashMap<String, String> env; private HashMap<String, String> env;
private String command; private String command;
private OperationType operationType;
private String requestId; private String requestId;
protected OutboundMessage() {} protected OutboundMessage() {}
@ -29,10 +28,6 @@ public class OutboundMessage {
return command; return command;
} }
public OperationType getOperationType() {
return operationType;
}
public void setRequestId(String requestId) { public void setRequestId(String requestId) {
this.requestId = requestId; this.requestId = requestId;
} }
@ -48,7 +43,6 @@ public class OutboundMessage {
", name='" + name + '\'' + ", name='" + name + '\'' +
", env=" + env + ", env=" + env +
", command='" + command + '\'' + ", command='" + command + '\'' +
", operationType=" + operationType +
", requestId='" + requestId + '\'' + ", requestId='" + requestId + '\'' +
'}'; '}';
} }
@ -62,7 +56,6 @@ public class OutboundMessage {
private String name; private String name;
private HashMap<String, String> env; private HashMap<String, String> env;
private String command; private String command;
private OperationType operationType;
private String requestId; private String requestId;
public Builder fileId(String fileId) { public Builder fileId(String fileId) {
@ -85,11 +78,6 @@ public class OutboundMessage {
return this; return this;
} }
public Builder operationType(OperationType operationType) {
this.operationType = operationType;
return this;
}
public Builder requestId(String requestId) { public Builder requestId(String requestId) {
this.requestId = requestId; this.requestId = requestId;
return this; return this;
@ -101,7 +89,6 @@ public class OutboundMessage {
deployMessage.name = this.name; deployMessage.name = this.name;
deployMessage.env = this.env; deployMessage.env = this.env;
deployMessage.command = this.command; deployMessage.command = this.command;
deployMessage.operationType = this.operationType;
deployMessage.requestId = this.requestId; deployMessage.requestId = this.requestId;
return deployMessage; return deployMessage;
} }

View File

@ -5,10 +5,10 @@ import inc.sdt.blokworks.devicedeployer.domain.Status;
public record InboundDeployMessagePayload( public record InboundDeployMessagePayload(
Status status, Status status,
String assetCode, String assetCode,
String requestId,
String name, String name,
Long size, Long size,
Long releasedAt, Long releasedAt,
Long updatedAt, Long updatedAt
String requestId
) { ) {
} }

View File

@ -2,8 +2,6 @@ package inc.sdt.blokworks.devicedeployer.infrastructure.relational;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.sql.Timestamp;
@Entity(name = "asset_app") @Entity(name = "asset_app")
class AssetAppEntity { class AssetAppEntity {
@Id @Id

View File

@ -23,6 +23,7 @@ public class AssetAppRelationalRepository implements DeployerRepositoryDelegate
@Override @Override
public void accept(InboundDeployMessagePayload inboundDeployMessagePayload) { public void accept(InboundDeployMessagePayload inboundDeployMessagePayload) {
log.info("[accept] payload = {}", inboundDeployMessagePayload);
this.save(this.fromMessage(inboundDeployMessagePayload)); this.save(this.fromMessage(inboundDeployMessagePayload));
} }

View File

@ -0,0 +1,50 @@
package inc.sdt.blokworks.devicedeployer.infrastructure.relational;
import inc.sdt.blokworks.devicedeployer.domain.OperationType;
import jakarta.persistence.*;
@Entity(name = "deploy_request")
public class DeployRequestEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "id")
private String id;
@Column(name = "request_id")
private String requestId;
@Column(name = "asset_code", length = 255)
private String assetCode;
@Column(name = "app_name", length = 255)
private String appName;
@Enumerated(EnumType.STRING)
@Column(name = "operation_type", length = 255)
private OperationType operationType;
protected DeployRequestEntity() {}
public DeployRequestEntity(String requestId, String assetCode, String appName, OperationType operationType) {
this.requestId = requestId;
this.assetCode = assetCode;
this.appName = appName;
this.operationType = operationType;
}
public String getId() {
return id;
}
public String getRequestId() {
return requestId;
}
public String getAssetCode() {
return assetCode;
}
public String getAppName() {
return appName;
}
public OperationType getOperationType() {
return operationType;
}
}

View File

@ -0,0 +1,7 @@
package inc.sdt.blokworks.devicedeployer.infrastructure.relational;
import org.springframework.data.jpa.repository.JpaRepository;
public interface DeployRequestJpaRepository extends JpaRepository<DeployRequestEntity, String> {
DeployRequestEntity findByRequestId(String requestId);
}

View File

@ -0,0 +1,54 @@
package inc.sdt.blokworks.devicedeployer.infrastructure.relational;
import inc.sdt.blokworks.devicedeployer.application.DeployRequestRepositoryDelegate;
import inc.sdt.blokworks.devicedeployer.domain.DeployRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import java.util.Optional;
@Component
public class DeployRequestRelationalRepository implements DeployRequestRepositoryDelegate {
private final Logger log;
private final DeployRequestJpaRepository deployRequestJpaRepository;
public DeployRequestRelationalRepository(DeployRequestJpaRepository deployRequestJpaRepository) {
this.log = LoggerFactory.getLogger(this.getClass());
this.deployRequestJpaRepository = deployRequestJpaRepository;
}
@Override
public DeployRequest save(DeployRequest deployRequest) {
log.info("[save] deployRequest = {}", deployRequest);
DeployRequestEntity entity = this.toEntity(deployRequest);
deployRequestJpaRepository.save(entity);
return deployRequest;
}
@Override
public Optional<DeployRequest> findByRequestId(String requestId) {
log.info("[findByRequestId] requestId = {}", requestId);
DeployRequestEntity entity = deployRequestJpaRepository.findByRequestId(requestId);
log.info("[findByRequestId] entity = {}", entity);
return Optional.of(this.fromEntity(entity));
}
private DeployRequestEntity toEntity(DeployRequest deployRequest) {
return new DeployRequestEntity(
deployRequest.getRequestId(),
deployRequest.getAssetCode(),
deployRequest.getAppName(),
deployRequest.getOperationType()
);
}
private DeployRequest fromEntity(DeployRequestEntity entity) {
return DeployRequest.builder()
.requestId(entity.getRequestId())
.assetCode(entity.getAssetCode())
.appName(entity.getAppName())
.operationType(entity.getOperationType())
.build();
}
}

View File

@ -2,15 +2,14 @@ package inc.sdt.blokworks.devicedeployer.presentation;
import inc.sdt.blokworks.devicedeployer.application.DeployerService; import inc.sdt.blokworks.devicedeployer.application.DeployerService;
import inc.sdt.blokworks.devicedeployer.application.ProcessService; import inc.sdt.blokworks.devicedeployer.application.ProcessService;
import inc.sdt.blokworks.devicedeployer.domain.AssetApp; import inc.sdt.blokworks.devicedeployer.domain.*;
import inc.sdt.blokworks.devicedeployer.domain.OperationType;
import inc.sdt.blokworks.devicedeployer.domain.OutboundMessage;
import inc.sdt.blokworks.devicedeployer.domain.Process; import inc.sdt.blokworks.devicedeployer.domain.Process;
import inc.sdt.blokworks.devicedeployer.infrastructure.amqp.ResourceMapping; import inc.sdt.blokworks.devicedeployer.infrastructure.amqp.ResourceMapping;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -47,6 +46,15 @@ public class DeployerController {
String requestId = UUID.randomUUID().toString(); String requestId = UUID.randomUUID().toString();
OutboundMessage outboundMessage = outboundMessageResourceConverter.fromResource(assetAppResource); OutboundMessage outboundMessage = outboundMessageResourceConverter.fromResource(assetAppResource);
outboundMessage.setRequestId(requestId); outboundMessage.setRequestId(requestId);
DeployRequest deployRequest = DeployRequest.builder()
.requestId(requestId)
.assetCode(assetCode)
.appName(outboundMessage.getName())
.operationType(OperationType.DEPLOY)
.build();
DeployRequest request = deployerService.save(deployRequest);
deployerService.publish(outboundMessage, assetCode); deployerService.publish(outboundMessage, assetCode);
} }

View File

@ -44,11 +44,11 @@ public class MqttMessageHandler {
.map(p -> new InboundDeployMessagePayload( .map(p -> new InboundDeployMessagePayload(
p.status(), p.status(),
p.assetCode(), p.assetCode(),
id,
p.name(), p.name(),
p.size(), p.size(),
p.releasedAt(), p.releasedAt(),
p.updatedAt(), p.updatedAt()))
id))
.flatMap(deployerService) .flatMap(deployerService)
.subscribe(); .subscribe();
}else { }else {

View File

@ -0,0 +1,31 @@
package inc.sdt.blokworks.devicedeployer.presentation.configuration;
import inc.sdt.blokworks.devicedeployer.presentation.filter.AuthorizationFilter;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Component
class WebConfiguration implements WebMvcConfigurer {
private final AuthorizationFilter authorizationFilter;
public WebConfiguration(AuthorizationFilter authorizationFilter) {
this.authorizationFilter = authorizationFilter;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.maxAge(3600);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authorizationFilter).addPathPatterns(
"/assets/**"
);
}
}

View File

@ -0,0 +1,14 @@
package inc.sdt.blokworks.devicedeployer.presentation.exception;
public class UnauthorizedException extends RuntimeException {
private static final String DEFAULT_MESSAGE = "Unauthorized";
public UnauthorizedException() {
super(DEFAULT_MESSAGE);
}
public UnauthorizedException(String message) {
super(message);
}
}

View File

@ -0,0 +1,50 @@
package inc.sdt.blokworks.devicedeployer.presentation.filter;
import inc.sdt.blokworks.devicedeployer.presentation.exception.UnauthorizedException;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.NoSuchElementException;
@Component
public class AuthorizationFilter implements HandlerInterceptor {
private final Logger log;
private final String secretKey;
public AuthorizationFilter(@Value("${application.security.jwt.secret-key}") String secretKey) {
this.secretKey = secretKey;
this.log = LoggerFactory.getLogger(this.getClass());
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
log.info("[preHandle]");
if(request.getHeader("Authorization") == null) {
throw new NoSuchElementException("token does not exist.");
}
String token = request.getHeader("Authorization").substring("Bearer ".length());
byte[] keyBytes = io.jsonwebtoken.io.Decoders.BASE64.decode(secretKey);
javax.crypto.SecretKey secretKey = io.jsonwebtoken.security.Keys.hmacShaKeyFor(keyBytes);
try {
io.jsonwebtoken.Claims claims = io.jsonwebtoken.Jwts
.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
request.setAttribute("userId", claims.get("id").toString());
return true;
}catch (ExpiredJwtException e) {
throw new UnauthorizedException("Token has expired");
}
}
}

View File

@ -27,4 +27,8 @@ iam:
username: ${IAM_AMQP_CREDENTIALS_USERNAME} username: ${IAM_AMQP_CREDENTIALS_USERNAME}
password: ${IAM_AMQP_CREDENTIALS_PASSWORD} password: ${IAM_AMQP_CREDENTIALS_PASSWORD}
exchange: ${IAM_AMQP_EXCHANGE} exchange: ${IAM_AMQP_EXCHANGE}
routing-key: ${IAM_AMQP_ROUTING_KEY} routing-key: ${IAM_AMQP_ROUTING_KEY}
stackbase:
api:
host: https://sdt-site-bucket.s3.ap-northeast-2.amazonaws.com/app/

View File

@ -13,8 +13,13 @@ spring:
inbound: inbound:
mqtt: mqtt:
url: tcp://localhost:1883 url: tcp://localhost:1883
#url: tcp://13.209.39.139:32259
username: sdt username: sdt
password: 251327 password: 251327
topics: topics:
- /assets/+/command-req/+ - /assets/+/command-req/+
- /assets/+/apps/process - /assets/+/apps/process
stackbase:
api:
host: https://sdt-site-bucket.s3.ap-northeast-2.amazonaws.com/app/

View File

@ -5,12 +5,20 @@ spring:
name: device-deployer name: device-deployer
datasource: datasource:
driver-class-name: org.postgresql.Driver driver-class-name: org.postgresql.Driver
application:
security:
jwt:
secret-key: D3KJ3G92IWW3W3QWZLU416IN4T9AFYEWM84P5HKHRFEA8C4I1HWALFXGP2HYD87Q
inbound: inbound:
mqtt: mqtt:
url: tcp://192.168.1.162:32102 url: tcp://13.209.39.139:32259
username: sdt username: sdt
password: 251327 password: 251327
topics: topics:
- /assets/+/command-req/+ - /assets/+/command-req/+
- /assets/+/apps/process - /assets/+/apps/process
stackbase:
api:
host: https://sdt-site-bucket.s3.ap-northeast-2.amazonaws.com/app/