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 @@ -42,6 +42,13 @@ class ShareEntity extends BaseEntity {
@Column(name = "token_hash", nullable = false, unique = true, updatable = false, length = 64)
private String tokenHash;

/**
* 대상 구성의 SHA-256. 두 조인 테이블에 흩어진 대상을 shares 한 테이블에서 비교하려고
* 비정규화한 인덱스 키다(V31). 대상은 발급 후 바뀌지 않으므로 갱신하지 않는다.
*/
@Column(name = "target_hash", nullable = false, updatable = false, length = 64)
private String targetHash;

@Column(name = "status", nullable = false)
private String status;

Expand Down Expand Up @@ -69,7 +76,8 @@ class ShareEntity extends BaseEntity {
protected ShareEntity() {
}

ShareEntity(Share share) {
ShareEntity(Share share, String targetHash) {
this.targetHash = targetHash;
shareId = share.shareId();
userId = share.userId();
tokenHash = share.tokenHash();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ interface ShareJpaRepository extends JpaRepository<ShareEntity, UUID> {
nativeQuery = true)
void deleteSavedStyleLinks(@Param("savedStyleId") UUID savedStyleId);

/**
* 같은 대상의 활성 공유를 한 번에 폐기한다. 만료 여부는 보지 않는다 — 부분 유니크 인덱스가
* 상태만 보므로(V31) 만료된 ACTIVE 행을 남기면 다음 발급이 인덱스에 걸린다.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("""
UPDATE ShareEntity share
SET share.status = 'REVOKED', share.revokedAt = :revokedAt
WHERE share.userId = :userId
AND share.targetHash = :targetHash
AND share.status = 'ACTIVE'
""")
int revokeActiveWithSameTarget(
@Param("userId") UUID userId,
@Param("targetHash") String targetHash,
@Param("revokedAt") Instant revokedAt);

Optional<ShareEntity> findByTokenHash(String tokenHash);

Page<ShareEntity> findByUserId(UUID userId, Pageable pageable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.heddy.domain.sharing.model.ShareStatus;
import com.heddy.domain.sharing.port.out.ShareRepositoryPort;
import com.heddy.domain.sharing.port.out.SharedRecordLookupPort;
import com.heddy.domain.account.port.out.TokenHasherPort;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -24,10 +25,24 @@ public class SharingPersistenceAdapter implements ShareRepositoryPort, SharedRec
private static final int MAX_PAGE_SIZE = 100;

private final ShareJpaRepository shareRepository;
/** 토큰과 같은 SHA-256 을 대상 구성에도 쓴다. 해시 계산이 한 곳이어야 값이 갈리지 않는다. */
private final TokenHasherPort tokenHasherPort;

@Override
public Share insert(Share share) {
return shareRepository.saveAndFlush(new ShareEntity(share)).toDomain();
return shareRepository
.saveAndFlush(new ShareEntity(share, targetHash(share.targetKey())))
.toDomain();
}

@Override
public int revokeActiveWithSameTarget(UUID userId, String targetKey, Instant revokedAt) {
return shareRepository.revokeActiveWithSameTarget(
userId, targetHash(targetKey), revokedAt);
}

private String targetHash(String targetKey) {
return tokenHasherPort.hash(targetKey);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ public CreateShareUseCase.Result create(CreateShareUseCase.Command command) {
command.savedStyleIds(), command.fields(), command.expiresInDays(), Instant.now());
requireOwnedRecords(command.userId(), command.recordIds());
requireOwnedSavedStyles(command.userId(), command.savedStyleIds());
// 같은 대상을 다시 공유하는 것은 "이전 링크는 그만 쓰겠다" 는 뜻으로 본다. 토큰 원문을
// 저장하지 않아 기존 링크의 URL 을 다시 내려줄 수 없으므로, 재사용이 아니라 교체다.
shareRepositoryPort.revokeActiveWithSameTarget(
command.userId(), share.targetKey(), Instant.now());
Share saved = shareRepositoryPort.insert(share);
return new CreateShareUseCase.Result(saved, publicBaseUrl + "/" + rawToken);
}
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/heddy/domain/sharing/model/Share.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.time.Instant;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.UUID;

/**
Expand Down Expand Up @@ -83,6 +84,21 @@ public static Share create(
now.plusSeconds((long) days * 86_400), null, recordIds, fields, savedStyleIds, now);
}

/**
* 대상 구성의 정규형. 같은 기록·후보를 고른 두 공유는 고르는 순서와 무관하게 같은 문자열을
* 낸다. 해싱은 이 문자열을 받는 쪽의 일이고, 여기서는 "무엇이 같은 대상인가" 만 정한다.
*
* <p>정렬은 UUID 문자열 기준이다. V31 의 백필도 같은 기준을 쓴다 — 두 정렬이 어긋나면
* 같은 대상이 서로 다른 해시를 갖고 중복 제거가 통째로 무력해진다.
*/
public String targetKey() {
return join(recordIds) + "|" + join(savedStyleIds);
}

private static String join(Set<UUID> ids) {
return ids.stream().map(UUID::toString).sorted().collect(Collectors.joining(","));
}

/** 이미 읽어 온 행을 도메인으로 되돌릴 때 쓰는 재구성용 팩터리다. 불변식을 다시 통과한다. */
public static Share reconstitute(
UUID shareId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ public interface ShareRepositoryPort {
*/
Optional<Share> findByIdAndUserId(UUID shareId, UUID userId);

/**
* 같은 대상 구성을 가리키는 이 소유자의 활성 공유를 모두 폐기한다.
*
* <p>만료 여부를 보지 않는 것이 중요하다. 부분 유니크 인덱스의 조건이 상태뿐이라(V31)
* 만료됐지만 상태가 ACTIVE 인 행을 남겨 두면 다음 발급이 인덱스에 걸린다.
*
* @return 폐기된 공유 수
*/
int revokeActiveWithSameTarget(UUID userId, String targetKey, Instant revokedAt);

/** 토큰 해시 대조로 조회한다. 공개 조회(#51)의 유일한 진입 경로다. */
Optional<Share> findByTokenHash(String tokenHash);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
-- "한 사용자, 한 대상 구성, 활성 링크 1개" 를 DB 가 강제한다.
--
-- 지금까지는 공유 버튼을 누를 때마다 새 링크가 생기고 이전 링크가 그대로 살아 있었다. 살아
-- 있는 공개 URL 의 개수에 상한이 없어, 시술기록이 몇 건이든 활성 링크는 얼마든지 늘어난다.
--
-- 대상 구성은 share_records·share_saved_styles 두 조인 테이블에 흩어져 있어 유니크 제약을
-- 직접 걸 수 없다. 그래서 정렬된 식별자 목록의 SHA-256 을 target_hash 로 비정규화해 shares
-- 한 테이블 안에서 비교한다.
--
-- 부분 인덱스의 조건은 status = 'ACTIVE' 뿐이다. 만료는 expires_at 비교라 인덱스 조건에
-- now() 를 쓸 수 없다(불변 표현식이 아니다). 그래서 만료됐지만 상태가 ACTIVE 인 행도 인덱스
-- 대상이다 — 애플리케이션이 발급 직전에 같은 대상의 ACTIVE 행을 만료 여부와 무관하게 모두
-- 폐기하므로 충돌하지 않는다.
ALTER TABLE shares ADD COLUMN target_hash VARCHAR(64);

-- 백필. 정렬 키를 uuid 가 아니라 text 로 두는 이유는 애플리케이션이 UUID 문자열을 정렬해
-- 같은 문자열을 만들기 때문이다. 두 정렬 기준이 어긋나면 같은 대상이 다른 해시를 갖는다.
UPDATE shares s
SET target_hash = encode(sha256(convert_to(t.payload, 'UTF8')), 'hex')
FROM (
SELECT sh.share_id,
coalesce((SELECT string_agg(r.record_id::text, ',' ORDER BY r.record_id::text)
FROM share_records r WHERE r.share_id = sh.share_id), '')
|| '|'
|| coalesce((SELECT string_agg(ss.saved_style_id::text, ',' ORDER BY ss.saved_style_id::text)
FROM share_saved_styles ss WHERE ss.share_id = sh.share_id), '') AS payload
FROM shares sh
) t
WHERE s.share_id = t.share_id;

ALTER TABLE shares ALTER COLUMN target_hash SET NOT NULL;

-- 기존 중복 정리. 같은 대상을 가리키는 활성 링크 중 가장 최근 것만 남긴다. 사용자가 마지막에
-- 발급한 링크가 지금 쓰고 있는 링크이고, 그 이전 것들은 이미 대체된 것으로 본다.
UPDATE shares
SET status = 'REVOKED',
revoked_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE share_id IN (
SELECT share_id FROM (
SELECT share_id,
row_number() OVER (PARTITION BY user_id, target_hash
ORDER BY created_at DESC, share_id DESC) AS recency
FROM shares
WHERE status = 'ACTIVE'
) ranked
WHERE ranked.recency > 1
);

CREATE UNIQUE INDEX uq_shares_active_target
ON shares(user_id, target_hash)
WHERE status = 'ACTIVE';
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,8 @@ INSERT INTO treatment_records (
UUID shareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (
share_id, user_id, token_hash, status, expires_at
) VALUES (?, ?, ?, ?, ?)
share_id, user_id, token_hash, target_hash, status, expires_at
) VALUES (?, ?, ?, md5(random()::text), ?, ?)
""", shareId, USER_ID, sha256Quiet(token), status,
Timestamp.from(expiresAt));
jdbcTemplate.update("""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,123 @@ void answers404ForUnknownShareOnDetailPatchAndDelete() throws Exception {
.andExpect(status().isNotFound());
}

// ------------------------------------------------------- 대상당 활성 링크 1개

/**
* 같은 기록을 다시 공유하면 이전 링크는 닫힌다. 발급할 때마다 링크가 쌓이면 사용자가
* 닫은 줄 아는 URL 이 계속 열려 있게 되고, 살아있는 링크 수에 상한이 없어진다.
*/
@Test
void revokesThePreviousLinkWhenTheSameTargetIsSharedAgain() throws Exception {
UUID recordId = insertRecord(USER_ID);
String firstToken = tokenOf(createShare(createBody(recordId, 7)));

createShare(createBody(recordId, 7));

mockMvc.perform(get("/public/shares/" + firstToken))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.error.code").value("SHARE_REVOKED"));
Integer active = jdbcTemplate.queryForObject(
"SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'",
Integer.class, USER_ID);
assertThat(active).isEqualTo(1);
}

/** 대상이 다르면 공존한다. 막는 것은 같은 대상의 중복이지 링크 발급 자체가 아니다. */
@Test
void keepsLinksThatPointAtDifferentTargets() throws Exception {
UUID first = insertRecord(USER_ID);
UUID second = insertRecord(USER_ID);

createShare(createBody(first, 7));
createShare(createBody(second, 7));

Integer active = jdbcTemplate.queryForObject(
"SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'",
Integer.class, USER_ID);
assertThat(active).isEqualTo(2);
}

/**
* 기록 순서를 바꿔 보내도 같은 대상이다. 정규형이 순서를 지우지 못하면 같은 조합으로
* 링크가 무한히 쌓인다.
*/
@Test
void treatsTheSameRecordsInADifferentOrderAsOneTarget() throws Exception {
UUID first = insertRecord(USER_ID);
UUID second = insertRecord(USER_ID);
String body = """
{"record_ids":["%s","%s"],"fields":["PHOTOS"],"expires_in_days":7}
""";

createShare(body.formatted(first, second));
createShare(body.formatted(second, first));

Integer active = jdbcTemplate.queryForObject(
"SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'",
Integer.class, USER_ID);
assertThat(active).isEqualTo(1);
}

/**
* 만료됐지만 상태가 ACTIVE 인 행이 남아 있어도 다음 발급이 막히지 않아야 한다. 부분 유니크
* 인덱스는 만료를 보지 못하므로(now() 는 불변 표현식이 아니다) 폐기가 만료 여부와 무관하게
* 이루어져야 한다.
*/
@Test
void reissuesOverAnExpiredButStillActiveRow() throws Exception {
UUID recordId = insertRecord(USER_ID);
createShare(createBody(recordId, 7));
jdbcTemplate.update(
"UPDATE shares SET expires_at = now() - interval '1 day' WHERE user_id = ?",
USER_ID);

createShare(createBody(recordId, 7));

Integer active = jdbcTemplate.queryForObject(
"SELECT count(*) FROM shares WHERE user_id = ? AND status = 'ACTIVE'",
Integer.class, USER_ID);
assertThat(active).isEqualTo(1);
}

/** 남의 공유는 건드리지 않는다. 대상이 같아도 소유자가 다르면 별개의 링크다. */
@Test
void leavesAnotherOwnersLinkAloneEvenOnTheSameRecord() throws Exception {
UUID recordId = insertRecord(USER_ID);
UUID otherRecord = insertRecord(OTHER_USER_ID);
createShare(createBody(recordId, 7));
UUID otherShareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (
share_id, user_id, token_hash, target_hash, status, expires_at
) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', now() + interval '1 day')
""", otherShareId, OTHER_USER_ID, UUID.randomUUID().toString().replace("-", ""));
jdbcTemplate.update(
"INSERT INTO share_records (share_id, record_id) VALUES (?, ?)",
otherShareId, otherRecord);

createShare(createBody(recordId, 7));

String otherStatus = jdbcTemplate.queryForObject(
"SELECT status FROM shares WHERE share_id = ?", String.class, otherShareId);
assertThat(otherStatus).isEqualTo("ACTIVE");
}

private String createShare(String body) throws Exception {
return mockMvc.perform(post("/shares")
.with(authentication(userAuthentication(USER_ID)))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
}

private String tokenOf(String createResponse) throws Exception {
String shareUrl = new ObjectMapper().readTree(createResponse)
.path("data").path("share_url").asText();
return shareUrl.substring(shareUrl.lastIndexOf('/') + 1);
}

// ------------------------------------------------------------------ 헬퍼

private String createShare(UUID recordId) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,10 @@ private UUID saveCandidate(UUID userId, UUID hairstyleId, UUID colorId) throws E
private UUID insertShareCarrying(UUID savedStyleId) {
UUID shareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (share_id, user_id, token_hash, status, expires_at, created_at)
VALUES (?, ?, ?, 'ACTIVE', now() + interval '1 day', now())
INSERT INTO shares (
share_id, user_id, token_hash, target_hash, status, expires_at, created_at
) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE',
now() + interval '1 day', now())
""", shareId, USER_ID, "hash-" + shareId);
jdbcTemplate.update(
"INSERT INTO share_saved_styles (share_id, saved_style_id) VALUES (?, ?)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,9 @@ INSERT INTO treatment_records (record_id, user_id, service_types, performed_at)
private void insertActiveShare(UUID recordId) {
UUID shareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (share_id, user_id, token_hash, status, expires_at)
VALUES (?, ?, ?, 'ACTIVE', now() + interval '7 days')
INSERT INTO shares (
share_id, user_id, token_hash, target_hash, status, expires_at
) VALUES (?, ?, ?, md5(random()::text), 'ACTIVE', now() + interval '7 days')
""", shareId, USER_ID, UUID.randomUUID().toString().replace("-", ""));
jdbcTemplate.update(
"INSERT INTO share_records (share_id, record_id) VALUES (?, ?)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1025,8 +1025,9 @@ INSERT INTO analysis_jobs (
private void insertShare(UUID ownerId, UUID recordId, String status, Instant expiresAt) {
UUID shareId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO shares (share_id, user_id, token_hash, status, expires_at, revoked_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO shares (
share_id, user_id, token_hash, target_hash, status, expires_at, revoked_at
) VALUES (?, ?, ?, md5(random()::text), ?, ?, ?)
""", shareId, ownerId, UUID.randomUUID().toString().replace("-", ""),
status, Timestamp.from(expiresAt),
"REVOKED".equals(status) ? Timestamp.from(Instant.now()) : null);
Expand Down
Loading
Loading