Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ public class AuthController implements AuthApi {
public ResponseEntity<ResponseBody<TokenResponse>> exchangeToken(@RequestBody CodeExchangeRequest request) {
// Redis에서 코드로 토큰 조회
String accessToken = redisTemplate.opsForValue().get("auth_code:" + request.getCode());
log.info("request = {}", request.getCode());
log.info("accessToken = {}", accessToken);
if (accessToken == null) {
throw new IllegalArgumentException("Invalid or expired code");
}
Expand Down
14 changes: 2 additions & 12 deletions src/main/java/avengers/lion/global/OAuth2SuccessHandler.java
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
package avengers.lion.global;

import avengers.lion.auth.domain.KakaoUserInfo;
import avengers.lion.global.jwt.TokenDto;
import avengers.lion.global.jwt.TokenProvider;
import avengers.lion.global.response.SuccessResponseBody;
import avengers.lion.member.domain.Member;
import avengers.lion.member.domain.MemberRole;
import avengers.lion.member.repository.MemberRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
Expand Down Expand Up @@ -44,15 +40,12 @@ public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException{
log.info("🟢 OAuth2SuccessHandler 시작 - 카카오 로그인 성공");

// 카카오에서 내려준 사용자 정보를 꺼냄
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
log.info("🔵 OAuth2User 정보: {}", oAuth2User.getAttributes());

// 파싱하기 위한 래퍼 클래스
KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(oAuth2User.getAttributes());
log.info("🔵 KakaoUserInfo - email: {}, nickname: {}", kakaoUserInfo.getEmail(), kakaoUserInfo.getNickname());

// DB에 이메일로 가입된 유저가 있는지 확인, 없으면 신규 회원 생성
Member member = memberRepository.findByEmail(kakaoUserInfo.getEmail())
Expand All @@ -66,23 +59,20 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo
.build()
));

log.info("🔵 Member 조회/생성 완료 - ID: {}, Email: {}", member.getId(), member.getEmail());

// 사용자 식별 정보를 이용해 토큰 발급
String accessToken = tokenProvider.createAccessToken(member.getId(), member.getRole().name());
log.info("🔵 AccessToken 생성 완료 - Length: {}", accessToken.length());

// 임시 코드 생성 (UUID)
String authCode = UUID.randomUUID().toString();
log.info("🔵 임시 코드 생성: {}", authCode);

// Redis에 임시 코드-토큰 매핑 저장 (5분 TTL)
redisTemplate.opsForValue().set("auth_code:" + authCode, accessToken, 5, TimeUnit.MINUTES);
log.info("🔵 Redis에 임시 코드 저장 완료");

Comment on lines 63 to +71
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

DB 트랜잭션 커밋 전 Redis에 토큰 저장 — TOCTOU 위험

JPA 트랜잭션이 커밋되기 전에 액세스 토큰을 Redis에 저장합니다. 커밋 실패 시 존재하지 않는 사용자에 대한 토큰이 발급·노출될 수 있습니다. 트랜잭션 커밋 이후에 저장하도록 조정하세요.

수정 예:

-        // 사용자 식별 정보를 이용해 토큰 발급
-        String accessToken = tokenProvider.createAccessToken(member.getId(), member.getRole().name());
-        // 임시 코드 생성 (UUID)
-        String authCode = UUID.randomUUID().toString();
-        // Redis에 임시 코드-토큰 매핑 저장 (5분 TTL)
-        redisTemplate.opsForValue().set("auth_code:" + authCode, accessToken, 5, TimeUnit.MINUTES);
+        String accessToken = tokenProvider.createAccessToken(member.getId(), member.getRole().name());
+        String authCode = UUID.randomUUID().toString();
+        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+            @Override public void afterCommit() {
+                redisTemplate.opsForValue()
+                    .set("auth_code:" + authCode, accessToken, 5, TimeUnit.MINUTES);
+            }
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 사용자 식별 정보를 이용해 토큰 발급
String accessToken = tokenProvider.createAccessToken(member.getId(), member.getRole().name());
log.info("🔵 AccessToken 생성 완료 - Length: {}", accessToken.length());
// 임시 코드 생성 (UUID)
String authCode = UUID.randomUUID().toString();
log.info("🔵 임시 코드 생성: {}", authCode);
// Redis에 임시 코드-토큰 매핑 저장 (5분 TTL)
redisTemplate.opsForValue().set("auth_code:" + authCode, accessToken, 5, TimeUnit.MINUTES);
log.info("🔵 Redis에 임시 코드 저장 완료");
String accessToken = tokenProvider.createAccessToken(member.getId(), member.getRole().name());
String authCode = UUID.randomUUID().toString();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
redisTemplate.opsForValue()
.set("auth_code:" + authCode, accessToken, 5, TimeUnit.MINUTES);
}
});
🤖 Prompt for AI Agents
In src/main/java/avengers/lion/global/OAuth2SuccessHandler.java around lines 63
to 71, the code stores the access token in Redis before the JPA transaction
commits which risks TOCTOU if the DB commit later fails; change the flow so the
Redis write happens only after successful transaction commit by either moving
the Redis set call into the service layer after the transaction completes or
register an afterCommit callback via
TransactionSynchronizationManager.registerSynchronization/TransactionSynchronizationAdapter
to perform redisTemplate.opsForValue().set("auth_code:"+authCode, accessToken,
5, TimeUnit.MINUTES) only in the afterCommit callback; ensure authCode is still
generated and propagated to the afterCommit handler and handle errors/logging
accordingly.


// 프론트엔드로 임시 코드와 함께 리다이렉트
String frontendUrl = "http://localhost:5174/auth/callback?code=" + authCode;
getRedirectStrategy().sendRedirect(request, response, frontendUrl);
Comment on lines 70 to 75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

임시 코드 교환 로직 점검 요청

auth_code 키로 저장된 토큰이 1회성으로 안전하게 교환되고 즉시 삭제되는지, TTL 5분이 문서/클라이언트와 일치하는지 확인 필요.

아래 스크립트로 교환/삭제 구현부를 찾아 확인해 주세요:


🏁 Script executed:

#!/bin/bash
# auth_code 조회·삭제 사용처 점검
rg -n -C3 -P 'auth_code:\s*\+|auth_code:' --type=java
rg -n -C2 -P 'opsForValue\(\)\.(get|getAndDelete|delete)\(' --type=java

Length of output: 4593


임시 auth_code 조회→삭제를 원자적으로 처리하도록 수정 필요

OAuth2SuccessHandler에서 auth_code를 5분 TTL로 저장하는 것(src/main/java/avengers/lion/global/OAuth2SuccessHandler.java:70)을 확인했고, AuthController.exchangeToken에서는 opsForValue().get(...)로 조회한 뒤 redisTemplate.delete(...)로 삭제(src/main/java/avengers/lion/auth/controller/AuthController.java:24,30)하고 있어 조회와 삭제가 분리되어 동시 요청 시 동일 코드가 중복 사용될 가능성이 있습니다.

  • 조치: 조회+삭제를 원자적으로 처리하도록 변경 — 예: opsForValue().getAndDelete("auth_code:...") 사용(라이브러리 메서드 지원 여부 확인) 또는 Lua 스크립트/트랜잭션으로 GET+DEL 원자화. (수정 대상: src/main/java/avengers/lion/auth/controller/AuthController.java:24-30)
  • 확인: TTL(5분)이 문서/프론트엔드 요구사항과 일치하는지 확인 및 필요 시 조정.
🤖 Prompt for AI Agents
In src/main/java/avengers/lion/auth/controller/AuthController.java around lines
24 to 30, the current flow separately calls redisTemplate.opsForValue().get(...)
then redisTemplate.delete(...), which can allow race conditions and double-use
of the same auth_code; change this to perform an atomic get-and-delete operation
— prefer using RedisOperations.opsForValue().getAndDelete(key) if available,
otherwise implement the atomicity via a Redis Lua script (GET+DEL) or a Redis
transaction that returns and removes the key in one atomic step; ensure error
handling remains and validate that the 5-minute TTL set elsewhere matches spec
and adjust if required.

log.info("🟢 OAuth2SuccessHandler 완료");

}
}
2 changes: 1 addition & 1 deletion src/main/java/avengers/lion/mission/api/MissionApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ ResponseEntity<ResponseBody<MissionPreReviewResponse>> getMissionReviews(
)
@GetMapping("/{missionId}/reviews/scroll")
@PreAuthorize("hasAuthority('ROLE_USER')")
ResponseEntity<ResponseBody<PageResult<MissionReviewResponse>>> getMissionReviewsScroll(
ResponseEntity<ResponseBody<PageResult<MissionReviewResponse>>> getMissionReviewsWithCursor(
@PathVariable Long missionId,
@RequestParam(required = false) Long lastReviewId,
@RequestParam(required = false, defaultValue = "3") @Min(1) @Max(50) int limit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public ResponseEntity<ResponseBody<MissionPreReviewResponse>> getMissionReviews(
*/
@GetMapping("/{missionId}/reviews/scroll")
@PreAuthorize( "hasAuthority('ROLE_USER')")
public ResponseEntity<ResponseBody<PageResult<MissionReviewResponse>>> getMissionReviewsScroll(
public ResponseEntity<ResponseBody<PageResult<MissionReviewResponse>>> getMissionReviewsWithCursor(
@PathVariable Long missionId,
@RequestParam("lastReviewId") Long lastReviewId,
@RequestParam(required = false, defaultValue = "3") @Min(1) @Max(50) int limit,
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/avengers/lion/mission/domain/Mission.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public class Mission extends BaseEntity {
@Column(name = "longitude", columnDefinition = "DECIMAL(11,8)")
private Double longitude;

@Column(name = "tip")
private String tip;

@OneToMany(mappedBy = "mission")
private List<CompletedMission> completedMissions = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import avengers.lion.place.domain.PlaceCategory;


public record MissionResponse(Long missionId, String placeName, String description, Double latitude, Double longitude, PlaceCategory placeCategory, String address, Boolean isCompleted) {
public record MissionResponse(Long missionId, String placeName, String description, Double latitude, Double longitude, PlaceCategory placeCategory, String address, String tip, Boolean isCompleted) {

public static MissionResponse of(Mission mission, Boolean isCompleted){
return new MissionResponse(
Expand All @@ -15,6 +15,7 @@ public static MissionResponse of(Mission mission, Boolean isCompleted){
mission.getLongitude(),
mission.getPlaceCategory(),
mission.getAddress(),
mission.getTip(),
isCompleted
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Set;

@Repository
public interface MissionRepository extends JpaRepository<Mission, Long> {

@Query("SELECT DISTINCT m FROM Mission m LEFT JOIN FETCH m.completedMissions cm " +
"LEFT JOIN FETCH cm.member " +
"WHERE m.status = :missionStatus")
List<Mission> findAllByMissionStatusWithCompletedMissions(@Param("missionStatus") MissionStatus missionStatus);
@Query("SELECT m FROM Mission m WHERE m.status = :status")
List<Mission> findAllByStatus(@Param("status") MissionStatus status);

@Query("SELECT cm.mission.id FROM CompletedMission cm WHERE cm.member.id = :memberId")
Set<Long> findCompletedMissionIdsByMember(@Param("memberId") Long memberId);
}
25 changes: 0 additions & 25 deletions src/main/java/avengers/lion/mission/service/CallbackService.java
Original file line number Diff line number Diff line change
Expand Up @@ -48,28 +48,14 @@ public class CallbackService {
*/
public void verifySignatureOrThrow(String jobId, FastApiCallbackRequest request, String signature) {
if (signature == null || signature.trim().isEmpty()) {
log.warn("Missing callback signature for jobId: {}", jobId);
throw new BusinessException(ExceptionType.FAST_API_DENIED);
}
log.info("request={}",request);

try {
// 페이로드 생성: jobId + requestBody (JSON)
String requestJson = objectMapper.writeValueAsString(request);
String payload = jobId + requestJson;

log.warn("Signature verification for jobId: {}", jobId);
log.warn("Request JSON: {}", requestJson);
log.warn("Payload: {}", payload);
log.warn("Received signature: {}", signature);
log.warn("Secret key: {}", secretKey);

log.debug("Signature verification for jobId: {}", jobId);
log.debug("Request JSON: {}", requestJson);
log.debug("Payload: {}", payload);
log.debug("Received signature: {}", signature);
log.debug("Secret key: {}", secretKey);

// HMAC-SHA256 서명 생성
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Expand All @@ -78,20 +64,12 @@ public void verifySignatureOrThrow(String jobId, FastApiCallbackRequest request,
byte[] rawHmac = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = HexFormat.of().formatHex(rawHmac);

log.warn("Expected signature: {}", expectedSignature);

log.debug("Expected signature: {}", expectedSignature);

// 서명 비교 (타이밍 공격 방지)
if (!constantTimeEquals(signature, expectedSignature)) {
log.warn("Invalid callback signature for jobId: {}", jobId);
throw new BusinessException(ExceptionType.FAST_API_DENIED);
}

log.debug("Callback signature verified successfully for jobId: {}", jobId);

} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to verify callback signature for jobId: {}", jobId, e);
throw new BusinessException(ExceptionType.FAST_API_DENIED);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
Expand Down Expand Up @@ -135,15 +113,13 @@ public void processCallback(String jobId, FastApiCallbackRequest request) {
진행 중 콜백
*/
private void handleProgressCallback(String jobId) {
log.info("진행중 콜백");
eventService.sendEvent(jobId, VerificationEvent.progress(jobId));
}

/*
완료 콜백
*/
private void handleCompletedCallback(String jobId, FastApiCallbackRequest request) {
log.info("완료 콜백");
MetadataCacheService.VerificationMetadata metadata = metadataCacheService.getMetadata(jobId);
if (metadata == null) {
eventService.sendEvent(jobId, VerificationEvent.error(jobId));
Expand All @@ -170,7 +146,6 @@ private void handleCompletedCallback(String jobId, FastApiCallbackRequest reques
}

private void handleFailedCallback(String jobId, FastApiCallbackRequest request) {
log.info("실패 콜백");
cleanupService.cleanupFailedVerification(jobId);
eventService.sendEvent(jobId, VerificationEvent.failed(jobId));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ public void cacheMetadata(String jobId, String imageUrl, String publicId, Long m
String key = CACHE_PREFIX + jobId;

redisTemplate.opsForValue().set(key, metadata, CACHE_TTL);
log.debug("Cached metadata for jobId: {}", jobId);

} catch (Exception e) {
log.error("Failed to cache metadata for jobId: {}", jobId, e);
throw new RuntimeException("메타데이터 캐싱에 실패했습니다.", e);
}
}
Expand All @@ -44,12 +42,10 @@ public VerificationMetadata getMetadata(String jobId) {
if (cached instanceof VerificationMetadata metadata) {
return metadata;
}

log.debug("No metadata found for jobId: {}", jobId);

return null;

} catch (Exception e) {
log.error("Failed to retrieve metadata for jobId: {}", jobId, e);
return null;
}
}
Expand All @@ -58,9 +54,7 @@ public void removeMetadata(String jobId) {
try {
String key = CACHE_PREFIX + jobId;
redisTemplate.delete(key);
log.debug("Removed metadata for jobId: {}", jobId);
} catch (Exception e) {
log.error("Failed to remove metadata for jobId: {}", jobId, e);
}
}

Expand Down
26 changes: 14 additions & 12 deletions src/main/java/avengers/lion/mission/service/MissionService.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@

@Service
@RequiredArgsConstructor

/*
Mission 서비스
사용자가 미션을 수행하기 위해 미션 페이지에 진입했을 때,
미션 조회, 미션에 관한 리뷰 조회를 수행하는 서비스
*/
public class MissionService {

private static final double THRESHOLD_METERS = 100.0;
Expand All @@ -35,14 +41,8 @@ public class MissionService {
미션 전체조회 프리뷰 -> 사용자별 완료 상태 포함
*/
public List<MissionResponse> getAllMissions(Long memberId){
List<Mission> activeMissions = missionRepository.findAllByMissionStatusWithCompletedMissions(MissionStatus.ACTIVE);

// 완료된 미션 ID 집합을 미리 추출하여 O(1) 조회 최적화
Set<Long> completedMissionIds = activeMissions.stream()
.flatMap(mission -> mission.getCompletedMissions().stream())
.filter(completed -> completed.getMember().getId().equals(memberId))
.map(completed -> completed.getMission().getId())
.collect(Collectors.toSet());
List<Mission> activeMissions = missionRepository.findAllByStatus(MissionStatus.ACTIVE);
Set<Long> completedMissionIds = missionRepository.findCompletedMissionIdsByMember(memberId);

return activeMissions.stream()
.map(mission -> {
Expand All @@ -55,19 +55,20 @@ public List<MissionResponse> getAllMissions(Long memberId){
public MissionPreReviewResponse getMissionPreReviews(Long missionId, SortType sortType){
List<Review> reviews = reviewRepository.findAllReviewByMissionId(missionId, null, 4, sortType);
Long count = reviewRepository.countReviewByMissionId(missionId);
// 다음 페이지가 있는지
boolean hasNext = reviews.size() > 3;
if (hasNext) reviews = reviews.subList(0, 3);
List<MissionReviewResponse> data = reviews.stream()
.map(MissionReviewResponse::of)
.toList();
LocalDateTime nextAt = null; Long nextId = null;
if (!reviews.isEmpty()) { Review last = reviews.getLast(); nextId = last.getId(); }
Long nextId = null;
// 다음 페이지 요청 시 전달할 마지막 id
if (!reviews.isEmpty()) nextId = reviews.getLast().getId();
return new MissionPreReviewResponse(count, data, new PageMeta(hasNext, null, nextId));
}




/*
미션 리뷰조회 스크롤
*/
Expand All @@ -81,7 +82,7 @@ public PageResult<MissionReviewResponse> getMissionReviews(Long missionId, Long
.toList();

Long nextId = null;
if (!reviews.isEmpty()) { Review last = reviews.getLast(); nextId = last.getId(); }
if (!reviews.isEmpty()) {nextId = reviews.getLast().getId(); }

return new PageResult<>(data, hasNext, null, nextId);
}
Expand All @@ -100,6 +101,7 @@ public String gpsAuthentication(Long missionId, GpsAuthenticationRequest gpsAuth
return mission.getImageUrl();
}


private double calcDistanceMeters(
double lat1, double lng1, double lat2, double lng2
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@
@Slf4j
@Service
@RequiredArgsConstructor

/*
Mission 인증 서비스
사용자가 촬영 미션을 수행할 때 처리하는 서비스 코드
*/
public class MissionVerifyService {

private final MetadataCacheService metadataCacheService;
private final CloudinaryPreSignedService cloudinaryPreSignedService;
private final VerificationCleanupService cleanupService;
private final WebClient webClient;
private final MissionRepository missionRepository;
@Value("${app.fast-api.url}")
private String fastApiUrl;

Expand All @@ -37,10 +41,8 @@ public class MissionVerifyService {
*/
public UploadUrlResponse generateUploadUrl(Long missionId) {
try {
log.info("Generating upload URL for missionId: {}", missionId);
// 임시 키 생성 (실제 인증 시 새로 생성됨)
String tempKey = "temp-" + metadataCacheService.generateJobId();
log.info("Temp key generated: {}", tempKey);
// Cloudinary Pre-signed URL 생성
CloudinaryPreSignedService.CloudinaryUploadInfo uploadInfo =
cloudinaryPreSignedService.generatePreSignedUrl(tempKey);
Expand Down Expand Up @@ -94,6 +96,9 @@ public VerifyImageResponse startVerification(Long missionId, VerifyImageRequest



/*
FastApi 콜백 메서드 -> 분석 요청을 보냄
*/
@Async
public void callFastApiAsync(String jobId, String imageUrl) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ public class VerificationCleanupService {
public void cleanupFailedVerification(String jobId) {
MetadataCacheService.VerificationMetadata metadata = metadataCacheService.getMetadata(jobId);
if (metadata != null) {
log.info("Cleaning up failed verification: jobId={}, publicId={}", jobId, metadata.publicId());

// 1. Cloudinary에서 이미지 삭제
try {
cloudinaryService.deleteImage(metadata.publicId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ public SseEmitter createEventStream(String jobId) {
public void sendEvent(String jobId, VerificationEvent event) {
SseEmitter emitter = emitters.get(jobId);
if (emitter == null) {
log.warn("No SSE emitter found for jobId: {}", jobId);
return;
}

Expand All @@ -82,7 +81,6 @@ public void sendEvent(String jobId, VerificationEvent event) {
}

} catch (IOException e) {
log.error("Failed to send SSE event for jobId: {}", jobId, e);
emitter.completeWithError(e);
emitters.remove(jobId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ public List<Review> findAllReviewByMissionId(Long missionId, Long cursorId, int
QReview r = QReview.review;
QMember member = QMember.member;
BooleanBuilder where = new BooleanBuilder()

.and(r.completedMission.mission.id.eq(missionId));
if (cursorId != null) {
where.and(sortType.equals(SortType.ASC) ? r.id.lt(cursorId) : r.id.gt(cursorId));
Expand Down