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 @@ -4,12 +4,18 @@ package com.example.slowclock.data
object FirestoreCollections {
const val USERS = "users"

/** 이름·공유 코드만 담는 공개 프로필. `users` 는 본인만 읽는다. */
/** 이름만 담는 공개 프로필. `users` 는 본인만 읽는다. */
const val PUBLIC_PROFILES = "publicProfiles"
const val SCHEDULES = "schedules"
const val NOTIFICATIONS = "notifications"
const val SCHEDULE_RECOMMENDATIONS = "scheduleRecommendations"
const val FAMILY_GROUPS = "familyGroups"

/**
* 공유 코드 등록부. 코드 자체가 문서 이름이라 만들기 한 번으로 중복이 걸러진다.
* 아무도 읽지 못한다 — 코드 목록은 그 자체가 사람을 찾는 열쇠 꾸러미다(#174).
*/
const val SHARE_CODES = "shareCodes"
const val SHARE_CODE_WATCHERS = "shareCodeWatchers"
const val SHARE_CODE_WATCHER_TOKENS = "tokens"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import com.example.slowclock.data.model.User
import com.google.firebase.Timestamp
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.FirebaseFirestoreException
import com.google.firebase.firestore.SetOptions
import com.google.firebase.firestore.toObject
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.tasks.await
import javax.inject.Inject

Expand All @@ -26,25 +31,30 @@ class UserRepository
) {
private val usersCollection = firestore.collection(FirestoreCollections.USERS)
private val publicProfilesCollection = firestore.collection(FirestoreCollections.PUBLIC_PROFILES)
private val shareCodesCollection = firestore.collection(FirestoreCollections.SHARE_CODES)

// 6-character code generator
private suspend fun generateUniqueShareCode(): String {
/**
* 아직 임자가 없는 여섯 자리 코드를 하나 만들어 [uid] 앞으로 등록한다.
*
* 등록부는 코드 자체가 문서 이름이라, 이미 있는 코드에 대한 만들기는 규칙이 거절한다.
* 확인과 저장이 한 번으로 합쳐지므로 그 사이에 같은 코드를 두 사람이 가져가는 틈이 없다.
* 질의로 확인하던 앞의 방식은 컬렉션을 훑을 권한을 함께 열어야 했다(#174).
*/
private suspend fun claimShareCode(uid: String): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
while (true) {
val code =
(1..6)
.map { chars.random() }
.joinToString("")
// Check uniqueness in Firestore
val exists =
publicProfilesCollection
.whereEqualTo("shareCode", code)
.get()
.await()
.documents
.isNotEmpty()
if (!exists) return code
repeat(SHARE_CODE_CLAIM_ATTEMPTS) {
val code = (1..SHARE_CODE_LENGTH).map { chars.random() }.joinToString("")
try {
shareCodesCollection.document(code).set(mapOf("userId" to uid)).await()
return code
} catch (e: FirebaseFirestoreException) {
// 임자가 있는 코드였다. 그 밖의 실패는 다음 코드로 넘어가도 똑같이 실패하므로
// 여기서 던져 부르는 쪽이 안내로 바꾸게 한다.
if (e.code != FirebaseFirestoreException.Code.PERMISSION_DENIED) throw e
Log.d(TAG, "이미 쓰이는 공유 코드다. 다시 만든다")
}
}
error("공유 코드를 ${SHARE_CODE_CLAIM_ATTEMPTS}번 만들어 봤지만 모두 임자가 있었다")
}

// 현재 로그인한 사용자 정보 가져오기
Expand All @@ -61,6 +71,18 @@ class UserRepository
// 계정 삭제용: 사용자 문서 삭제
suspend fun deleteUserDocument(userId: String): Boolean =
try {
// 공유 코드 등록부는 본인만 지울 수 있다. 사용자 문서를 먼저 지우면 코드를 알
// 길이 없어져 등록부에 영영 남고, 그 코드는 아무도 다시 쓰지 못한다(#174).
val shareCode =
usersCollection
.document(userId)
.get()
.await()
.getString("shareCode")
.orEmpty()
if (shareCode.isNotBlank()) {
shareCodesCollection.document(shareCode).delete().await()
}
usersCollection.document(userId).delete().await()
publicProfilesCollection.document(userId).delete().await()
true
Expand All @@ -87,12 +109,12 @@ class UserRepository
id = uid,
name = name,
email = email,
shareCode = generateUniqueShareCode(),
shareCode = claimShareCode(uid),
createdAt = existing?.createdAt ?: Timestamp.now(),
updatedAt = Timestamp.now(),
)
usersCollection.document(uid).set(newUser).await()
savePublicProfile(newUser.id, newUser.name, newUser.shareCode)
savePublicProfile(newUser.id, newUser.name)
} else {
val updates = mutableMapOf<String, Any>()
if (existing.name != name && name.isNotBlank()) updates["name"] = name
Expand All @@ -102,7 +124,7 @@ class UserRepository
usersCollection.document(uid).update(updates).await()
}
// 공개 프로필이 아직 없거나 이름이 바뀐 경우를 함께 맞춘다.
savePublicProfile(uid, if (name.isNotBlank()) name else existing.name, existing.shareCode)
savePublicProfile(uid, if (name.isNotBlank()) name else existing.name)
}
true
} catch (e: Exception) {
Expand All @@ -114,41 +136,52 @@ class UserRepository
}

/**
* 공개 프로필을 만들거나 갱신한다. 이름·공유 코드만 담으므로 다른 사용자가 읽어도 된다.
* 공개 프로필을 만들거나 갱신한다. 이름만 담으므로 다른 사용자가 읽어도 된다.
* 실패해도 사용자 문서 저장 결과를 뒤집지 않는다. 다음 로그인에서 다시 맞춘다.
*/
private suspend fun savePublicProfile(
uid: String,
name: String,
shareCode: String,
) {
if (shareCode.isBlank()) return
try {
publicProfilesCollection
.document(uid)
.set(PublicProfile(id = uid, name = name, shareCode = shareCode))
.set(PublicProfile(id = uid, name = name))
.await()
} catch (e: Exception) {
Log.w(TAG, "공개 프로필 저장 실패", e)
}
}

/** 사용자 ID → 이름. Firestore `whereIn` 은 10개까지라 나눠 조회한다. */
/**
* 사용자 ID → 이름.
*
* 문서를 하나씩 읽는다. 공개 프로필은 목록 조회를 막아 두었다 — 목록이 열려 있으면 문서
* 이름을 몰라도 컬렉션을 통째로 훑을 수 있기 때문이다. 여기 들어오는 uid 는 이미 읽을 수
* 있는 공유 일정에서 나온 값이라 하나씩 읽는 것으로 충분하다(#174).
*/
suspend fun getUserNames(userIds: List<String>): Map<String, String> {
if (userIds.isEmpty()) return emptyMap()
return try {
val result = mutableMapOf<String, String>()
userIds.distinct().chunked(FIRESTORE_WHERE_IN_LIMIT).forEach { chunk ->
publicProfilesCollection
.whereIn("id", chunk)
.get()
.await()
.documents
.mapNotNull { it.toObject<PublicProfile>() }
.forEach { result[it.id] = it.name }
coroutineScope {
userIds
.distinct()
.map { id ->
async {
id to
publicProfilesCollection
.document(id)
.get()
.await()
.getString("name")
.orEmpty()
}
}.awaitAll()
.filter { (_, name) -> name.isNotBlank() }
.toMap()
}
result
} catch (e: Exception) {
Log.w(TAG, "공유 일정 소유자 이름 조회 실패", e)
emptyMap()
}
}
Expand All @@ -166,12 +199,22 @@ class UserRepository
}
}

/** 공유 코드 감시자로 현재 기기 토큰을 등록한다. 공유 일정 변경 알림을 받기 위한 것이다. */
/**
* 공유 코드 감시자로 이 기기를 등록한다.
*
* 이 문서가 있다는 것이 곧 「그 코드를 받은 사람」 이라는 표시라, 공유 일정을 읽을 권한의
* 근거가 된다. 그래서 FCM 토큰을 못 받아도 등록 자체는 한다. 토큰이 없으면 알림만 못 받고,
* 등록을 거르면 가족 일정이 통째로 안 보인다(#174).
*/
suspend fun registerShareCodeWatcher(shareCode: String): Boolean {
val uid = auth.currentUser?.uid ?: return false
return try {
val token = messaging.token.await()
shareCodeWatcherTokens(shareCode).document(uid).set(mapOf("fcmToken" to token)).await()
val token = runCatching { messaging.token.await() }.getOrNull()
if (token == null) Log.w(TAG, "FCM 토큰 없이 감시자를 등록한다. 알림은 다음 등록에서 붙는다")
val fields = mutableMapOf<String, Any>("userId" to uid)
token?.let { fields["fcmToken"] = it }
// merge 로 쓴다. 토큰을 못 받은 등록이 앞서 저장해 둔 토큰을 지우면 안 된다.
shareCodeWatcherTokens(shareCode).document(uid).set(fields, SetOptions.merge()).await()
true
} catch (e: Exception) {
Log.e(TAG, "공유 코드 감시자 등록 실패", e)
Expand All @@ -197,6 +240,7 @@ class UserRepository
.collection(FirestoreCollections.SHARE_CODE_WATCHER_TOKENS)

private companion object {
const val FIRESTORE_WHERE_IN_LIMIT = 10
const val SHARE_CODE_LENGTH = 6
const val SHARE_CODE_CLAIM_ATTEMPTS = 8
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package com.example.slowclock.data.model

/**
* 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주고 공유 코드로 사람을
* 찾는 데 쓴다.
* 다른 사용자에게 보여도 되는 값만 담는다. 공유 일정의 소유자 이름을 보여 주는 데 쓴다.
*
* `users` 문서에는 이메일과 FCM 토큰이 함께 있어 통째로 열 수 없다. Firestore 는 필드 단위 읽기
* 제한이 없으므로 공개할 값만 따로 둔다.
*
* 공유 코드는 여기에 두지 않는다. 사람을 찾는 열쇠라 이름 옆에 둘 값이 아니고, 코드를 문서 이름으로
* 삼는 등록부(`shareCodes`)가 그 자리를 대신한다(#174).
*/
data class PublicProfile(
val id: String = "",
val name: String = "",
val shareCode: String = "",
)
19 changes: 16 additions & 3 deletions docs/firestore.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
| 경로 | 담는 값 | 읽기 | 쓰기 |
|---|---|---|---|
| `users/{uid}` | 이름, 이메일, 공유 코드, FCM 토큰, 생성·수정 시각 | 본인만 | 본인만 |
| `publicProfiles/{uid}` | id, 이름, 공유 코드 | 로그인한 사용자 전체 | 본인만, 이 세 필드만 |
| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 `sharedCode` 가 있으면 로그인 사용자 | 소유자. 공유 일정은 `completed`·`updatedAt` 만 |
| `publicProfiles/{uid}` | id, 이름 | 로그인한 사용자가 문서 하나씩(`get`). 목록 조회(`list`)는 아무도 못 함 | 본인만, 이 두 필드만 |
| `shareCodes/{code}` | 그 코드의 임자 uid | 아무도 못 함 | 만들기는 본인 이름으로 한 번만(이미 있으면 거절), 지우기는 임자만 |
| `schedules/{id}` | 일정 제목·설명·시각·완료 여부·소유자 uid·공유 코드 | 소유자, 그리고 그 `sharedCode` 의 감시자로 등록한 사람 | 소유자. 공유 일정은 감시자가 `completed`·`completedDates`·`updatedAt` 만 |
| `notifications/{id}` | 알림 기록(소유자 uid) | 소유자 | 소유자 |
| `shareCodeWatchers/{code}/tokens/{uid}` | 그 공유 코드를 보는 기기의 FCM 토큰 | 본인 문서만 | 본인 문서만 |
| `scheduleRecommendations/{id}` | 유형별 추천 일정 | 로그인한 사용자 전체 | 아무도 못 함(콘솔로만) |
Expand All @@ -19,7 +20,17 @@

Firestore 에는 필드 단위 읽기 제한이 없다. 문서 하나를 읽게 허용하면 그 문서의 모든 필드가 함께 나간다. 그래서 이름 하나를 보여 주려고 `users` 를 열면 이메일과 FCM 토큰도 같이 열린다. 실제로 그런 규칙이 배포돼 있었고 #95 에서 이 구조로 바꿨다.

`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name`·`shareCode` 로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다.
`publicProfiles` 는 쓰기 규칙이 필드 목록을 `id`·`name` 으로 묶는다. 필드를 늘리려면 규칙과 테스트를 함께 고쳐야 한다. 그 마찰이 의도한 것이다.

## 공유 범위를 무엇으로 정하는가

「로그인했는가」 는 범위가 아니다. Google 계정은 누구나 몇 초 만에 새로 만들 수 있어, 로그인만 조건으로 두면 그 컬렉션은 사실상 공개다. 그리고 읽기 권한을 문서 하나 단위로만 생각하면 부족하다. `get` 을 열면 「문서 이름을 아는 사람」 이 읽지만, `list` 까지 열면 「아무것도 모르는 사람」 이 컬렉션을 통째로 가져간다.

그래서 이 저장소는 세 가지를 지킨다(#174).

1. 사람을 찾는 열쇠(공유 코드)는 훑을 수 있는 자리에 두지 않는다. `shareCodes` 는 코드가 문서 이름이고 읽기를 아무에게도 열지 않는다. 중복 확인은 만들기 한 번으로 끝나므로, 확인과 저장 사이에 같은 코드를 두 사람이 가져가는 틈도 없다.
2. 공유 일정은 「공유로 표시됐는가」 가 아니라 「그 코드의 감시자로 등록한 사람인가」 로 연다. 등록 문서(`shareCodeWatchers/{code}/tokens/{uid}`)가 그 관계의 증거다. 이 문서는 읽기 권한의 근거이기도 해서, FCM 토큰을 못 받아도 등록 자체는 한다.
3. 읽기보다 쓰기를 더 좁게 본다. 남이 완료 표시를 뒤집을 수 있으면 어르신이 약을 이미 먹은 줄로 안다. 이 앱에서는 그쪽이 더 아픈 피해다.

## 서버가 관리자 권한으로 읽는 곳

Expand Down Expand Up @@ -50,6 +61,8 @@ firebase deploy --only firestore:indexes

배포 전에 `npm test` 로 규칙 테스트를 돌린다. 규칙을 좁히는 배포는 앱의 읽기 경로를 끊을 수 있어, 배포 뒤 기기에서 로그인·일정 조회·공유 코드 입력까지 확인한다.

공유 읽기 규칙은 문서마다 감시자 등록을 확인한다. 규칙의 문서 접근 횟수에는 한도가 있으므로, 공유 일정이 늘어도 목록 조회가 막히지 않는지 규칙 테스트가 실제 건수로 재 둔다.

## Play 데이터 보안 선언과의 관계

[`play-release.md`](play-release.md) 의 데이터 보안 표가 수집·공유 항목의 정본이다. 컬렉션을 늘리거나 담는 값을 바꾸면 그 표와 [`privacy.html`](privacy.html) 을 함께 고친다. 규칙이 선언보다 넓으면 선언이 거짓이 된다.
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@ class MainViewModel
if (uid == null || shareCode.isNullOrBlank()) {
flowOf(emptyList())
} else {
// 감시자 등록이 곧 공유 일정을 읽을 권한이다. 코드는 저장됐는데 등록만
// 실패한 채로 남아 있으면 가족 일정이 영영 비어 보이므로 구독 전에 다시
// 세운다. 같은 문서에 다시 쓰는 것이라 값은 그대로고, 그 사이 바뀐 FCM
// 토큰이 함께 갱신된다(#174).
val registered = userRepository.registerShareCodeWatcher(shareCode)
if (!registered) Log.w(TAG, "감시자 등록에 실패해 공유 일정을 못 읽을 수 있다")
scheduleRepository.observeSchedulesBySharedCode(shareCode).catch { e ->
Log.e(TAG, "공유 일정 구독 실패", e)
emit(emptyList())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
Expand Down Expand Up @@ -67,5 +68,15 @@ internal fun ShareCodeContent(
) {
Text(if (state.isSaving) "저장 중..." else "저장하고 돌아가기")
}
// 등록이 곧 읽기 권한이라, 실패를 알리지 않으면 가족 일정이 왜 비어 있는지 알 수 없다(#174).
if (state.saveError != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = state.saveError,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,17 @@ sealed interface ShareCodeIntent : MviIntent {
data object ConsumeSaved : ShareCodeIntent
}

/** [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다. */
/**
* [isSaved] 는 일회성 신호다. 화면이 소비하고 [ShareCodeIntent.ConsumeSaved] 로 되돌린다.
*
* [saveError] 는 신호가 아니라 화면에 남는 값이다. 잠깐 떴다 사라지는 안내로는 이 화면에서 할
* 일을 알 수 없다 — 저장이 안 됐다는 사실과 다시 눌러야 한다는 것이 계속 보여야 한다.
*/
data class ShareCodeUiState(
val input: String = "",
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val saveError: String? = null,
) : UiState {
val canSave: Boolean get() = input.isNotBlank() && !isSaving
}
Expand All @@ -33,4 +39,6 @@ sealed interface ShareCodeReducerEvent : ReducerEvent {
data object Saved : ShareCodeReducerEvent

data object SavedConsumed : ShareCodeReducerEvent

data object SaveFailed : ShareCodeReducerEvent
}
Loading
Loading