Repository navigation
Expand file tree
/
Copy pathfirestore.rules
More file actions
215 lines (194 loc) · 9.62 KB
/
Copy pathfirestore.rules
File metadata and controls
215 lines (194 loc) · 9.62 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
rules_version = '2';
// ===============================================================
// 가정한 데이터 모델
// ===============================================================
//
// 컬렉션: users
// 문서 ID: Firebase Auth uid
// 클라이언트 소유 필드(본인만 읽기·쓰기)
// - repositories: list<map{fullName, url, branch?}> (선택, 최대 5개)
// 플래시카드 생성 대상 저장소. Free 1개, Pro 5개
// - onboardingCompleted: bool (선택) 온보딩 완료 표시
// - onboardingSkipped: bool (선택) 온보딩 건너뛰기 표시
// - githubToken: string (선택, 최대 500자) GitHub OAuth 액세스 토큰
// - pushEnabled: bool (선택) 푸시 알림 사용 여부
// - fcmToken: string (선택, 최대 1024자) FCM 등록 토큰
// - preferredPushHour: int (선택, 0~23) 푸시 희망 시
// - preferredPushTimezone: string (선택, 최대 64자) IANA 타임존
// - timezone: string (선택, 최대 64자) 앱이 열린 기기의 IANA 타임존
// - language: "ko" | "en" (선택) 앱 표시 언어
// timezone과 language는 서버가 오늘의 플래시카드를 미리 만들 날짜와 언어를 정할 때 씀
// - updatedAt: string (선택, 최대 64자) ISO 8601 갱신 시각
// 서버 전용 필드(Admin SDK만 쓰기, 본인 읽기만 허용)
// - subscriptionTier: "free" | "pro" (Stripe 웹훅)
// - subscriptionPeriodEnd: string | null (Stripe 웹훅)
// - stripeCustomerId: string (Stripe 웹훅)
//
// 컬렉션: regenerateCounts
// 문서 ID: Firebase Auth uid
// - date / count / updatedAt: 로그인 사용자의 일일 질문 재생성 사용량
// (Admin SDK만 쓰기, 본인 읽기만 허용)
//
// 컬렉션: users/{uid}/flashcards
// 문서 ID: YYYY-MM-DD
// - data_ko / data_en: list<map> 언어별 카드 덱
//
// 컬렉션: deletedUsers
// 문서 ID: 탈퇴한 사용자 uid
// - deletedAt / email / githubUsername
//
// 컬렉션: meta, demoFlashcards
// - 랜딩 데모용 공개 읽기 전용 캐시. 쓰기는 Admin SDK만 수행
//
// 컬렉션: notices
// - 공지사항. 쓰기는 Firebase Console에서만 수행
//
// ===============================================================
service cloud.firestore {
match /databases/{database}/documents {
// ===========================================================
// 헬퍼
// ===========================================================
// 로그인 여부
function isSignedIn() {
return request.auth != null;
}
// 본인 문서 여부
function isOwner(userId) {
return isSignedIn() && request.auth.uid == userId;
}
// 클라이언트가 직접 쓸 수 있는 users 필드 목록
// 여기에 없는 필드는 Admin SDK 경로(Stripe 웹훅, Cloud Functions)만 쓴다
function userWritableFields() {
return [
'repositories',
'onboardingCompleted',
'onboardingSkipped',
'githubToken',
'pushEnabled',
'fcmToken',
'preferredPushHour',
'preferredPushTimezone',
'timezone',
'language',
'updatedAt'
];
}
// repositories 원소 하나의 형태 검증
function isValidRepository(repo) {
return repo is map
&& repo.keys().hasOnly(['fullName', 'url', 'branch'])
&& repo.keys().hasAll(['fullName', 'url'])
&& repo.fullName is string
&& repo.fullName.size() > 0 && repo.fullName.size() <= 200
&& repo.url is string
&& repo.url.size() <= 500 && repo.url.matches('^https://.*')
&& (!('branch' in repo)
|| (repo.branch is string && repo.branch.size() <= 255));
}
// repositories 목록 검증
// Firestore 규칙에는 반복문이 없지만 상한이 5개라 인덱스로 전부 확인할 수 있다.
// 개수만 제한하면 원소 하나에 문서 상한만큼의 문자열을 넣을 수 있어 원소까지 본다
function isValidRepositories(repos) {
return repos is list
&& repos.size() <= 5
&& (repos.size() < 1 || isValidRepository(repos[0]))
&& (repos.size() < 2 || isValidRepository(repos[1]))
&& (repos.size() < 3 || isValidRepository(repos[2]))
&& (repos.size() < 4 || isValidRepository(repos[3]))
&& (repos.size() < 5 || isValidRepository(repos[4]));
}
// users 문서의 클라이언트 소유 필드 타입과 크기 검증
//
// `changed`는 이번 쓰기가 실제로 건드리는 키 집합이고, 그 키만 검사한다. 문서에
// 이미 들어 있는 값까지 검사하면 과거 버전이 남긴 형태 하나 때문에 그 사용자의
// 모든 쓰기가 막힌다. 새로 쓰는 값만 보면 기존 문서는 그대로 두면서 앞으로 들어오는
// 값은 전부 규격을 지키게 된다.
//
// 서버 전용 필드는 여기서 다루지 않는다. userWritableFields의 hasOnly 검사로 이미
// 거부되기 때문이다.
function isValidUserData(data, changed) {
return (!changed.hasAny(['repositories'])
|| isValidRepositories(data.repositories))
&& (!changed.hasAny(['onboardingCompleted']) || data.onboardingCompleted is bool)
&& (!changed.hasAny(['onboardingSkipped']) || data.onboardingSkipped is bool)
&& (!changed.hasAny(['githubToken'])
|| (data.githubToken is string && data.githubToken.size() <= 500))
&& (!changed.hasAny(['pushEnabled']) || data.pushEnabled is bool)
&& (!changed.hasAny(['fcmToken'])
|| (data.fcmToken is string && data.fcmToken.size() <= 1024))
// 정수로만 좁히지 않는 이유는 배포된 번들이 브라우저에 캐시돼 남고, 과거 번들이
// 실수를 쓰더라도 서버가 "현재 시"와 비교할 때 맞지 않아 넘어갈 뿐이기 때문이다
&& (!changed.hasAny(['preferredPushHour'])
|| (data.preferredPushHour is number
&& data.preferredPushHour >= 0
&& data.preferredPushHour <= 23))
&& (!changed.hasAny(['preferredPushTimezone'])
|| (data.preferredPushTimezone is string
&& data.preferredPushTimezone.size() <= 64))
&& (!changed.hasAny(['timezone'])
|| (data.timezone is string && data.timezone.size() <= 64))
&& (!changed.hasAny(['language']) || data.language in ['ko', 'en'])
// 문자열과 timestamp를 모두 받는 이유는 과거 번들이 Date 객체를 그대로 쓰고,
// PWA 서비스 워커 캐시 때문에 그 번들이 배포 뒤에도 한동안 남기 때문이다
&& (!changed.hasAny(['updatedAt'])
|| data.updatedAt is timestamp
|| (data.updatedAt is string && data.updatedAt.size() <= 64));
}
// ===========================================================
// 컬렉션 규칙
// ===========================================================
// 사용자 컬렉션: 본인만 읽고 쓰되, 쓰기는 클라이언트 소유 필드로 한정
// 등급(subscriptionTier)은 서버가 한도를 고를 때 쓰는 값이라 Stripe 웹훅만 쓴다
match /users/{userId} {
allow read: if isOwner(userId);
allow create: if isOwner(userId)
&& request.resource.data.keys().hasOnly(userWritableFields())
&& isValidUserData(request.resource.data, request.resource.data.keys());
// 변경된 키만 검사해 Admin SDK가 넣어 둔 서버 전용 필드는 그대로 두고
// 클라이언트가 그 값을 건드리는 것만 거부한다
allow update: if isOwner(userId)
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(userWritableFields())
&& isValidUserData(
request.resource.data,
request.resource.data.diff(resource.data).affectedKeys()
);
// 회원 탈퇴에서 본인 문서 삭제
allow delete: if isOwner(userId);
// 플래시카드 서브컬렉션
match /flashcards/{date} {
allow read, write: if isOwner(userId);
}
}
// 질문 재생성 사용량 카운터
// 남은 횟수 표시에 필요해 본인 읽기만 열고, 쓰기는 규칙에서 열지 않아 Admin SDK만 수행
// users 문서에 두면 문서를 지웠다가 다시 만드는 것만으로 한도가 초기화되므로 분리함
match /regenerateCounts/{userId} {
allow read: if isOwner(userId);
}
// 탈퇴한 사용자 기록 (재가입 방지용)
match /deletedUsers/{userId} {
allow read, create: if isOwner(userId);
}
// GitHub Trending 캐시: 랜딩 데모는 비로그인 방문자도 읽어야 하므로 공개 읽기
// 쓰기는 Cloud Functions의 Admin SDK만 수행하므로 규칙에서 허용하지 않음
match /meta/trendingRepos {
allow read: if true;
}
// 랜딩 데모 사전 생성 카드: 트렌딩 목록과 같은 이유로 비로그인 방문자도 읽어야 함
// 쓰기는 스케줄러(refreshTrendingRepos)의 Admin SDK만 수행하므로 규칙에서 허용하지 않음
match /demoFlashcards/{cacheId} {
allow read: if true;
}
// AI 호출 카운터(aiUsage), 데모 재생성 카운터(demoRegenerateCounts),
// 플래시카드 사전 생성 완료 기록(flashcardPregeneration):
// 규칙을 두지 않아 기본 거부 상태이며 Admin SDK만 읽고 쓴다.
// 사용자가 자기 한도를 직접 지울 수 있으면 경계가 없는 것과 같으므로 여기에
// match 블록을 추가해 클라이언트 접근을 열지 않는다
// 공지사항: 읽기 전용 (쓰기는 Firebase Console에서만)
match /notices/{noticeId} {
allow read: if isSignedIn();
}
}
}