Skip to content
Closed
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
@@ -0,0 +1,162 @@
package com.afternote.core.common.deeplink

import java.net.URI

/**
* 링크 문자열 하나를 [NavigationTarget] 으로 옮기는 단일 관문 (#924).
*
* 브라우저 App Link(`Intent.getData()`)와 FCM `data.link` 가 **같은 함수**를 지난다. 입력을
* `String` 으로 받는 이유가 그것이다 — 한쪽은 `android.net.Uri`, 다른 쪽은 payload 의 문자열이라
* 공통분모가 문자열뿐이고, 문자열로 받아야 이 모듈이 Android 플랫폼 타입에도 묶이지 않는다
* (그래서 이 파서의 테스트는 Robolectric 없이 도는 순수 JVM 테스트다).
*
* ## 계약
*
* | 항목 | 값 |
* |---|---|
* | scheme | `https` 만 |
* | host | `afternote.kro.kr` 만 (대소문자 무시, userinfo 금지, 포트는 생략 또는 443) |
* | path | 아래 표의 정규 경로만. 소문자·끝 슬래시 없음·퍼센트 인코딩 없음 |
* | query | 지원하는 키 없음 — 붙으면 거절 |
* | fragment | 지원 안 함 — 붙으면 거절 |
*
* | 경로 | 목적지 | ID 형식 | 관문 |
* |---|---|---|---|
* | `/` | [NavigationTarget.Home] | — | 로그인 |
* | `/afternote` | [NavigationTarget.AfternoteHome] | — | 로그인 + 지문 |
* | `/afternote/{id}` | [NavigationTarget.AfternoteDetail] | 양의 10진 정수 | 로그인 + 지문 |
* | `/timeletter/{id}` | [NavigationTarget.TimeLetterDetail] | 양의 10진 정수 | 로그인 |
* | `/mindrecord/daily-question` | [NavigationTarget.DailyQuestionCompose] | — | 로그인 |
* | `/settings/notification` | [NavigationTarget.NotificationSettings] | — | 로그인 |
*
* 수신자 경로의 서버 식별자·접근 관문은 #1951에서 확정한다. 현재 지원 목적지에 포함하지 않는다.
*
* canonical URL 은 `https://afternote.kro.kr` + 경로다([canonicalUrl]).
*
* ## 왜 이렇게까지 좁히나
*
* 링크는 **앱 밖에서 오는 입력**이다. 관대하게 받으면 그만큼이 공격면이다 — 커스텀 scheme 은
* Digital Asset Links 검증을 못 받아 아무 앱이나 선점할 수 있고, `https://evil@afternote.kro.kr/`
* 같은 userinfo 는 눈으로 우리 도메인처럼 보인다. 그래서 계약에 **명시된 것만** 통과시키고 나머지는
* 전부 [AppLinkResolution.Rejected] 다. 모르는 입력을 추측해서 여는 경로는 없다.
*/
object AfternoteAppLinkParser {
/** 검증된 App Link 의 유일한 scheme. */
const val CANONICAL_SCHEME: String = "https"

/** 검증된 App Link 의 유일한 host. `assetlinks.json` 이 이 도메인에 올라가 있다(Afternote-BE#228). */
const val CANONICAL_HOST: String = "afternote.kro.kr"

/** [target] 을 가리키는 정규 URL. `parse(canonicalUrl(t)) == Resolved(t)` 가 목적지 전량에서 성립한다. */
fun canonicalUrl(target: NavigationTarget): String = "$CANONICAL_SCHEME://$CANONICAL_HOST${target.canonicalPath}"

/**
* [rawLink] 를 목적지로 옮긴다. 계약 밖이면 사유와 안전한 기본 진입을 담은
* [AppLinkResolution.Rejected] 다 — 예외를 던지지 않으며 `null` 도 없다.
*/
fun parse(rawLink: String?): AppLinkResolution {
val trimmed = rawLink?.trim().orEmpty()
if (trimmed.isEmpty()) return reject(AppLinkRejectionReason.MALFORMED_URI)

val uri = runCatching { URI(trimmed) }.getOrNull() ?: return reject(AppLinkRejectionReason.MALFORMED_URI)

if (!CANONICAL_SCHEME.equals(uri.scheme, ignoreCase = true)) {
return reject(AppLinkRejectionReason.UNSUPPORTED_SCHEME)
}
if (!CANONICAL_HOST.equals(uri.host, ignoreCase = true) ||
uri.rawUserInfo != null ||
uri.port !in ACCEPTED_PORTS
) {
return reject(AppLinkRejectionReason.UNSUPPORTED_HOST)
}
if (uri.rawQuery != null) return reject(AppLinkRejectionReason.UNSUPPORTED_QUERY)
if (uri.rawFragment != null) return reject(AppLinkRejectionReason.UNSUPPORTED_FRAGMENT)

val segments =
uri.rawPath.orEmpty().toCanonicalSegments()
?: return reject(AppLinkRejectionReason.UNKNOWN_PATH)

return resolvePath(segments)
}

private fun resolvePath(segments: List<String>): AppLinkResolution =
when {
segments.isEmpty() -> {
AppLinkResolution.Resolved(NavigationTarget.Home)
}

segments == listOf(SEGMENT_AFTERNOTE) -> {
AppLinkResolution.Resolved(NavigationTarget.AfternoteHome)
}

segments.size == 2 && segments[0] == SEGMENT_AFTERNOTE -> {
segments[1].resolveWithId(NavigationTarget::AfternoteDetail)
}

segments.size == 2 && segments[0] == SEGMENT_TIMELETTER -> {
segments[1].resolveWithId(NavigationTarget::TimeLetterDetail)
}

segments == listOf(SEGMENT_MINDRECORD, SEGMENT_DAILY_QUESTION) -> {
AppLinkResolution.Resolved(NavigationTarget.DailyQuestionCompose)
}

segments == listOf(SEGMENT_SETTINGS, SEGMENT_NOTIFICATION) -> {
AppLinkResolution.Resolved(NavigationTarget.NotificationSettings)
}

else -> {
reject(AppLinkRejectionReason.UNKNOWN_PATH)
}
}

private fun String.resolveWithId(target: (Long) -> NavigationTarget): AppLinkResolution =
toResourceIdOrNull()
?.let { AppLinkResolution.Resolved(target(it)) }
?: reject(AppLinkRejectionReason.MALFORMED_ID)

/**
* 정규 경로 세그먼트 목록. 계약 밖 문자가 하나라도 있으면 `null` 이다.
*
* 빈 세그먼트를 버리지 않고 **거절**하는 것이 요점이다 — 버리면 `/afternote//1` 이나
* `/afternote/1/` 이 정규형과 같은 목적지가 되어 canonical URL 이 하나가 아니게 된다.
*/
private fun String.toCanonicalSegments(): List<String>? {
if (isEmpty() || this == "/") return emptyList()
if (!startsWith('/')) return null

val segments = drop(1).split('/')
return segments.takeIf { parsed -> parsed.all(SEGMENT_FORMAT::matches) }
}

/**
* 양의 10진 정수 ID. 앞자리 `0`·부호·공백·비-ASCII 숫자는 전부 거절이다.
*
* 자릿수 판정에 정규식 `\d` 를 쓰지 않는다 — Android 의 `\d` 는 유니코드 숫자(예: 아라비아-인도
* 숫자)까지 물어 서버 JVM 과 다르게 판정한다. `'0'..'9'` 로 직접 좁힌다.
*/
private fun String.toResourceIdOrNull(): Long? {
if (isEmpty() || length > MAX_ID_LENGTH) return null
if (any { character -> character !in '0'..'9' }) return null
if (length > 1 && first() == '0') return null
return toLongOrNull()?.takeIf { id -> id > 0L }
}

private fun reject(reason: AppLinkRejectionReason): AppLinkResolution = AppLinkResolution.Rejected(reason)

/** 생략(`-1`)이거나 https 기본 포트만 허용한다. 다른 포트는 우리 서비스가 서지 않는다. */
private val ACCEPTED_PORTS = setOf(-1, 443)

/** 정규 경로 세그먼트는 소문자·숫자·하이픈뿐이다. 대문자·퍼센트 인코딩·점은 정규형이 아니다. */
private val SEGMENT_FORMAT = Regex("[a-z0-9-]+")

/** `Long.MAX_VALUE` 의 자릿수. 넘으면 `toLongOrNull` 이전에 잘라 낸다. */
private const val MAX_ID_LENGTH = 19

private const val SEGMENT_AFTERNOTE = "afternote"
private const val SEGMENT_TIMELETTER = "timeletter"
private const val SEGMENT_MINDRECORD = "mindrecord"
private const val SEGMENT_DAILY_QUESTION = "daily-question"
private const val SEGMENT_SETTINGS = "settings"
private const val SEGMENT_NOTIFICATION = "notification"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.afternote.core.common.deeplink

/**
* 링크를 목적지로 못 옮긴 이유 (#924).
*
* fail-closed 의 «관측 가능한» 쪽을 담당한다. 거절은 조용히 홈으로 보내는 것으로 끝나면 안 된다 —
* 서버가 새 경로를 먼저 배포했는지, ID 형식을 잘못 만들었는지, 남이 우리 도메인을 흉내 냈는지는
* 이유가 남아야 갈린다.
*
* [reportValue] 는 리포팅 속성에 싣는 고정 문자열이다. enum 이름을 바꿔도 이 값은 유지해야 한다 —
* 대시보드 집계가 옛 값으로 쌓여 있다.
*
* **원본 링크 자체는 절대 싣지 않는다.** 경로에 애프터노트·타임레터 ID 가 들어 있어 그대로 로그에
* 남기면 식별 가능한 사용자 자료가 리포팅으로 새 나간다. 관측에 쓰는 값은 이 이유뿐이다.
*/
enum class AppLinkRejectionReason(
val reportValue: String,
) {
/** 비어 있거나 URI 로 파싱조차 되지 않는다. */
MALFORMED_URI("malformed_uri"),

/** `https` 가 아니다. 커스텀 scheme 은 Digital Asset Links 검증을 못 받아 아무 앱이나 가로챌 수 있다. */
UNSUPPORTED_SCHEME("unsupported_scheme"),

/** 우리 도메인이 아니거나, 호스트 앞에 userinfo 가 붙어 도메인을 흉내 냈다. */
UNSUPPORTED_HOST("unsupported_host"),

/** 계약 표에 없는 경로다. 끝 슬래시·빈 세그먼트·퍼센트 인코딩 변형도 여기로 온다. */
UNKNOWN_PATH("unknown_path"),

/** 경로 모양은 계약에 있는데 ID 가 형식을 벗어났다. */
MALFORMED_ID("malformed_id"),

/** 아직 지원하는 query 가 없다. 하나라도 붙어 오면 거절이다. */
UNSUPPORTED_QUERY("unsupported_query"),

/** fragment 는 목적지를 바꾸지 않는데 붙어 왔다 — 계약 밖 입력이다. */
UNSUPPORTED_FRAGMENT("unsupported_fragment"),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.afternote.core.common.deeplink

/**
* 링크 한 건을 해석한 결과 (#924).
*
* `null` 을 돌려주고 소비처가 폴백을 고르게 두지 않는다. 그렇게 두면 호출부마다 폴백이 갈리고,
* 「왜 홈으로 왔는지」가 아무 데도 남지 않는다. 거절도 **값**이라 [Rejected] 가 안전한 기본 진입과
* 사유를 함께 들고 온다 — 소비처는 `when` 두 갈래로 끝내면 되고, 사유를 버릴지 리포팅할지만 고른다.
*/
sealed interface AppLinkResolution {
/** 소비처가 실제로 이동할 목적지. 거절이어도 [Rejected.fallback] 이 있어 항상 값이 있다. */
val target: NavigationTarget

/** 계약 표의 한 행으로 해석됐다. */
data class Resolved(
override val target: NavigationTarget,
) : AppLinkResolution

/**
* 계약 밖이라 거절했다. [fallback] 으로 보내되 [reason] 을 관측에 남긴다.
*
* [fallback] 은 [NavigationTarget.Home] 이라 여전히 로그인 관문을 지난다 — 거절이 관문을
* 건너뛰는 우회로가 되지 않는다.
*/
data class Rejected(
val reason: AppLinkRejectionReason,
val fallback: NavigationTarget = NavigationTarget.Home,
) : AppLinkResolution {
override val target: NavigationTarget get() = fallback
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.afternote.core.common.deeplink

/**
* 목적지에 들어가기 전에 통과해야 하는 인증 관문 (#924).
*
* 순서가 의미를 갖는다 — `ordinal` 이 낮을수록 먼저 통과해야 하는 관문이다. 작성자 지문 인증은 로그인 뒤에 확인하므로 [LOGIN] 이 항상 맨 앞이고, 그 불변식은
* `NavigationTargetContractTest` 가 목적지 전량에 대해 잠근다.
*
* 관문을 **판정**하는 것과 관문을 **띄우는** 것은 다른 일이다. 이 enum 과
* [NavigationTarget.requiredGates] 는 앞엣것만 소유한다 — 어떤 화면으로 관문을 띄우고 통과 후
* 어떻게 원래 목적지를 재개할지는 앱 루트가 갖는다.
*/
enum class AuthGate {
/** 앱 계정 로그인. 모든 목적지의 최초 관문이다. */
LOGIN,

/** 지문(생체) 인증. 작성자 본인의 애프터노트 열람 경로에 걸린다. */
BIOMETRIC,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.afternote.core.common.deeplink

/**
* App Link·FCM `data.link` 가 가리키는 **엔진 중립** 목적지 (#924).
*
* Nav2 `Route` 도 Navigation 3 `NavKey` 도 아니다. 링크 계약은 서버·브라우저·알림이 공유하는 외부
* 계약이라 앱 안의 navigation 엔진 교체(#1698·#1702)와 수명이 다르다 — 둘을 같은 타입으로 묶으면
* 엔진을 갈아탈 때 외부 계약이 함께 흔들린다. 그래서 이 모듈은 화면 그래프를 알지 않고, 실제
* 화면 매핑은 앱 루트의 navigator adapter 가 갖는다(`NotificationDestination` 과 같은 이유·같은 층).
*
* [canonicalPath] 는 이 목적지를 가리키는 **유일한** 정규 경로다. 파서는 정규형만 받는다 —
* 끝 슬래시·대문자·퍼센트 인코딩 변형은 전부 거절이다([AfternoteAppLinkParser]).
*
* @property canonicalPath `/` 로 시작하고 끝 슬래시가 없는 정규 경로.
* @property requiredGates 진입 전 통과해야 하는 관문. [AuthGate] 순서대로 오름차순이며 비어 있지 않다.
*/
sealed interface NavigationTarget {
val canonicalPath: String
val requiredGates: List<AuthGate>

/** 링크가 사이트 루트를 가리킬 때의 목적지이자, 거절된 링크의 안전한 기본 진입([AppLinkResolution.Rejected.fallback]). */
data object Home : NavigationTarget {
override val canonicalPath: String = "/"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN)
}

/** 작성자 애프터노트 홈. */
data object AfternoteHome : NavigationTarget {
override val canonicalPath: String = "/afternote"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC)
}

/** 작성자 애프터노트 상세. */
data class AfternoteDetail(
val afternoteId: Long,
) : NavigationTarget {
override val canonicalPath: String = "/afternote/$afternoteId"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN, AuthGate.BIOMETRIC)
}

/** 타임레터 상세. */
data class TimeLetterDetail(
val timeLetterId: Long,
) : NavigationTarget {
override val canonicalPath: String = "/timeletter/$timeLetterId"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN)
}

/** 데일리질문 작성. */
data object DailyQuestionCompose : NavigationTarget {
override val canonicalPath: String = "/mindrecord/daily-question"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN)
}

/** 알림 설정. */
data object NotificationSettings : NavigationTarget {
override val canonicalPath: String = "/settings/notification"
override val requiredGates: List<AuthGate> = listOf(AuthGate.LOGIN)
}
}
Loading
Loading