Firestore · Storage 보안 규칙 · 인덱스 · 보안 규칙 테스트 · 개발 서버 구성 스크립트
This commit is contained in:
@@ -3,10 +3,16 @@
|
|||||||
"rules": "firestore.rules",
|
"rules": "firestore.rules",
|
||||||
"indexes": "firestore.indexes.json"
|
"indexes": "firestore.indexes.json"
|
||||||
},
|
},
|
||||||
|
"storage": {
|
||||||
|
"rules": "storage.rules"
|
||||||
|
},
|
||||||
"emulators": {
|
"emulators": {
|
||||||
"firestore": {
|
"firestore": {
|
||||||
"port": 8080
|
"port": 8080
|
||||||
},
|
},
|
||||||
|
"storage": {
|
||||||
|
"port": 9199
|
||||||
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
|||||||
+946
-126
File diff suppressed because it is too large
Load Diff
+397
-61
@@ -36,6 +36,28 @@ service cloud.firestore {
|
|||||||
function touchesOnly(fields) {
|
function touchesOnly(fields) {
|
||||||
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
|
return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
|
||||||
}
|
}
|
||||||
|
// 닉네임 길이 — 정책 원장 B-52 (2~20자 · 중복 허용). 검증 기준이
|
||||||
|
// 「20자 성공 · 21자 거부」이므로 길이를 규칙에서 본다. 앱도 같은 두 수를
|
||||||
|
// `nicknameMinLength`·`nicknameMaxLength` 로 들고 있다.
|
||||||
|
// 닉네임을 건드리지 않는 갱신(사진·언어 등)은 이 검사를 통과시킨다.
|
||||||
|
// 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을
|
||||||
|
// 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 앱 `normalizeNickname` 과 같은 계산.
|
||||||
|
function nicknameKey(n) {
|
||||||
|
return n.replace('[\\x{200B}-\\x{200D}\\x{FEFF}]', '').replace('\\x{3000}', ' ').trim().lower();
|
||||||
|
}
|
||||||
|
function nicknameOk() {
|
||||||
|
return !('nickname' in request.resource.data.diff(resource.data).affectedKeys())
|
||||||
|
|| (request.resource.data.nickname is string
|
||||||
|
&& request.resource.data.nickname.size() >= 2
|
||||||
|
&& request.resource.data.nickname.size() <= 20
|
||||||
|
// 운영 사칭 이름 차단 — 대소문자 무시 부분 일치. 앱 `reservedNicknameWords` 와 같은 목록.
|
||||||
|
&& !nicknameKey(request.resource.data.nickname)
|
||||||
|
.matches('.*(fanuzu|운영팀|관리자|admin|official).*')
|
||||||
|
// 닉네임 없는 작성자 표기(`publicNameOf` 「팬」)와 그 번역(Fan · ファン · 粉丝)은
|
||||||
|
// 정확히 일치하면 막는다. 앱 `reservedFanNames` 와 같은 목록.
|
||||||
|
&& !(nicknameKey(request.resource.data.nickname) in ['팬', 'fan', 'ファン', '粉丝']));
|
||||||
|
}
|
||||||
|
|
||||||
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
|
// 생성 문서의 작성자 필드가 요청자 본인인지 검사 — 타인 사칭 작성 차단.
|
||||||
function authorIsSelf() {
|
function authorIsSelf() {
|
||||||
return signedIn() && request.resource.data.authorUid == request.auth.uid;
|
return signedIn() && request.resource.data.authorUid == request.auth.uid;
|
||||||
@@ -48,24 +70,107 @@ service cloud.firestore {
|
|||||||
function myUser() {
|
function myUser() {
|
||||||
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data;
|
||||||
}
|
}
|
||||||
// 작성자 표시명이 프로필 원본과 같은지 — 운영팀 사칭 차단
|
// 다른 사용자에게 보이는 작성자 이름 — 앱 `publicName(nickname)` 과 같은 계산.
|
||||||
function authorNameMatchesProfile() {
|
// 닉네임이 없거나 이메일 형태면 「팬」. 공급자 실명(displayName)은 쓰지 않는다(M-7).
|
||||||
return request.resource.data.authorName == myUser().displayName;
|
function publicNameOf(u) {
|
||||||
|
let n = u.get('nickname', '');
|
||||||
|
return (n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*')) ? n.trim() : '팬';
|
||||||
|
}
|
||||||
|
// 공개 닉네임이 있는가 — publicNameOf 가 「팬」으로 대체하지 않는 경우와 같다.
|
||||||
|
function hasPublicNickname() {
|
||||||
|
let n = myUser().get('nickname', '');
|
||||||
|
return n is string && n.trim().size() > 0 && !n.matches('(?s).*@.*');
|
||||||
|
}
|
||||||
|
// 라운지 글 · 댓글 · 인사이드 답변의 작성자 이름이 공개 닉네임과 같은지 — 사칭 차단.
|
||||||
|
function authorNameIsPublic() {
|
||||||
|
return request.resource.data.authorName == publicNameOf(myUser());
|
||||||
|
}
|
||||||
|
// 팬덤 회원인가. 멤버십 문서(`fandoms/{id}/members/{uid}`)가 있거나, 없던 시절의
|
||||||
|
// 원천인 대표 팬덤(`users.fandomId`)이 같으면 회원이다. 두 원천을 모두 받아
|
||||||
|
// 멤버십 도입 전후 어느 쪽에서도 같은 판정이 난다.
|
||||||
|
function isFandomMember(fandomId) {
|
||||||
|
return signedIn()
|
||||||
|
&& (exists(/databases/$(database)/documents/fandoms/$(fandomId)/members/$(request.auth.uid))
|
||||||
|
|| myUser().fandomId == fandomId);
|
||||||
}
|
}
|
||||||
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단
|
// 소속 팬덤이 본인 소속과 같은지 — 타 팬덤 피드 삽입 차단
|
||||||
function fandomIsMine() {
|
function fandomIsMine() {
|
||||||
return request.resource.data.fandomId == myUser().fandomId;
|
return isFandomMember(request.resource.data.fandomId);
|
||||||
|
}
|
||||||
|
// 라운지 반응(좋아요 · 댓글)을 남길 수 있는 글인가 — 보이는 글이고, 요청자가
|
||||||
|
// 그 글의 팬덤 회원이다. 글 작성과 같은 회원 판정을 쓴다.
|
||||||
|
function canReactTo(postId) {
|
||||||
|
let post = get(/databases/$(database)/documents/posts/$(postId)).data;
|
||||||
|
return post.visibility == 'visible' && isFandomMember(post.fandomId);
|
||||||
|
}
|
||||||
|
// 라운지 본문 — 빈 글 차단 · 길이 상한(글 5,000 · 댓글 1,000).
|
||||||
|
function bodyOk(max) {
|
||||||
|
return request.resource.data.body is string
|
||||||
|
&& request.resource.data.body.size() > 0
|
||||||
|
&& request.resource.data.body.size() <= max;
|
||||||
|
}
|
||||||
|
// 서버 시각 강제 — 앱은 serverTimestamp 를 보낸다. 시각 위조로 정렬 맨 위를 차지하지 못한다.
|
||||||
|
function createdNow() {
|
||||||
|
return request.resource.data.createdAt == request.time;
|
||||||
|
}
|
||||||
|
function updatedNow() {
|
||||||
|
return request.resource.data.updatedAt == request.time;
|
||||||
|
}
|
||||||
|
// 라운지 글 사진 — Storage 경로를 담는다(`users/{uid}/lounge/{postId}-{i}`).
|
||||||
|
// URL 이 아니라 경로라 버킷 이름에 묶이지 않고, 칸마다 정확한 경로만 받으므로
|
||||||
|
// 남의 파일 · 외부 주소 · 다른 글의 사진을 끼워 넣지 못한다. B-44 「이미지 5 장」.
|
||||||
|
function loungeImagesOk(postId) {
|
||||||
|
let urls = request.resource.data.imageUrls;
|
||||||
|
let base = 'users/' + request.auth.uid + '/lounge/' + postId + '-';
|
||||||
|
return urls is list && urls.size() <= 5
|
||||||
|
&& (urls.size() < 1 || urls[0] == base + '0')
|
||||||
|
&& (urls.size() < 2 || urls[1] == base + '1')
|
||||||
|
&& (urls.size() < 3 || urls[2] == base + '2')
|
||||||
|
&& (urls.size() < 4 || urls[3] == base + '3')
|
||||||
|
&& (urls.size() < 5 || urls[4] == base + '4');
|
||||||
}
|
}
|
||||||
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외
|
// 무인증 열람을 허용하는 캠페인 상태 — draft·review 는 제외
|
||||||
function campaignIsPublic() {
|
function campaignIsPublic() {
|
||||||
return resource.data.status in ['funding', 'success', 'fail', 'report'];
|
return resource.data.status in ['funding', 'success', 'fail', 'report'];
|
||||||
}
|
}
|
||||||
|
// 하위 문서(댓글 · 좋아요) 쓰기에서 부모 캠페인이 공개 상태인가.
|
||||||
|
function campaignOpenFor(campaignId) {
|
||||||
|
return get(/databases/$(database)/documents/campaigns/$(campaignId)).data.status
|
||||||
|
in ['funding', 'success', 'fail', 'report'];
|
||||||
|
}
|
||||||
|
// 응원 게시판 댓글 작성 자격 — config/policy.campaignCommentScope(1. 운영 파라미터 참고).
|
||||||
|
function campaignCommentScope() {
|
||||||
|
let policy = /databases/$(database)/documents/config/policy;
|
||||||
|
return exists(policy) ? get(policy).data.get('campaignCommentScope', 'all') : 'all';
|
||||||
|
}
|
||||||
|
function campaignCommenter(campaignId) {
|
||||||
|
let scope = campaignCommentScope();
|
||||||
|
return scope == 'all'
|
||||||
|
|| (scope == 'members'
|
||||||
|
&& isFandomMember(get(/databases/$(database)/documents/campaigns/$(campaignId)).data.fandomId))
|
||||||
|
|| (scope == 'backers'
|
||||||
|
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
|
||||||
|
}
|
||||||
|
|
||||||
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
|
// ───────────────────────── 1. 운영 파라미터 ─────────────────────────
|
||||||
|
|
||||||
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
|
// 미확정 수치 원격 설정. 하드코딩 금지 대상 전부가 여기 들어온다.
|
||||||
// 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
|
// 근거: 결정 D-07·D-13·D-14 · FPI 가중치(P-03) · 이클립스 수치(CD-91)
|
||||||
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
|
// 화면: S-AD-POP · S-AD-FPI · S-AD-QUEST · S-AD-ECLIPSE
|
||||||
|
//
|
||||||
|
// config/policy.campaignCommentScope — 응원 게시판 댓글 작성 자격(발주사 확인 중).
|
||||||
|
// 'all'(없으면 이 값) 로그인 회원 누구나 · 'members' 캠페인 팬덤 회원 · 'backers' 후원자.
|
||||||
|
// 명세에 작성 자격이 없어 잠정 'all'. 그 밖의 값은 아무도 쓰지 못한다(오타는 닫힌 쪽으로).
|
||||||
|
//
|
||||||
|
// config/features — 앱 기능 스위치. 지금 있는 키는 하나다.
|
||||||
|
// stationEnabled (bool, 기본 false) STATION 하단 탭 노출.
|
||||||
|
// 별첨 B v1.4 — *1차에서는 사용자 탭과 Coming Soon 을 모두 비노출하고
|
||||||
|
// Route/데이터 구조만 유지해 향후 앱 재배포 없이 활성화합니다.*
|
||||||
|
// 앱은 로그인 전에도 이 문서를 읽으므로 아래 공개 읽기가 그대로 필요하다.
|
||||||
|
// 쓰기가 마스터 전용인 것이 이 스위치의 유일한 보호막이다.
|
||||||
|
//
|
||||||
|
// 이메일/비밀번호 provider 에 의존하는 규칙은 없다(별첨 B v1.4 로 이메일 가입 제외).
|
||||||
|
// 판정은 전부 request.auth.uid 와 커스텀 클레임 role 로만 한다.
|
||||||
match /config/{configId} {
|
match /config/{configId} {
|
||||||
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
|
allow read: if true; // 스플래시·로그인 전 화면도 읽어야 해 공개 읽기
|
||||||
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
|
allow write: if isMaster(); // rbac-summary 「재화 정책·FPI 가중치」 = 마스터 전용
|
||||||
@@ -104,9 +209,27 @@ service cloud.firestore {
|
|||||||
// 생성 주체를 Auth 트리거 하나로 고정한다.
|
// 생성 주체를 Auth 트리거 하나로 고정한다.
|
||||||
allow create: if false;
|
allow create: if false;
|
||||||
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
|
// 클라이언트가 바꿀 수 있는 것은 프로필 표시 항목뿐이다.
|
||||||
|
// `nickname` 은 설계서 v1.1 §2.1 이 정한 프로필 표시 이름이다(원장 B-52 —
|
||||||
|
// 2~20자 · 중복 허용 · 유니크 인덱스 제거). 목록에 없으면 MY 의 닉네임 변경이
|
||||||
|
// permission-denied 로 떨어지는데, 원인이 화면처럼 보여 찾기 어렵다.
|
||||||
|
// `displayName` 은 Auth 트리거가 처음 채우는 필드라(§8.5 fn-auth-oncreate) 함께 남긴다.
|
||||||
|
//
|
||||||
|
// `fcmTokens` — 단말 푸시 토큰. 재화가 아니고 본인 문서에만 붙으므로 본인이
|
||||||
|
// 직접 쓴다. 토큰 등록을 Functions 로 돌리면 로그인마다 호출이 한 번 늘고,
|
||||||
|
// 토큰은 앱이 재발급받을 때마다 바뀌어 그 호출이 계속 반복된다.
|
||||||
|
//
|
||||||
|
// `lastSeenAt` — HUD 「LIVE」 접속자 집계 입력(해석 확인 요청 Q-GM-03). 서버 시각만 받고,
|
||||||
|
// 직전 기록에서 60초가 지나야 다시 쓸 수 있다(앱 주기 2분 · 쓰기 폭주 차단).
|
||||||
|
// `fandomIds`(가입 팬덤 비정규화)는 목록에 없으므로 서버만 쓴다.
|
||||||
allow update: if isSelf(uid)
|
allow update: if isSelf(uid)
|
||||||
&& touchesOnly(['displayName','photoUrl','countryCode','locale',
|
&& touchesOnly(['nickname','displayName','photoUrl','countryCode','locale',
|
||||||
'representativeBadgeId','notificationPrefs','updatedAt']);
|
'representativeBadgeId','notificationPrefs',
|
||||||
|
'fcmTokens','lastSeenAt','updatedAt'])
|
||||||
|
&& nicknameOk()
|
||||||
|
&& (!('lastSeenAt' in request.resource.data.diff(resource.data).affectedKeys())
|
||||||
|
|| (request.resource.data.lastSeenAt == request.time
|
||||||
|
&& (!('lastSeenAt' in resource.data)
|
||||||
|
|| request.time > resource.data.lastSeenAt + duration.value(60, 's'))));
|
||||||
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
|
// 계정 상태 변경(정지·섀도우밴)은 운영자 권한. rbac-summary 「어뷰징 제재」 = 운영 이상
|
||||||
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
|
allow update: if isOps() && touchesOnly(['status','banReason','bannedUntil','updatedAt']);
|
||||||
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
|
allow delete: if false; // 탈퇴는 Functions 경유(원장 보존 · P-13)
|
||||||
@@ -167,6 +290,23 @@ service cloud.firestore {
|
|||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 결제 기록. `fn-iap-verify` 가 쓰고 환불 회수는 `fn-iap-refund-webhook` 가 바꾼다.
|
||||||
|
// 화면: MY/설정/서비스 결제 내역 · 상세 · 환불 요청 (원장 B-02 · B-72)
|
||||||
|
// 본인이 할 수 있는 것은 **지급 완료 → 환불 요청** 한 가지 전이뿐이다. 금액·상태의
|
||||||
|
// 다른 전이(승인·반려·회수)는 Admin 과 서버가 한다.
|
||||||
|
match /users/{uid}/purchases/{purchaseId} {
|
||||||
|
allow read: if isSelf(uid) || isViewer();
|
||||||
|
allow update: if isSelf(uid)
|
||||||
|
&& resource.data.state == 'granted'
|
||||||
|
&& request.resource.data.state == 'refund-requested'
|
||||||
|
&& request.resource.data.refundReason is string
|
||||||
|
&& request.resource.data.refundReason.size() > 0
|
||||||
|
&& request.resource.data.refundReason.size() <= 100
|
||||||
|
&& request.resource.data.refundRequestedAt == request.time
|
||||||
|
&& touchesOnly(['state', 'refundReason', 'refundRequestedAt']);
|
||||||
|
allow create, delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// 알림함. 읽음 처리만 본인이 할 수 있다.
|
// 알림함. 읽음 처리만 본인이 할 수 있다.
|
||||||
// 화면: S-X-NOTICE
|
// 화면: S-X-NOTICE
|
||||||
match /users/{uid}/notifications/{notiId} {
|
match /users/{uid}/notifications/{notiId} {
|
||||||
@@ -175,6 +315,13 @@ service cloud.firestore {
|
|||||||
allow create, delete: if false; // 발송은 Functions(FCM) 경유
|
allow create, delete: if false; // 발송은 Functions(FCM) 경유
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 가입한 행성/성운 목록(명세 §2.58 「가입 또는 생성한 행성/성운」). 가입 · 탈퇴 Function 만 쓴다 —
|
||||||
|
// 클라이언트가 쓰면 가입하지 않은 팬덤이 목록에 끼고 `memberCount` 와 어긋난다.
|
||||||
|
match /users/{uid}/memberships/{fandomId} {
|
||||||
|
allow read: if isSelf(uid) || isViewer();
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
|
// 약관 동의 이력. 개인정보 처리 근거의 입증 자료라 append-only 이고 클라이언트가 쓰지 못한다.
|
||||||
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
// 본인은 자기 동의 이력을 볼 수 있어야 한다(열람권). 화면: S-A-TERMS · S-M-SETTINGS
|
||||||
match /users/{uid}/consents/{consentId} {
|
match /users/{uid}/consents/{consentId} {
|
||||||
@@ -215,9 +362,19 @@ service cloud.firestore {
|
|||||||
// 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW
|
// 화면: S-G-PLANET · S-G-THEME · S-A-PLANET-NEW
|
||||||
match /fandoms/{fandomId}/planets/{planetId} {
|
match /fandoms/{fandomId}/planets/{planetId} {
|
||||||
allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입)
|
allow read: if signedIn(); // 타인 행성 열람 허용(갤럭시 맵 진입)
|
||||||
allow update: if isSelf(planetId)
|
// 원장 v1.2 C-50 — 행성 외형은 **생성 시 형태(planetType) + 컬러(colorKey)만** 고르고
|
||||||
&& touchesOnly(['planetType','colorKey','themeId','effectLevel','updatedAt']);
|
// 이후 사용자 커스텀은 없다. 생성은 온보딩 Function(fn-planet-create)이 하며,
|
||||||
allow create, delete: if false; // 생성은 온보딩 Function(fn-planet-create)
|
// themeId · effectLevel 은 서버 산출값이다. 클라이언트 쓰기는 전부 닫는다.
|
||||||
|
allow create, update, delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 갤럭시 맵 상단에 흐르는 최근 사건(이클립스 · 아이템 사용).
|
||||||
|
// 로그인한 사용자에게만 보인다 — 맵 자체가 로그인 뒤 화면이다.
|
||||||
|
// 쓰기는 서버(Functions)만 한다. 클라이언트가 사건을 만들면 맵이 조작된다.
|
||||||
|
// 화면: S-G-GALAXY
|
||||||
|
match /galaxyEvents/{eventId} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 성장 단계 정의(7단계). 마스터 데이터.
|
// 성장 단계 정의(7단계). 마스터 데이터.
|
||||||
@@ -241,24 +398,43 @@ service cloud.firestore {
|
|||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 이클립스 발동 이벤트.
|
// 이클립스 회차(설계서 v1.2 §2.11 `eclipseRounds` — v0.3.0 `eclipses` 에서 전환).
|
||||||
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
|
// 화면: S-G-ECLIPSE · S-AD-ECLIPSE
|
||||||
match /fandoms/{fandomId}/eclipses/{eclipseId} {
|
match /fandoms/{fandomId}/eclipseRounds/{eclipseId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
|
allow write: if false; // 발동·회복 수치는 서버 기준(별첨1 ① 판정 기준)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 참여자별 유효 Shake 누적(`participants.validShakes` — v0.3.0 `defenders`). 사용자당
|
||||||
|
// 상한과 속도 제한의 근거이며, 문서 ID 가 uid 라 중복이 구조적으로 없다(G-04).
|
||||||
|
// 판정은 서버가 하므로 클라이언트 쓰기를 막는다. 화면: S-G-ECLIPSE(내 참여량)
|
||||||
|
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/participants/{uid} {
|
||||||
|
allow read: if signedIn(); // 참여자 목록은 공동 이벤트의 일부다
|
||||||
|
allow write: if false; // 상한·속도 판정은 fn-eclipse-defend
|
||||||
|
}
|
||||||
|
|
||||||
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
|
// 분산 카운터 샤드. 동시 방어 시 쓰기 핫스팟을 흡수한다.
|
||||||
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
|
// 클라이언트가 직접 증가시키면 침식률을 위조할 수 있어 Functions 경유로 고정한다.
|
||||||
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
|
// 화면: S-G-ECLIPSE · 근거: 견적서 4주차 「이클립스 동시 쓰기 분산 카운터·요약 캐시로 핫스팟 흡수」
|
||||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/shards/{shardId} {
|
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/shards/{shardId} {
|
||||||
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
|
allow read: if signedIn(); // 합산은 클라이언트가 해도 무방
|
||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 방어 로그(라이브 소셜 프루프 원천).
|
// 방어 로그(이클립스 단위 원장). 원천은 여기고, 화면이 구독하는 것은 아래
|
||||||
|
// 팬덤 단위 liveEvents 다 — 티커는 활성 이클립스 ID 를 모른 채 떠야 한다.
|
||||||
// 화면: S-G-ECLIPSE(실시간 방어 로그)
|
// 화면: S-G-ECLIPSE(실시간 방어 로그)
|
||||||
match /fandoms/{fandomId}/eclipses/{eclipseId}/defenseLogs/{logId} {
|
match /fandoms/{fandomId}/eclipseRounds/{eclipseId}/defenseLogs/{logId} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라이브 소셜 프루프(방어 참여 · 이후 아이템 사용). 홈 라이브 띠와 갤럭시
|
||||||
|
// 섬광 배너가 이 컬렉션을 구독한다. 닉네임·POP·시각만 담는다.
|
||||||
|
// 클라이언트가 쓰면 「누가 방어했다」를 위조할 수 있어 Functions 전용이다.
|
||||||
|
// 화면: S-H-HOME(라이브 띠) · S-G-GALAXY(섬광 배너)
|
||||||
|
// 근거: 별첨 1 ① 완료 시나리오 5 「방어 참여 시 라이브 소셜 프루프 실시간 노출」
|
||||||
|
match /fandoms/{fandomId}/liveEvents/{eventId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
@@ -277,6 +453,14 @@ service cloud.firestore {
|
|||||||
allow write: if false; // 수령은 fn-herodrop-claim 경유
|
allow write: if false; // 수령은 fn-herodrop-claim 경유
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hero Gift 상품 Catalog — 원장 B-85. 문서 ID = 스토어 상품 ID.
|
||||||
|
// 화면: 홈 「아이템 구입」 · Admin `관리 마스터_아이템 관리`(fnAdminWrite 경유)
|
||||||
|
// 지급량(buyerPop · dropPop)은 `fn-iap-verify` 가 여기서 읽으므로 클라이언트 쓰기를 막는다.
|
||||||
|
match /heroGiftProducts/{productId} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// 단결형 아이템 발동 상태.
|
// 단결형 아이템 발동 상태.
|
||||||
// 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」
|
// 화면: S-M-SHOP · 근거: 별첨 1 ⑥ 「지급 대상·배수·시간창 정확」
|
||||||
match /fandoms/{fandomId}/solidarityBoosts/{boostId} {
|
match /fandoms/{fandomId}/solidarityBoosts/{boostId} {
|
||||||
@@ -290,6 +474,22 @@ service cloud.firestore {
|
|||||||
match /quests/{questId} {
|
match /quests/{questId} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
allow write: if isMaster(); // rbac-summary 「퀘스트·이클립스 파라미터」
|
||||||
|
|
||||||
|
// 공개 후 수정 이력(C-07). 쓰기는 fn-quest-edit 전용 — append-only.
|
||||||
|
match /edits/{editId} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 생성 Quest 제출 · 검토 · 지급(C-08 · C-17 · G-01 · G-03).
|
||||||
|
// 제출자 · 그 Quest 의 생성자 · 운영자만 읽는다. 상태 전이와 예약은 fn-quest-submit /
|
||||||
|
// fn-quest-review 가 원장과 한 트랜잭션에서 하므로 클라이언트 쓰기는 없다.
|
||||||
|
match /questSubmissions/{submissionId} {
|
||||||
|
allow read: if signedIn() && (resource.data.uid == request.auth.uid
|
||||||
|
|| resource.data.questAuthorUid == request.auth.uid
|
||||||
|
|| isOps());
|
||||||
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
|
// 위치 퀘스트 장소. 화면: S-H-PLACE · S-H-PLACE-DETAIL
|
||||||
@@ -298,10 +498,23 @@ service cloud.firestore {
|
|||||||
allow write: if isOps();
|
allow write: if isOps();
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI Daily Briefing 카드(Functions 생성 → 캐시). 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
|
// AI Daily Briefing 카드. 화면: S-H-BRIEF · S-H-BRIEF-DETAIL
|
||||||
|
//
|
||||||
|
// **게시분만 공개한다.** 예전 규칙은 `if true` 였는데, 그러면 승인 전 Draft 가
|
||||||
|
// 누구에게나 읽혔다. 별첨 B B-47 의 판정 기준이 「미승인 Draft 노출 0건」이므로
|
||||||
|
// 규칙 층에서 막는다 — 앱이 걸러 주는 것과 읽을 수 없는 것은 다르다.
|
||||||
|
// 로그인 없는 웹 브릿지 열람(D-16)은 게시분만 필요하므로 그대로 열려 있다.
|
||||||
|
//
|
||||||
|
// 앱 질의가 `status == 'published'` 를 포함하므로 목록 조회도 통과한다.
|
||||||
|
// 운영자는 검수를 위해 Draft 를 봐야 해서 `isViewer()` 를 함께 둔다.
|
||||||
|
//
|
||||||
|
// **쓰기는 함수만 한다.** 예전 규칙의 `isOps()` 는 `fn-briefing-publish` 를
|
||||||
|
// 우회해 상태를 바꿀 수 있는 길이었고, 그 경로로 바뀐 게시는 `adminActionLogs`
|
||||||
|
// 에 남지 않는다. 누가 무엇을 승인했는지가 사라지면 B-47 의 승인 게이트가
|
||||||
|
// 사실상 없는 것과 같다.
|
||||||
match /briefings/{briefingId} {
|
match /briefings/{briefingId} {
|
||||||
allow read: if true; // 웹 브릿지 열람 — D-16
|
allow read: if resource.data.status == 'published' || isViewer();
|
||||||
allow write: if isOps(); // 생성은 Functions, 수동 편집은 운영
|
allow write: if false; // 수신·승인은 Functions 전용
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배지 도감 마스터. 화면: S-M-BADGE
|
// 배지 도감 마스터. 화면: S-M-BADGE
|
||||||
@@ -326,24 +539,18 @@ service cloud.firestore {
|
|||||||
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
// 클라이언트 쿼리 필터가 아니라 규칙에서 강제한다. 공개 상태만 무인증 열람 대상이다.
|
||||||
allow read: if campaignIsPublic()
|
allow read: if campaignIsPublic()
|
||||||
|| isOps()
|
|| isOps()
|
||||||
|| (signedIn() && resource.data.creatorUid == request.auth.uid);
|
|| (signedIn() && resource.data.creatorUid == request.auth.uid)
|
||||||
// 화이트리스트 방식. 목록에 없는 필드가 하나라도 있으면 거부되므로
|
// 회사 귀책 취소(B-58)는 후원자에게 환불 알림이 가고 그 링크가 이 문서를 연다.
|
||||||
// 나중에 집계 필드가 추가돼도 자동으로 막힌다. 집계·분류 필드는 초기값을 못 벗어난다.
|
// 후원 문서 ID 가 거래 ID 라 규칙이 찾을 수 없어, fn-campaign-fund 가 같은
|
||||||
allow create: if signedIn()
|
// 트랜잭션에 쓰는 `backers/{uid}` 표식으로 후원 여부를 본다. 철회(withdrawn)는
|
||||||
&& request.resource.data.keys().hasOnly([
|
// 공개 전·첫 후원 전에만 가능해 후원자가 없으므로 계속 비공개다.
|
||||||
'fandomId','creatorUid','creatorName','title','summary','body',
|
|| (signedIn() && resource.data.status == 'cancelled'
|
||||||
'coverImageUrl','campaignType','targetPop','endsAt','status',
|
&& exists(/databases/$(database)/documents/campaigns/$(campaignId)/backers/$(request.auth.uid)));
|
||||||
'raisedPop','achievedRate','donorCount',
|
// 개설은 `fn-campaign-create` 전용이다. 등록비 차감(B-57)과 자격 판정(B-56),
|
||||||
'refundState','createdAt','updatedAt'
|
// 심사 대기(review) 진입이 한 트랜잭션이어야 하고, 개설자는 status 를 쓸 수 없어 직접
|
||||||
])
|
// 만든 draft 는 심사로 넘어갈 길이 없었다. 화이트리스트 · 집계 초기값
|
||||||
&& request.resource.data.creatorUid == request.auth.uid
|
// 판정은 이제 함수가 필드를 직접 정해 쓰는 것으로 대신한다.
|
||||||
&& request.resource.data.creatorName == myUser().displayName
|
allow create: if false;
|
||||||
&& fandomIsMine()
|
|
||||||
&& request.resource.data.status == 'draft'
|
|
||||||
&& request.resource.data.raisedPop == 0 // 개설 시 모금액 0 고정
|
|
||||||
&& request.resource.data.achievedRate == 0 // 달성률 위조 차단(웹 브릿지 노출 대상)
|
|
||||||
&& request.resource.data.donorCount == 0 // 후원자 수 위조 차단
|
|
||||||
&& request.resource.data.refundState == 'none';
|
|
||||||
// 개설자는 심사 전 초안만 고칠 수 있다.
|
// 개설자는 심사 전 초안만 고칠 수 있다.
|
||||||
allow update: if signedIn()
|
allow update: if signedIn()
|
||||||
&& resource.data.creatorUid == request.auth.uid
|
&& resource.data.creatorUid == request.auth.uid
|
||||||
@@ -358,23 +565,81 @@ service cloud.firestore {
|
|||||||
|
|
||||||
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
|
// 후원 내역. POP 차감과 1:1이라 전량 서버 경유.
|
||||||
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
|
// 화면: S-C-FUND · S-C-MINE · S-C-REFUND
|
||||||
|
// 캠페인 좋아요. 문서 ID = uid 로 중복을 구조적으로 막고, 개수는 앱이
|
||||||
|
// 집계 쿼리로 센다 — 캠페인 문서의 화이트리스트를 넓히지 않는다. 공개 캠페인에만 누른다.
|
||||||
|
match /campaigns/{campaignId}/likes/{uid} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow create: if isSelf(uid)
|
||||||
|
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||||
|
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
||||||
|
.data.status in ['funding','success','fail','report'];
|
||||||
|
allow delete: if isSelf(uid);
|
||||||
|
allow update: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응원 게시판 댓글. 화면: S-C-DETAIL 응원 게시판 탭 · 시안 `527:21190`.
|
||||||
|
// 라운지 댓글과 같은 틀 — 표시명은 공개 닉네임만(M-7), 서버 시각, 1,000자.
|
||||||
|
// 누가 쓰는가: 운영값 `config/policy.campaignCommentScope`(all · members · backers).
|
||||||
|
// 명세에 제한이 없어 잠정 'all' = 로그인 회원 누구나.
|
||||||
|
// 공개 캠페인에만 단다. `commentCount` 는 fnCampaignCommentCount 가 다시 센다.
|
||||||
|
match /campaigns/{campaignId}/comments/{commentId} {
|
||||||
|
// 숨김 댓글은 운영만 본다. 목록 질의는 `visibility == 'visible'` 로 좁혀야 통과한다.
|
||||||
|
allow read: if isOps() || (signedIn() && resource.data.visibility == 'visible');
|
||||||
|
allow create: if authorIsSelf()
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||||
|
])
|
||||||
|
// 응원 게시판은 「팬」 대체 이름을 받지 않는다 — 공개 닉네임이 있어야 쓴다.
|
||||||
|
&& hasPublicNickname()
|
||||||
|
&& authorNameIsPublic()
|
||||||
|
&& request.resource.data.visibility == 'visible'
|
||||||
|
&& bodyOk(1000)
|
||||||
|
&& createdNow() && updatedNow()
|
||||||
|
&& campaignOpenFor(campaignId)
|
||||||
|
&& campaignCommenter(campaignId);
|
||||||
|
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||||
|
// 운영이 숨긴 댓글은 작성자가 지워 증거를 없애지 못한다.
|
||||||
|
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||||
|
&& resource.data.visibility == 'visible';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응원 게시판 댓글 좋아요(시안 `527:21192`). 문서 ID = uid 로 1인 1회.
|
||||||
|
// 댓글의 `likeCount` 는 fnCampaignCommentLikeCount 가 다시 센다 — 댓글 문서는
|
||||||
|
// 운영의 숨김 외에 수정 경로가 없어 위조할 수 없다.
|
||||||
|
match /campaigns/{campaignId}/comments/{commentId}/likes/{uid} {
|
||||||
|
allow read: if signedIn();
|
||||||
|
allow create: if isSelf(uid)
|
||||||
|
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||||
|
&& createdNow()
|
||||||
|
&& get(/databases/$(database)/documents/campaigns/$(campaignId)/comments/$(commentId))
|
||||||
|
.data.visibility == 'visible'
|
||||||
|
&& campaignOpenFor(campaignId)
|
||||||
|
// 댓글을 쓸 수 없는 사람은 좋아요도 누를 수 없다(같은 작성 자격).
|
||||||
|
&& campaignCommenter(campaignId);
|
||||||
|
allow delete: if isSelf(uid);
|
||||||
|
allow update: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 후원자 표식. 문서 ID = uid. `fn-campaign-fund` 가 후원 문서와 같은 트랜잭션에 쓴다.
|
||||||
|
match /campaigns/{campaignId}/backers/{uid} {
|
||||||
|
allow read: if isSelf(uid) || isOps();
|
||||||
|
allow write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
match /campaigns/{campaignId}/donations/{donationId} {
|
match /campaigns/{campaignId}/donations/{donationId} {
|
||||||
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
|
allow read: if signedIn(); // 후원자 리더보드 노출(S-C-DETAIL)
|
||||||
allow write: if false; // 별첨1 ⑤ 환불 무결성
|
allow write: if false; // 별첨1 ⑤ 환불 무결성
|
||||||
}
|
}
|
||||||
|
|
||||||
// 결과 보고. 성공 캠페인 개설자만 1회 제출하고 이후 운영이 관리한다.
|
// 결과 보고. 성공 캠페인 개설자(또는 운영)가 1회 제출하고 이후 운영이 관리한다.
|
||||||
// 화면: S-C-RESULT
|
// 화면: S-C-RESULT
|
||||||
match /campaigns/{campaignId}/reports/{reportId} {
|
match /campaigns/{campaignId}/reports/{reportId} {
|
||||||
allow read: if true;
|
allow read: if true;
|
||||||
// §7.3 표는 「성공 캠페인 개설자」라고 적었는데 규칙은 creatorUid만 보고
|
// 대상은 성공한 캠페인이다(별첨1 ⑤ 「결과보고 게시 확인」).
|
||||||
// 상태를 확인하지 않아 실패·초안 캠페인에도 결과보고를 올릴 수 있었다.
|
// 제출은 `fn-campaign-report-submit` 전용이다. 보고 문서와 캠페인 상태 `report`
|
||||||
// 별첨1 ⑤ 판정 기준 「결과보고 게시 확인」의 대상은 성공한 캠페인이다.
|
// 전이·후원자 알림이 한 번에 서야 하는데, 개설자는 캠페인 status 를 쓸 수 없어
|
||||||
allow create: if signedIn()
|
// 클라이언트 직접 생성으로는 「보고는 있는데 상태는 success」가 남는다.
|
||||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
allow create: if false;
|
||||||
.data.creatorUid == request.auth.uid
|
|
||||||
&& get(/databases/$(database)/documents/campaigns/$(campaignId))
|
|
||||||
.data.status == 'success';
|
|
||||||
allow update: if isOps();
|
allow update: if isOps();
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
@@ -393,23 +658,36 @@ service cloud.firestore {
|
|||||||
'fandomId','authorUid','authorName','body','imageUrls',
|
'fandomId','authorUid','authorName','body','imageUrls',
|
||||||
'likeCount','commentCount','visibility','createdAt','updatedAt'
|
'likeCount','commentCount','visibility','createdAt','updatedAt'
|
||||||
])
|
])
|
||||||
&& authorNameMatchesProfile()
|
&& authorNameIsPublic()
|
||||||
&& fandomIsMine()
|
&& fandomIsMine()
|
||||||
&& request.resource.data.likeCount == 0
|
&& request.resource.data.likeCount == 0
|
||||||
&& request.resource.data.commentCount == 0
|
&& request.resource.data.commentCount == 0
|
||||||
&& request.resource.data.visibility == 'visible';
|
&& request.resource.data.visibility == 'visible'
|
||||||
|
&& bodyOk(5000)
|
||||||
|
&& createdNow() && updatedNow()
|
||||||
|
// 1 장 용량 · 형식 · 파일 이름은 storage.rules 가 본다.
|
||||||
|
&& loungeImagesOk(postId);
|
||||||
// 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
// 'title' 을 뺐다. 설계서 §2.6 posts 스키마에 title 필드가 없다.
|
||||||
|
// 사진은 작성 후 바꾸지 않는다 — 본문만 고친다.
|
||||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||||
&& touchesOnly(['body','imageUrls','updatedAt']);
|
&& touchesOnly(['body','updatedAt'])
|
||||||
|
&& bodyOk(5000)
|
||||||
|
&& updatedNow();
|
||||||
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
// 숨김·복원. rbac-summary 「콘텐츠 숨김·복원」 = 운영 이상
|
||||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
|
// 좋아요. 문서 ID = uid 로 중복 좋아요를 구조적으로 차단한다.
|
||||||
|
// likeCount 는 fnPostLikeCount 트리거가 하위 문서 수를 다시 세어 적는다 —
|
||||||
|
// 클라이언트는 글 문서의 수를 못 건드리므로(위 touchesOnly) 위조 경로가 없다.
|
||||||
match /posts/{postId}/likes/{uid} {
|
match /posts/{postId}/likes/{uid} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn();
|
||||||
allow create, delete: if isSelf(uid); // 집계 반영은 Functions 트리거
|
allow create: if isSelf(uid)
|
||||||
|
&& request.resource.data.keys().hasOnly(['createdAt'])
|
||||||
|
&& createdNow()
|
||||||
|
&& canReactTo(postId);
|
||||||
|
allow delete: if isSelf(uid); // 취소는 회원 자격과 무관하게 연다
|
||||||
allow update: if false;
|
allow update: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,10 +699,15 @@ service cloud.firestore {
|
|||||||
&& request.resource.data.keys().hasOnly([
|
&& request.resource.data.keys().hasOnly([
|
||||||
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
'authorUid','authorName','body','visibility','createdAt','updatedAt'
|
||||||
])
|
])
|
||||||
&& authorNameMatchesProfile()
|
&& authorNameIsPublic()
|
||||||
&& request.resource.data.visibility == 'visible';
|
&& request.resource.data.visibility == 'visible'
|
||||||
|
&& bodyOk(1000)
|
||||||
|
&& createdNow() && updatedNow()
|
||||||
|
&& canReactTo(postId); // 팬덤 회원만 · 보이는 글에만
|
||||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||||
&& touchesOnly(['body','updatedAt']);
|
&& touchesOnly(['body','updatedAt'])
|
||||||
|
&& bodyOk(1000)
|
||||||
|
&& updatedNow();
|
||||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||||
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
|
||||||
}
|
}
|
||||||
@@ -446,9 +729,15 @@ service cloud.firestore {
|
|||||||
'authorUid','authorName','body','sourceUrls','accepted',
|
'authorUid','authorName','body','sourceUrls','accepted',
|
||||||
'visibility','createdAt','updatedAt'
|
'visibility','createdAt','updatedAt'
|
||||||
])
|
])
|
||||||
&& authorNameMatchesProfile()
|
&& authorNameIsPublic()
|
||||||
&& request.resource.data.accepted == false;
|
&& request.resource.data.accepted == false
|
||||||
|
// 서버 시각만 받는다 — 과거 시각으로 최신순 정렬을 흔들지 못하게.
|
||||||
|
&& request.resource.data.createdAt == request.time
|
||||||
|
// 채택·만료된 질문에는 답변을 달지 않는다(현상금이 이미 정산됨).
|
||||||
|
&& get(/databases/$(database)/documents/questions/$(questionId)).data.status == 'open';
|
||||||
|
// 채택된 답변은 본문을 고칠 수 없다 — 채택 후 내용 바꿔치기 차단.
|
||||||
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
allow update: if signedIn() && resource.data.authorUid == request.auth.uid
|
||||||
|
&& resource.data.accepted == false
|
||||||
&& touchesOnly(['body','sourceUrls','updatedAt']);
|
&& touchesOnly(['body','sourceUrls','updatedAt']);
|
||||||
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
allow update: if isOps() && touchesOnly(['visibility','moderationNote','updatedAt']);
|
||||||
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
|
allow delete: if false; // 채택 대상이라 삭제 대신 숨김
|
||||||
@@ -459,9 +748,37 @@ service cloud.firestore {
|
|||||||
match /reports/{reportId} {
|
match /reports/{reportId} {
|
||||||
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
|
allow get: if isOps() || (signedIn() && resource.data.reporterUid == request.auth.uid);
|
||||||
allow list: if isOps();
|
allow list: if isOps();
|
||||||
|
// 설계서 §2.6 reports 스키마로만 받는다. 문서 ID 를 「post-{글}-{신고자}」로
|
||||||
|
// 규칙이 묶어 두므로 같은 사람의 재신고는 update 가 되어 아래에서 거부되고,
|
||||||
|
// 임의 ID 로 같은 글을 여러 번 신고할 수도 없다(B-45 「신고자 수」).
|
||||||
|
// 지금 앱이 접수하는 대상은 라운지 글 · 캠페인(`campaign-{캠페인}-{신고자}`) ·
|
||||||
|
// 캠페인 응원 게시판 댓글(`campaignComment-{캠페인}-{댓글}-{신고자}`)이다. 라운지 댓글 · 답변 · 회원
|
||||||
|
// 신고는 앱 경로가 생길 때 같은 방식으로 ID 를 묶어 연다.
|
||||||
|
// 사유 코드는 Figma 사본 신고 사유 4항목(`moderation.dart`)의 코드다.
|
||||||
allow create: if signedIn()
|
allow create: if signedIn()
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'reporterUid','targetType','targetPath','reasonCode',
|
||||||
|
'status','createdAt'
|
||||||
|
])
|
||||||
&& request.resource.data.reporterUid == request.auth.uid
|
&& request.resource.data.reporterUid == request.auth.uid
|
||||||
&& request.resource.data.status == 'pending';
|
&& request.resource.data.targetPath is string
|
||||||
|
&& ((request.resource.data.targetType == 'post'
|
||||||
|
&& request.resource.data.targetPath.matches('posts/[^/]+')
|
||||||
|
&& reportId == 'post-' + request.resource.data.targetPath.split('/')[1]
|
||||||
|
+ '-' + request.auth.uid)
|
||||||
|
|| (request.resource.data.targetType == 'campaign'
|
||||||
|
&& request.resource.data.targetPath.matches('campaigns/[^/]+')
|
||||||
|
&& reportId == 'campaign-' + request.resource.data.targetPath.split('/')[1]
|
||||||
|
+ '-' + request.auth.uid)
|
||||||
|
|| (request.resource.data.targetType == 'campaignComment'
|
||||||
|
&& request.resource.data.targetPath.matches('campaigns/[^/]+/comments/[^/]+')
|
||||||
|
&& reportId == 'campaignComment-' + request.resource.data.targetPath.split('/')[1]
|
||||||
|
+ '-' + request.resource.data.targetPath.split('/')[3]
|
||||||
|
+ '-' + request.auth.uid))
|
||||||
|
&& request.resource.data.reasonCode in
|
||||||
|
['defamation','inappropriate','spam','privacy']
|
||||||
|
&& request.resource.data.status == 'pending'
|
||||||
|
&& createdNow();
|
||||||
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
|
allow update: if isOps() && touchesOnly(['status','handledBy','handleNote','updatedAt']);
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
@@ -469,8 +786,19 @@ service cloud.firestore {
|
|||||||
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
|
// 문의. 화면: S-M-INQUIRY · S-AD-REPORT
|
||||||
match /inquiries/{inquiryId} {
|
match /inquiries/{inquiryId} {
|
||||||
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
allow get: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||||
allow list: if isOps();
|
// 내 문의 목록(S-M-INQUIRY · Figma `373:634916`)이 자기 문의를 열거한다.
|
||||||
|
// 규칙이 문서마다 `authorUid` 를 보므로, 질의가 `authorUid == uid` 로
|
||||||
|
// 좁혀지지 않으면 **질의 전체가 거부**된다 — 남의 문의는 한 건도 새지 않는다.
|
||||||
|
allow list: if isOps() || (signedIn() && resource.data.authorUid == request.auth.uid);
|
||||||
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
|
allow create: if authorIsSelf() && request.resource.data.status == 'pending';
|
||||||
|
// 작성자 수정 — Figma 문의 수정 `554:24330` · 상세의 「수정」 `373:635123`.
|
||||||
|
// 접수(`pending`) 상태에서만, 제목과 본문만 고칠 수 있다. `status` 를 함께
|
||||||
|
// 건드리면 거부되므로 스스로 「답변 완료」로 올릴 수 없고, 운영이 상태를
|
||||||
|
// 올린 뒤에는 이 갈래가 닫힌다.
|
||||||
|
allow update: if signedIn()
|
||||||
|
&& resource.data.authorUid == request.auth.uid
|
||||||
|
&& resource.data.status == 'pending'
|
||||||
|
&& touchesOnly(['title','body','updatedAt']);
|
||||||
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
|
allow update: if isOps() && touchesOnly(['status','answerBody','handledBy','updatedAt']);
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
}
|
}
|
||||||
@@ -522,7 +850,8 @@ service cloud.firestore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
|
// 초대 코드. 코드 유효성 확인을 위해 미로그인 읽기가 필요하다.
|
||||||
// 남용 방지를 위해 문서에는 사용 횟수 등 최소 필드만 두고 개인정보를 담지 않는다.
|
// 문서에는 `{active}` 만 둔다 — 코드 주인(UID)은 서버 전용 `inviteCodeOwners/{code}`
|
||||||
|
// (기본 거부 구역)에 있어 코드로 사용자를 알아낼 수 없다.
|
||||||
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
|
// 화면: S-W-PREREG(코드 입력) · E-PRE-01
|
||||||
match /inviteCodes/{code} {
|
match /inviteCodes/{code} {
|
||||||
allow get: if true; // 코드 직접 조회만 허용
|
allow get: if true; // 코드 직접 조회만 허용
|
||||||
@@ -545,12 +874,19 @@ service cloud.firestore {
|
|||||||
allow write: if false;
|
allow write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 마킹. 문서 ID = uid 로 중복 마킹을 차단하고, 본인 번역 자기 마킹은 서버가 막는다(E-TRN-01).
|
// 번역 일일 카운터(`{uid}_{dayKey}`). 도배 판정 입력이라 서버 전용.
|
||||||
|
match /translationDaily/{docId} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 마킹. 문서 ID = uid 로 중복 마킹을 차단한다. 쓰기는 fn-translation-mark 만 —
|
||||||
|
// 본인 번역(E-TRN-01) · 팬덤 멤버 · 가입 24시간(E-TRN-04) 판정이 규칙으로는 안 된다.
|
||||||
|
// 읽기는 번역 작성자 · 마킹한 본인 · 운영자만 — 수는 번역 문서(validCount · invalidCount)에 있다.
|
||||||
match /translationEntries/{entryId}/markings/{uid} {
|
match /translationEntries/{entryId}/markings/{uid} {
|
||||||
allow read: if signedIn();
|
allow read: if signedIn()
|
||||||
allow create: if isSelf(uid)
|
&& (request.auth.uid == uid || isOps()
|
||||||
&& get(/databases/$(database)/documents/translationEntries/$(entryId))
|
|| get(/databases/$(database)/documents/translationEntries/$(entryId)).data.authorUid == request.auth.uid);
|
||||||
.data.authorUid != request.auth.uid;
|
allow create: if false;
|
||||||
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
|
allow update, delete: if false; // 번복 불가 — 어뷰징 가드
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,480 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""검수용 시연 데이터를 Firestore 에 넣는다.
|
||||||
|
|
||||||
|
발주사가 설치본으로 화면을 확인하실 수 있어야 하는데(별첨 2 2주차 「앱 설치·로그인·
|
||||||
|
탭 이동 확인」), 빈 프로젝트에서는 모든 화면이 빈 상태로 뜬다. 그래서 검수 프로젝트에만
|
||||||
|
시연 데이터를 넣는다. **운영 데이터가 아니다.**
|
||||||
|
|
||||||
|
인증은 firebase-tools 가 보관한 dev@sweech.io 액세스 토큰을 쓴다. 별도 서비스 계정
|
||||||
|
키를 만들지 않으려는 것이며, 토큰은 파일에 남기지 않는다.
|
||||||
|
|
||||||
|
python3 seed-demo.py [--uid <가입한 uid>]
|
||||||
|
|
||||||
|
uid 를 주면 그 사용자의 지갑·행성·소속을 함께 만든다. 주지 않으면 공용 컬렉션만 넣는다.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
PROJECT = "fanuzu-dev-b020d"
|
||||||
|
BASE = f"https://firestore.googleapis.com/v1/projects/{PROJECT}/databases/(default)/documents"
|
||||||
|
KST = timezone(timedelta(hours=9))
|
||||||
|
|
||||||
|
|
||||||
|
def access_token(email: str = "dev@sweech.io") -> str:
|
||||||
|
"""firebase-tools 토큰 저장소에서 액세스 토큰을 꺼낸다."""
|
||||||
|
path = os.path.expanduser("~/.config/configstore/firebase-tools.json")
|
||||||
|
store = json.load(open(path, encoding="utf-8"))
|
||||||
|
if store.get("user", {}).get("email") == email:
|
||||||
|
return store["tokens"]["access_token"]
|
||||||
|
for account in store.get("additionalAccounts", []):
|
||||||
|
if account.get("user", {}).get("email") == email:
|
||||||
|
return account["tokens"]["access_token"]
|
||||||
|
raise SystemExit(f"{email} 토큰을 찾지 못했습니다. firebase login:add 를 먼저 하십시오.")
|
||||||
|
|
||||||
|
|
||||||
|
class GeoPoint:
|
||||||
|
"""좌표. Firestore 는 지도(map)가 아니라 geoPointValue 로 받아야 하며, 서버가
|
||||||
|
`place.get('geo').latitude` 로 읽으므로 형이 다르면 반경 판정이 조용히 실패한다."""
|
||||||
|
|
||||||
|
def __init__(self, lat: float, lng: float) -> None:
|
||||||
|
self.lat = lat
|
||||||
|
self.lng = lng
|
||||||
|
|
||||||
|
|
||||||
|
def value(v):
|
||||||
|
"""파이썬 값 → Firestore REST 의 Value 표현."""
|
||||||
|
if isinstance(v, GeoPoint):
|
||||||
|
return {"geoPointValue": {"latitude": v.lat, "longitude": v.lng}}
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return {"booleanValue": v}
|
||||||
|
if isinstance(v, int):
|
||||||
|
return {"integerValue": str(v)}
|
||||||
|
if isinstance(v, float):
|
||||||
|
return {"doubleValue": v}
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return {"timestampValue": v.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")}
|
||||||
|
if isinstance(v, list):
|
||||||
|
return {"arrayValue": {"values": [value(x) for x in v]}}
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {"mapValue": {"fields": {k: value(x) for k, x in v.items()}}}
|
||||||
|
return {"stringValue": str(v)}
|
||||||
|
|
||||||
|
|
||||||
|
def put(token: str, path: str, fields: dict) -> None:
|
||||||
|
"""문서 하나를 통째로 덮어쓴다. 여러 번 돌려도 결과가 같도록 PATCH 를 쓴다."""
|
||||||
|
url = f"{BASE}/{path}"
|
||||||
|
body = json.dumps({"fields": {k: value(v) for k, v in fields.items()}}).encode()
|
||||||
|
req = urllib.request.Request(url, data=body, method="PATCH")
|
||||||
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=30).read()
|
||||||
|
print(f" ✔ {path}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f" ✖ {path} — {e.code} {e.read().decode()[:200]}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime.now(KST)
|
||||||
|
FANDOM = "fanuzu-demo"
|
||||||
|
|
||||||
|
# FPI 5 축 — 별첨 B 「FPI 5 축」 확정. 순서를 여기서 한 번 고정하고 아래 표가 따른다.
|
||||||
|
AXES = ["bpm", "health", "referral", "community", "contribution"]
|
||||||
|
|
||||||
|
# 갤럭시에 뜨는 행성들. `fpiScore` 는 5 축 단순 합산이다(별첨 B · 설계서 §4.1,
|
||||||
|
# 가중치 초기 1.0). 앱이 TOTAL FPI 를 축의 합으로 그리므로 여기서 어긋나면
|
||||||
|
# 지표 카드의 합과 TOTAL FPI 가 갈린다 — 발주사 검토회신서 4절의 지적 자리다.
|
||||||
|
#
|
||||||
|
# **축마다 1 위가 다르도록 짰다.** 「서로 다른 지표 화면에 동일한 Ranking 데이터가
|
||||||
|
# 반복된다」가 지적이었으므로, 시연 데이터에서 지표를 바꾸면 순서가 바뀌는 것이
|
||||||
|
# 눈으로 확인돼야 한다.
|
||||||
|
FANDOMS = [
|
||||||
|
# id, 이름, 축값[5], 멤버, 접속, 티커, 직전 축별 순위(순위 동향 ▲▼ 용)
|
||||||
|
(FANDOM, "FANUZU 데모 행성", [24_800, 31_200, 18_400, 27_600, 26_400],
|
||||||
|
12_840, 431, "오늘의 기여가 132% 늘었습니다", [3, 1, 4, 1, 2]),
|
||||||
|
("aurora", "오로라", [31_400, 12_800, 22_600, 15_200, 14_200],
|
||||||
|
8_310, 214, "", [1, 4, 1, 3, 2]),
|
||||||
|
("nova-crew", "노바 크루", [9_800, 24_600, 11_200, 19_400, 9_800],
|
||||||
|
6_002, 158, "", [3, 2, 4, 2, 3]),
|
||||||
|
("stellar", "스텔라", [7_200, 8_400, 25_800, 6_300, 3_600],
|
||||||
|
4_115, 96, "", [4, 5, 2, 5, 5]),
|
||||||
|
("lumen", "루멘", [5_100, 6_200, 4_300, 11_400, 6_700],
|
||||||
|
2_884, 61, "", [5, 5, 5, 4, 4]),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 데모 행성의 멤버. 사용자 지표 랭킹(Figma 546:14668·14674)의 원천이다.
|
||||||
|
# 국가 랭킹(546:14713)은 이 표를 국가별로 합산해 만든다 — 따로 손으로 적으면
|
||||||
|
# 두 화면의 수치가 갈린다.
|
||||||
|
MEMBERS = [
|
||||||
|
# uid, 닉네임, 국가, 축값[5]
|
||||||
|
("demo-bunnies", "별빛수집가", "KR", [4_820, 2_110, 1_240, 980, 1_530]),
|
||||||
|
("demo-carat", "달빛", "KR", [3_960, 3_480, 620, 2_140, 870]),
|
||||||
|
("demo-moomin", "코스모스", "JP", [1_880, 4_020, 940, 3_050, 760]),
|
||||||
|
("demo-bora", "은하수", "JP", [1_210, 870, 2_260, 1_180, 2_980]),
|
||||||
|
("demo-dive", "네뷸라", "US", [940, 1_640, 1_720, 610, 1_120]),
|
||||||
|
("demo-nova", "혜성", "", [520, 780, 430, 340, 610]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def axis_map(values):
|
||||||
|
"""축값 5개를 `fpiAxes` 맵으로."""
|
||||||
|
return dict(zip(AXES, values))
|
||||||
|
|
||||||
|
# (문서 ID, 제목, 보상 POP, 일일 한도, 진행 라벨, Quest Type)
|
||||||
|
#
|
||||||
|
# `type` 은 앱이 **어느 검증 함수를 부를지** 고르는 값이다(설계서 §2.8). 비워 두면 앱이
|
||||||
|
# 판정 경로를 몰라 참여 버튼을 잠그고 사유를 표시한다 — 임의로 붙이면 검증 없는 지급
|
||||||
|
# 경로가 생기므로 비워 두는 쪽이 맞다. 라운지·응원처럼 Community 유형은 목표 수치 정의가
|
||||||
|
# 회신 대기(Q-QC-08)라 실제로 비어 있는 상태다.
|
||||||
|
QUESTS = [
|
||||||
|
("daily-checkin", "출석 체크", 50, 1, "1/1", "attend"),
|
||||||
|
("daily-steps", "8,000보 걷기", 120, 1, "6,240 / 8,000", "walk"),
|
||||||
|
("daily-place", "홍대 팝업스토어 방문", 100, 1, "0 / 1", "location"),
|
||||||
|
("daily-post", "라운지에 글 남기기", 100, 1, "0 / 1", ""),
|
||||||
|
("daily-cheer", "다른 행성 응원하기", 60, 3, "1 / 3", ""),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 행성 성장 단계 — 설계서 §2.3 `planetStages/{stageNo}`.
|
||||||
|
#
|
||||||
|
# 값의 출처는 **발주사 제공 수치표 v1.4 「Stage Summary」** 시트다(구간 기본 POP·sFPI).
|
||||||
|
# 설계서의 `requiredPop` 은 「해당 Level 진입 **누적** 기여 POP」이므로 시트의 구간값을
|
||||||
|
# 누계로 바꿔 적었다. 합계는 시트의 합계 행과 같다 — 50,000,000 POP · 7,500,000 sFPI.
|
||||||
|
#
|
||||||
|
# Stage 번호는 별첨 B **B-53** 을 따른다. 성운은 Stage0 으로 빼고 정식 행성이 1~6 이다
|
||||||
|
# (시트의 1~7 번호와 한 칸 어긋난다). 성운 → 정식 행성 전환 조건은 이 표가 아니라
|
||||||
|
# B-10(10 명 + 1,000 POP + 7 일)이라 Stage0 의 문턱은 0 이다.
|
||||||
|
#
|
||||||
|
# (stageNo, levelFrom, levelTo, 진입 누적 POP, 진입 누적 sFPI)
|
||||||
|
PLANET_STAGES = [
|
||||||
|
(0, None, None, 0, 0), # 태동 — 성운(Pre-Planet)
|
||||||
|
(1, 1, 20, 0, 0), # 시작
|
||||||
|
(2, 21, 40, 100_000, 15_000), # 성장
|
||||||
|
(3, 41, 60, 500_000, 75_000), # 함께
|
||||||
|
(4, 61, 80, 2_000_000, 300_000), # 동행
|
||||||
|
(5, 81, 99, 10_000_000, 1_500_000), # 빛
|
||||||
|
(6, 100, 100, 50_000_000, 7_500_000), # 영원
|
||||||
|
]
|
||||||
|
|
||||||
|
# AI Daily Briefing 표본 — 정책 원장 B-47 · B-75.
|
||||||
|
# 문안은 발주사 Agent 엔진이 만들고 우리는 수신·승인·노출만 한다(B-48). 표본도
|
||||||
|
# 그 규격대로 5 요소(출처명·게시시각·원본 URL·원문보기·AI 요약)를 전건 채운다 —
|
||||||
|
# 하나라도 비면 앱이 그 줄을 버리므로 데모에서 카드가 사라진다.
|
||||||
|
BRIEFINGS = [
|
||||||
|
("News", 1, "데뷔 10주년 단독 콘서트 서울 이틀 확정",
|
||||||
|
"연합뉴스", "https://example.com/news/anniversary-concert",
|
||||||
|
"서울 공연 이틀로 확정되었고 예매는 다음 주에 열립니다."),
|
||||||
|
("News", 2, "신곡, 주요 음원 차트 상위권 진입",
|
||||||
|
"스포티파이 차트", "https://example.com/news/chart-entry",
|
||||||
|
"발매 당일 글로벌 차트 상위권에 올랐습니다."),
|
||||||
|
("SNS", 1, "공식 계정, 팬미팅 비하인드 공개 예고",
|
||||||
|
"공식 X 계정", "https://example.com/sns/behind-teaser",
|
||||||
|
"비하인드 영상 공개 일정이 예고되었습니다."),
|
||||||
|
]
|
||||||
|
|
||||||
|
QUESTIONS = [
|
||||||
|
("데뷔 초기 무대 영상 어디서 볼 수 있나요", "공식 채널에 남아 있는 영상이 있는지 찾고 있습니다.", "별빛수집가", 500, 4),
|
||||||
|
("응원봉 배터리 교체 방법 아시는 분", "공식 안내가 없어서 직접 해보신 분 계신지 여쭙니다.", "달빛", 300, 7),
|
||||||
|
("팬미팅 대기 순서는 어떻게 정해지나요", "선착순인지 추첨인지 정확히 아는 분 계실까요.", "코스모스", 200, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
POSTS = [
|
||||||
|
("오늘 방송 보신 분", "마지막 곡 편곡이 완전히 달라졌더라고요. 다시 듣고 왔습니다.", "별빛수집가", 42, 8),
|
||||||
|
("행성 3단계 축하합니다", "다들 고생 많으셨어요. 다음 목표도 금방일 것 같습니다.", "달빛", 128, 23),
|
||||||
|
("굿즈 실물 후기", "생각보다 크기가 커서 놀랐습니다. 마감도 깔끔해요.", "코스모스", 76, 14),
|
||||||
|
("걷기 퀘스트 같이 하실 분", "혼자 하니 자꾸 까먹네요. 인증 스레드 만들어요.", "은하수", 55, 31),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--uid", help="가입한 사용자 uid — 지갑·소속·행성을 함께 만든다")
|
||||||
|
args = ap.parse_args()
|
||||||
|
token = access_token()
|
||||||
|
|
||||||
|
# 별첨 B v1.4 — STATION 은 1차 비노출. 문서를 미리 만들어 두어야
|
||||||
|
# 나중에 "앱 재배포 없이" 값 하나만 뒤집을 수 있다.
|
||||||
|
print("▸ 기능 스위치(config/features)")
|
||||||
|
put(token, "config/features", {"stationEnabled": False})
|
||||||
|
|
||||||
|
# 원장 C-01 · C-02 — 4 카테고리 ↔ 10 활동 · 순서 · 노출 · 사용자 생성 허용(S03 초기값).
|
||||||
|
# 서버 `DEFAULT_QUEST_CATALOG`(functions/src/quest/ugc.ts)와 같은 값이다. 운영 변경은 fn-admin-write.
|
||||||
|
print("▸ 퀘스트 분류 마스터(config/questCatalog)")
|
||||||
|
field = lambda f: [{"field": f, "required": True}] if f else []
|
||||||
|
put(token, "config/questCatalog", {
|
||||||
|
"categories": [
|
||||||
|
{"key": k, "label": label, "order": i + 1, "visible": True}
|
||||||
|
for i, (k, label) in enumerate([
|
||||||
|
("health", "건강"), ("planet", "행성 활동"),
|
||||||
|
("community", "커뮤니티"), ("location", "특정 위치"),
|
||||||
|
])
|
||||||
|
],
|
||||||
|
"activities": [
|
||||||
|
{"key": k, "categoryKey": c, "order": o, "visible": True, "ugcAllowed": u,
|
||||||
|
"inputSchema": field(f), **({"minReactions": 1} if k == "community" else {})}
|
||||||
|
for k, c, o, u, f in [
|
||||||
|
("walk", "health", 1, False, "goal"), ("shake", "health", 2, False, "goal"),
|
||||||
|
("streaming", "planet", 1, False, "link"), ("youtube", "planet", 2, False, "link"),
|
||||||
|
("vote", "planet", 3, True, "link"), ("sns", "planet", 4, True, "link"),
|
||||||
|
("community", "community", 1, True, None),
|
||||||
|
("location", "location", 1, True, "place"),
|
||||||
|
# 위치 무관 QR / NFC — 검증 명세 확정 전 사용자 생성 비노출(C-03).
|
||||||
|
("qr", "location", 2, False, "code"), ("nfc", "location", 3, False, "tag"),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 행성(fandoms)")
|
||||||
|
# 축별 순위는 서버(fn-fpi-aggregate)가 매기는 값이다. 시드도 같은 방식으로
|
||||||
|
# 계산해 넣는다 — 손으로 적으면 값과 순위가 갈린다.
|
||||||
|
ranks = {
|
||||||
|
axis: {
|
||||||
|
fid: i + 1
|
||||||
|
for i, (fid, *_) in enumerate(
|
||||||
|
sorted(FANDOMS, key=lambda f: -f[2][a])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for a, axis in enumerate(AXES)
|
||||||
|
}
|
||||||
|
# 생성 팔레트 키(C-50 · 앱 planetColors) — 행성마다 돌려 쓴다.
|
||||||
|
colors = ["blue", "purple", "gold", "green", "red", "orange", "white", "black"]
|
||||||
|
for i, (fid, name, axes, members, online, ticker, prev) in enumerate(FANDOMS):
|
||||||
|
color = colors[i % len(colors)]
|
||||||
|
# 팬덤당 행성 1건 — 문서 ID 고정 `planet`(fn-planet-create 와 같은 자리 · 설계서 §2.4).
|
||||||
|
put(token, f"fandoms/{fid}/planets/planet", {
|
||||||
|
"ownerUid": "seed", "fandomId": fid, "planetType": "shape1", "colorKey": color,
|
||||||
|
"createdAt": NOW,
|
||||||
|
})
|
||||||
|
# 이름 중복 판정 문서(fn-nebula-create) — 시드 이름으로 성운을 또 만들지 못하게.
|
||||||
|
put(token, f"fandomNames/{name.lower()}", {"fandomId": fid, "createdAt": NOW})
|
||||||
|
put(token, f"fandoms/{fid}", {
|
||||||
|
"planetType": "shape1", "colorKey": color,
|
||||||
|
"name": name,
|
||||||
|
# TOTAL FPI = 5 축 단순 합산.
|
||||||
|
"fpiScore": sum(axes),
|
||||||
|
"fpiAxes": axis_map(axes),
|
||||||
|
"fpiRanks": {axis: ranks[axis][fid] for axis in AXES},
|
||||||
|
"fpiRanksPrev": axis_map(prev),
|
||||||
|
# 별첨 B 「FPI 랭킹」 모집단 — 정식 행성(Stage 1 이상) 중
|
||||||
|
# 최근 7 일 유효 활동 존재 행성.
|
||||||
|
"stage": 1, "rankingEligible": True,
|
||||||
|
"memberCount": members, "onlineCount": online,
|
||||||
|
"liveTicker": ticker, "countryCode": "KR",
|
||||||
|
"fpiUpdatedAt": NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ Daily FPI Snapshot — 일 1 회(별첨 B). 지표 카드의 「최근 동향」 기준선")
|
||||||
|
# 앱의 최근 동향 = 현재 `fpiAxes` − 오늘 스냅샷. 스냅샷을 조금 낮게 두어
|
||||||
|
# 상승·하락이 섞여 보이게 한다.
|
||||||
|
day_key = NOW.strftime("%Y-%m-%d")
|
||||||
|
for fid, _name, axes, *_rest in FANDOMS:
|
||||||
|
base = [v - d for v, d in zip(axes, [1_700, -1_150, 500, 1_800, 1_500])]
|
||||||
|
put(token, f"fandoms/{fid}/fpiSnapshots/{day_key}", {
|
||||||
|
"createdAt": NOW.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||||
|
"fpiAxes": axis_map(base), "fpiScore": sum(base),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 데모 행성 멤버 — 사용자 지표 랭킹의 원천")
|
||||||
|
for muid, nickname, country, axes in MEMBERS:
|
||||||
|
put(token, f"fandoms/{FANDOM}/members/{muid}", {
|
||||||
|
"uid": muid, "fandomId": FANDOM, "nickname": nickname,
|
||||||
|
# 국가 랭킹 집계가 읽는 비정규화 필드. 빈 값은 ZZ(미지정) 버킷이다.
|
||||||
|
"countryCode": country,
|
||||||
|
"fpiAxes": axis_map(axes),
|
||||||
|
"contributedPop": axes[4],
|
||||||
|
"joinedAt": NOW - timedelta(days=20),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 국가별 지표 랭킹 — 축마다 문서 1건(설계서 §10)")
|
||||||
|
for a, axis in enumerate(AXES):
|
||||||
|
totals = {}
|
||||||
|
for _muid, _nick, country, axes in MEMBERS:
|
||||||
|
totals[country or "ZZ"] = totals.get(country or "ZZ", 0) + axes[a]
|
||||||
|
rows = sorted(totals.items(), key=lambda kv: -kv[1])
|
||||||
|
put(token, f"rankings/country-{axis}", {
|
||||||
|
"axis": axis, "updatedAt": NOW,
|
||||||
|
# 표시명은 넣지 않는다 — 앱의 `countryName` 한 곳이 코드를 이름으로
|
||||||
|
# 옮긴다. 여기에도 이름을 적으면 표기가 두 벌이 된다.
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"code": code, "value": value, "rank": i + 1,
|
||||||
|
# 시드에는 직전 집계가 없으므로 동향은 0 이다.
|
||||||
|
"change": 0,
|
||||||
|
}
|
||||||
|
for i, (code, value) in enumerate(rows)
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 퀘스트")
|
||||||
|
for qid, title, reward, limit, label, qtype in QUESTS:
|
||||||
|
fields = {
|
||||||
|
"title": title, "rewardPop": reward, "dailyLimit": limit,
|
||||||
|
"progressLabel": label, "active": True,
|
||||||
|
}
|
||||||
|
if qtype:
|
||||||
|
fields["type"] = qtype
|
||||||
|
if qid == "daily-place":
|
||||||
|
# 위치 퀘스트는 장소 문서를 가리켜야 서버가 반경을 판정할 수 있다.
|
||||||
|
fields["placeId"] = "hongdae-popup"
|
||||||
|
put(token, f"quests/{qid}", fields)
|
||||||
|
|
||||||
|
print("▸ 행성 성장 단계 — 발주사 수치표 v1.4 「Stage Summary」")
|
||||||
|
for stage_no, level_from, level_to, pop, sfpi in PLANET_STAGES:
|
||||||
|
fields = {"stageNo": stage_no, "requiredPop": pop, "requiredSfpi": sfpi}
|
||||||
|
if level_from:
|
||||||
|
fields["levelFrom"] = level_from
|
||||||
|
fields["levelTo"] = level_to
|
||||||
|
put(token, f"planetStages/{stage_no}", fields)
|
||||||
|
|
||||||
|
print("▸ 위치 퀘스트 장소 — 반경은 Admin 운영값(별첨 B B-27 기본 100m)")
|
||||||
|
put(token, "places/hongdae-popup", {
|
||||||
|
"name": "홍대 팝업스토어",
|
||||||
|
"geo": GeoPoint(37.5563, 126.9236),
|
||||||
|
"radiusM": 100,
|
||||||
|
"rewardPop": 100,
|
||||||
|
"verifyMethods": ["gps", "qr"],
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 브리핑")
|
||||||
|
# 앱은 `status == published` 이고 `dayKey` 가 당일(KST)인 것만 읽는다(B-47).
|
||||||
|
# 시드가 그 두 필드를 채우지 않으면 데모에서 브리핑 블록이 숨는다.
|
||||||
|
day_key = NOW.strftime("%Y-%m-%d")
|
||||||
|
for section, rank, headline, source_name, url, summary in BRIEFINGS:
|
||||||
|
put(token, f"briefings/{FANDOM}_{day_key}_{section}_{rank}", {
|
||||||
|
"fandomId": FANDOM, "dayKey": day_key, "section": section,
|
||||||
|
"rank": rank, "headline": headline, "status": "published",
|
||||||
|
"sourceName": source_name, "sourceAt": NOW - timedelta(hours=rank),
|
||||||
|
"sourceUrl": url, "articleUrl": url, "aiSummary": summary,
|
||||||
|
"aiGenerated": True,
|
||||||
|
"createdAt": NOW, "publishedAt": NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 캠페인")
|
||||||
|
put(token, "campaigns/demo-campaign", {
|
||||||
|
"fandomId": FANDOM, "status": "funding", "campaignType": "support",
|
||||||
|
"title": "데뷔 5주년 지하철 광고", "authorName": "별빛수집가",
|
||||||
|
"description": "역사 스크린에 5주년 축하 영상을 올립니다. 모인 POP 만큼 노출 일수가 늘어납니다.",
|
||||||
|
"goalPop": 500_000, "raisedPop": 341_200, "donorCount": 284,
|
||||||
|
"commentCount": 47, "likeCount": 512, "achievedRate": 68,
|
||||||
|
"createdAt": NOW - timedelta(days=6), "endsAt": NOW + timedelta(days=9),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 질문(INSIDE)")
|
||||||
|
for i, (title, body, author, bounty, answers) in enumerate(QUESTIONS):
|
||||||
|
put(token, f"questions/q-{i}", {
|
||||||
|
"fandomId": FANDOM, "status": "open", "category": "general",
|
||||||
|
"title": title, "body": body, "authorName": author,
|
||||||
|
"bountyPop": bounty, "answerCount": answers,
|
||||||
|
"createdAt": NOW - timedelta(hours=2 + i * 5),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 라운지 글")
|
||||||
|
for i, (title, body, author, likes, comments) in enumerate(POSTS):
|
||||||
|
put(token, f"posts/post-{i}", {
|
||||||
|
# 설계서 §2.6 — visible | hidden | deleted. 규칙이 새 글을 visible 로만 만든다.
|
||||||
|
"fandomId": FANDOM, "visibility": "visible",
|
||||||
|
"authorUid": f"demo-author-{i}",
|
||||||
|
"title": title, "body": body, "authorName": author,
|
||||||
|
"likeCount": likes, "commentCount": comments,
|
||||||
|
"createdAt": NOW - timedelta(hours=1 + i * 4),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ STATION 큐레이션 링크")
|
||||||
|
for i, (title, url, category, desc, verified) in enumerate([
|
||||||
|
("FANUZU 공식 사이트", "https://fanuzu.co.kr", "official", "공지와 일정이 가장 먼저 올라옵니다.", True),
|
||||||
|
("공식 스토어", "https://fanuzu.co.kr", "shop", "앨범과 응원봉을 파는 공식 판매처입니다.", True),
|
||||||
|
("팬미팅 예매", "https://fanuzu.co.kr", "ticket", "예매 일정과 대기 순서를 확인하십시오.", False),
|
||||||
|
("공식 미디어 채널", "https://fanuzu.co.kr", "media", "무대 영상과 비하인드가 올라옵니다.", True),
|
||||||
|
]):
|
||||||
|
put(token, f"stationLinks/link-{i}", {
|
||||||
|
"fandomId": FANDOM, "rank": i + 1, "title": title, "url": url,
|
||||||
|
"category": category, "description": desc, "verified": verified,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 갤럭시 이벤트")
|
||||||
|
put(token, "galaxyEvents/evt-latest", {
|
||||||
|
"kind": "itemUsed", "message": "오로라가 성장 촉진 아이템을 사용했습니다",
|
||||||
|
"createdAt": NOW - timedelta(minutes=18),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 이클립스 운영값 — Admin 「관리 마스터_이클립스 관리」 항목")
|
||||||
|
# **`enabled` 를 끈 채 심는다.** 켜 두면 `fn-eclipse-trigger` 가 발동 시각에
|
||||||
|
# 이클립스를 하나 더 만들어, 아래에서 손으로 심는 시연용 이클립스와 둘이 동시에
|
||||||
|
# 활성이 된다. 발동 스케줄을 확인할 때 이 값을 켠다.
|
||||||
|
put(token, "config/eclipse", {
|
||||||
|
"enabled": False,
|
||||||
|
# 원장 C-24 — 공통 일정(배열 길이 = 하루 회차 수). 팬덤별은 fandoms.eclipseSchedule[].
|
||||||
|
"schedule": [{"hhmm": "07:50"}, {"hhmm": "17:50"}],
|
||||||
|
"erosionPercent": 0.25, # C-23 실패 감소율(%)
|
||||||
|
"warnSeconds": 180, # C-22 발생 3분 전 푸시
|
||||||
|
"windowSec": 60, # C-22 공통 방어 창(초)
|
||||||
|
"targetRate": 0.05, # C-25 7일 평균 DAU 대비 목표율
|
||||||
|
"dauWindowDays": 7, # C-21 DAU 평균 기간(일)
|
||||||
|
"qualifyShakes": 60, # C-25 개인 인정 유효 Shake
|
||||||
|
"perCallMaxShakes": 50, "perUserMaxDefense": 500, "minIntervalMs": 300,
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 활성 이클립스 — 경보 배너·침식 HUD·ZAP 방어의 대상")
|
||||||
|
# 샤드는 비워 둔다. `fn-eclipse-defend` 가 채우고 `fn-eclipse-aggregate` 가
|
||||||
|
# 합산하는 것이 확인 항목이므로, 여기서 채워 두면 방어가 반영되는지를 못 본다.
|
||||||
|
# 시연 편의로 창을 30분 열어 둔다(운영 기본은 60초). 목표 2명 · 인정 60회(C-25·C-26).
|
||||||
|
put(token, f"fandoms/{FANDOM}/eclipseRounds/ecl-demo", {
|
||||||
|
"status": "active",
|
||||||
|
"fandomName": "BTS",
|
||||||
|
"target": 2, "qualifyShakes": 60, "windowSec": 1800,
|
||||||
|
"defense": 0, "defenseCount": 0,
|
||||||
|
"erosionRate": 100, "defended": False,
|
||||||
|
"startAt": NOW - timedelta(minutes=6),
|
||||||
|
"endsAt": NOW + timedelta(minutes=24),
|
||||||
|
})
|
||||||
|
|
||||||
|
print("▸ 라이브 소셜 프루프 — 홈 라이브 띠·갤럭시 섬광 배너")
|
||||||
|
# 별첨 B 「Global Ticker」 — 이 컬렉션은 이클립스 전용이 아니다. `kind` 로 갈린다.
|
||||||
|
for i, (nickname, defense, minutes) in enumerate([
|
||||||
|
("달빛", 30, 1), ("오로라", 10, 4), ("버니즈", 50, 9),
|
||||||
|
]):
|
||||||
|
at = NOW - timedelta(minutes=minutes)
|
||||||
|
put(token, f"fandoms/{FANDOM}/liveEvents/live-{i}", {
|
||||||
|
"kind": "eclipse_defense", "eclipseId": "ecl-demo",
|
||||||
|
"uid": f"seed-{i}", "nickname": nickname, "defense": defense,
|
||||||
|
"priority": 20, "linkTo": f"/eclipse/{FANDOM}/ecl-demo",
|
||||||
|
"createdAt": at, "expiresAt": at + timedelta(hours=1),
|
||||||
|
})
|
||||||
|
|
||||||
|
if args.uid:
|
||||||
|
uid = args.uid
|
||||||
|
print(f"▸ 사용자 {uid}")
|
||||||
|
# 대표 팬덤(C-15) — primaryFandomId 가 정본, fandomId 는 옛 독자용 같은 값.
|
||||||
|
put(token, f"users/{uid}", {
|
||||||
|
"primaryFandomId": FANDOM, "fandomId": FANDOM, "status": "active", "createdAt": NOW,
|
||||||
|
})
|
||||||
|
put(token, f"users/{uid}/wallet/main", {"activityBalance": 12_400, "supportBalance": 3_000})
|
||||||
|
# GALAXY 「가입 또는 생성한 행성/성운」 목록(명세 §2.58)의 원천.
|
||||||
|
put(token, f"users/{uid}/memberships/{FANDOM}", {
|
||||||
|
"fandomId": FANDOM, "joinedAt": NOW - timedelta(days=12),
|
||||||
|
})
|
||||||
|
put(token, f"fandoms/{FANDOM}/members/{uid}", {
|
||||||
|
"uid": uid, "fandomId": FANDOM, "nickname": "나",
|
||||||
|
"countryCode": "KR",
|
||||||
|
# 지표마다 내 순위가 달라야 한다 — REFERRAL·CONTRIBUTION 은 1 위,
|
||||||
|
# BPM 은 3 위가 되도록 잡았다.
|
||||||
|
"fpiAxes": axis_map([2_740, 1_290, 3_180, 1_620, 4_210]),
|
||||||
|
# 지표 카드의 「내 기여 상위 n%」. 서버 산출값이며 멤버 순위에서 나온다.
|
||||||
|
"topPercentByAxis": axis_map([43, 72, 15, 43, 15]),
|
||||||
|
"contributedPop": 4_210,
|
||||||
|
"joinedAt": NOW - timedelta(days=12),
|
||||||
|
})
|
||||||
|
for qid, *_ in QUESTS[:2]:
|
||||||
|
put(token, f"users/{uid}/questProgress/{qid}", {"count": 1, "dayKey": NOW.strftime("%Y-%m-%d")})
|
||||||
|
|
||||||
|
print("\n완료했습니다.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+73
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# FANUZU Firebase 프로젝트 개설 — 별첨 3 B-1 「저희 계정으로 먼저 개설」.
|
||||||
|
#
|
||||||
|
# 사전 조건: `firebase login` 을 한 번 해 두어야 합니다(브라우저가 열립니다).
|
||||||
|
#
|
||||||
|
# cd firebase && npx firebase login
|
||||||
|
# ./setup-project.sh
|
||||||
|
#
|
||||||
|
# 하는 일
|
||||||
|
# 1. 프로젝트 개설 (이미 있으면 건너뜁니다)
|
||||||
|
# 2. Android · iOS 앱 등록
|
||||||
|
# 3. 설정 파일 두 개를 제자리에 내려받기
|
||||||
|
# 4. Firestore 규칙·인덱스 배포
|
||||||
|
#
|
||||||
|
# **Cloud Functions 배포는 하지 않습니다.** Blaze 요금제 전환이 필요하고(D-10),
|
||||||
|
# 그건 결제 수단이 걸린 결정이라 발주사 회신 대상입니다.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||||
|
|
||||||
|
PROJECT_ID="${FANUZU_PROJECT_ID:-fanuzu-dev-b020d}"
|
||||||
|
ANDROID_ID="${FANUZU_ANDROID_ID:-kr.co.fanuzu.app}"
|
||||||
|
IOS_ID="${FANUZU_IOS_ID:-kr.co.fanuzu.app}"
|
||||||
|
|
||||||
|
if ! firebase login:list 2>&1 | grep -q '@'; then
|
||||||
|
echo "먼저 로그인하십시오: npx firebase login" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "▸ 프로젝트 $PROJECT_ID"
|
||||||
|
firebase projects:list --json 2>/dev/null | grep -q "\"$PROJECT_ID\"" \
|
||||||
|
|| firebase projects:create "$PROJECT_ID" --display-name "FANUZU Dev"
|
||||||
|
|
||||||
|
echo "▸ Android 앱 $ANDROID_ID"
|
||||||
|
firebase apps:list ANDROID --project "$PROJECT_ID" --json 2>/dev/null | grep -q "\"packageName\": *\"$ANDROID_ID\"" \
|
||||||
|
|| firebase apps:create ANDROID "FANUZU Android" \
|
||||||
|
--package-name "$ANDROID_ID" --project "$PROJECT_ID"
|
||||||
|
|
||||||
|
echo "▸ iOS 앱 $IOS_ID"
|
||||||
|
firebase apps:list IOS --project "$PROJECT_ID" --json 2>/dev/null | grep -q "\"bundleId\": *\"$IOS_ID\"" \
|
||||||
|
|| firebase apps:create IOS "FANUZU iOS" \
|
||||||
|
--bundle-id "$IOS_ID" --project "$PROJECT_ID"
|
||||||
|
|
||||||
|
echo "▸ 설정 파일 내려받기"
|
||||||
|
# 앱이 둘 이상(레거시 kr.co.sweech.fancake 잔존)이라 첫 행이 아니라 패키지명으로 고른다.
|
||||||
|
ANDROID_APP=$(firebase apps:list ANDROID --project "$PROJECT_ID" --json 2>/dev/null \
|
||||||
|
| python3 -c "import json,sys;print(next(a['appId'] for a in json.load(sys.stdin)['result'] if a.get('packageName')=='$ANDROID_ID'))")
|
||||||
|
IOS_APP=$(firebase apps:list IOS --project "$PROJECT_ID" --json 2>/dev/null \
|
||||||
|
| python3 -c "import json,sys;print(next(a['appId'] for a in json.load(sys.stdin)['result'] if a.get('bundleId')=='$IOS_ID'))")
|
||||||
|
|
||||||
|
firebase apps:sdkconfig ANDROID "$ANDROID_APP" --project "$PROJECT_ID" \
|
||||||
|
--out ../android/app/google-services.json
|
||||||
|
firebase apps:sdkconfig IOS "$IOS_APP" --project "$PROJECT_ID" \
|
||||||
|
--out ../ios/Runner/GoogleService-Info.plist
|
||||||
|
|
||||||
|
echo "▸ Firestore 규칙·인덱스 배포"
|
||||||
|
firebase deploy --only firestore --project "$PROJECT_ID"
|
||||||
|
|
||||||
|
cat <<'DONE'
|
||||||
|
|
||||||
|
끝났습니다. 남은 것 둘.
|
||||||
|
|
||||||
|
1. Firebase 콘솔에서 Authentication → Sign-in method →
|
||||||
|
「이메일/비밀번호」와 「Google」을 켜 주십시오. CLI 로는 켤 수 없습니다.
|
||||||
|
Google 을 켜야 google-services.json 에 oauth_client 가 채워집니다.
|
||||||
|
2. Android SHA-1 지문 등록도 CLI 에 명령이 없습니다. 콘솔의
|
||||||
|
프로젝트 설정 → 내 앱 → Android 앱 → 「디지털 지문 추가」로 넣으십시오.
|
||||||
|
절차와 현재 등록값은 BUILD.md 「Firebase 앱 등록」에 있습니다.
|
||||||
|
3. Cloud Functions 배포는 Blaze 요금제 전환(D-10) 뒤에 합니다.
|
||||||
|
|
||||||
|
설정 파일 두 개는 .gitignore 에 걸려 있어 저장소에 올라가지 않습니다.
|
||||||
|
DONE
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
|
||||||
|
// FANUZU — Cloud Storage 보안 규칙
|
||||||
|
//
|
||||||
|
// 근거
|
||||||
|
// - 정책 원장 B-44 「Community 미디어」 — *업로드 → 클라이언트 1차 검증 →
|
||||||
|
// Storage 규칙 서버 검증 → 게시*. 상한은 이미지 10MB · 영상 100MB/60초.
|
||||||
|
// - firestore.rules 의 `users/{uid}` — `photoUrl` 은 본인이 고칠 수 있는 필드다.
|
||||||
|
//
|
||||||
|
// 원칙
|
||||||
|
// 1) 클라이언트 검증은 **거를 수 있을 뿐 막지는 못한다.** 용량과 형식을 여기서
|
||||||
|
// 한 번 더 본다. 앱을 고쳐 올리는 경로가 있어도 규칙은 우회되지 않는다.
|
||||||
|
// 2) 남의 경로에는 쓰지 못한다. 경로 첫 칸이 UID 이고 토큰의 UID 와 같아야 한다.
|
||||||
|
// 3) 열지 않은 경로는 전부 닫는다(마지막 블록).
|
||||||
|
//
|
||||||
|
// 다운로드 URL 에 대하여
|
||||||
|
// `getDownloadURL()` 이 만드는 URL 은 토큰이 붙은 공개 링크이고 **본 규칙의 read
|
||||||
|
// 판정을 지나지 않는다.** 프로필 사진은 프로필 카드·라운지에서 다른 사용자에게
|
||||||
|
// 보여야 하므로 그 성격이 맞다. 여기 `read` 는 SDK 로 바이트를 직접 읽는 경로만
|
||||||
|
// 가린다.
|
||||||
|
|
||||||
|
service firebase.storage {
|
||||||
|
match /b/{bucket}/o {
|
||||||
|
|
||||||
|
function isSelf(uid) {
|
||||||
|
return request.auth != null && request.auth.uid == uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로필 사진 — `FirestoreMyRepository.uploadProfilePhoto` 가 쓰는 경로.
|
||||||
|
// 사용자당 한 장으로 고정되어 있어 바꿀 때 이전 파일이 남지 않는다.
|
||||||
|
match /users/{uid}/profile/{fileName} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if isSelf(uid)
|
||||||
|
&& request.resource.size <= 10 * 1024 * 1024
|
||||||
|
&& request.resource.contentType.matches('image/(jpeg|png|webp|gif)');
|
||||||
|
// 사진을 지우는 동선이 아직 없다. 여는 순간 「되돌릴 수 없는 삭제」의
|
||||||
|
// 확인 동선까지 필요해지므로 필요해지는 회차에 함께 연다.
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캠페인 결과보고 집행 사진 · 영수증 — 원장 B-35 · FR-ADM-05 「결과보고는 Admin 수동 운영」.
|
||||||
|
// 운영(master·ops)만 올린다. `fn-campaign-report-submit` 이 이 경로 접두와 파일 존재를
|
||||||
|
// 다시 본다. 결과보고는 공개 캠페인 상세에 뜨므로 읽기는 열어 둔다. 상한은 B-44 10MB.
|
||||||
|
match /campaignReports/{campaignId}/{fileName} {
|
||||||
|
allow read: if true;
|
||||||
|
allow create: if request.auth != null
|
||||||
|
&& request.auth.token.get('role', '') in ['master', 'ops']
|
||||||
|
&& request.resource.size <= 10 * 1024 * 1024
|
||||||
|
&& request.resource.contentType.matches('image/(jpeg|png|webp|gif)');
|
||||||
|
allow update, delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 팬덤 라운지 글 사진 — `FirestorePlanetRepository.createPost` 가 쓰는 경로.
|
||||||
|
// B-44 「이미지 5 장 10MB」의 1 장 상한 · 형식을 여기서 보고, 장수(5)는
|
||||||
|
// firestore.rules 의 `posts.imageUrls.size()` 가 본다. 팬덤 회원 판정도 글 문서
|
||||||
|
// 쪽 규칙이 한다 — 회원이 아니면 글이 거부되고 앱이 올린 사진을 지운다.
|
||||||
|
// 삭제는 본인만(글 삭제 · 글 저장 실패 시 되돌리기).
|
||||||
|
match /users/{uid}/lounge/{fileName} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
// `resource == null` — 덮어쓰기 금지. 글의 URL 이 가리키는 내용이 바뀌면 안 된다.
|
||||||
|
// 파일 이름 = 「글 ID(Firestore 자동 ID 20자)-칸 번호(0~4)」. 글 문서의 규칙이
|
||||||
|
// 칸마다 같은 경로만 받으므로 다른 이름의 파일은 어디에도 붙지 않는다.
|
||||||
|
allow create: if isSelf(uid)
|
||||||
|
&& resource == null
|
||||||
|
&& fileName.matches('[A-Za-z0-9]{20}-[0-4]')
|
||||||
|
&& request.resource.size <= 10 * 1024 * 1024
|
||||||
|
&& request.resource.contentType.matches('image/(jpeg|png|webp|gif)');
|
||||||
|
allow update: if false;
|
||||||
|
allow delete: if isSelf(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그 밖의 경로는 닫는다. 인사이드 미디어 · 라운지 영상(B-44 영상 100MB/60초)은
|
||||||
|
// 업로드 동선이 들어오는 회차에 같은 방식으로 상한과 함께 연다.
|
||||||
|
match /{allPaths=**} {
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,34 @@
|
|||||||
# Firestore 보안 규칙 회귀 테스트
|
# Firestore · Storage 보안 규칙 회귀 테스트
|
||||||
|
|
||||||
Firebase 실제 프로젝트 없이 로컬 Firestore 에뮬레이터에서 `../firestore.rules`의 allow/deny 동작을 검증합니다.
|
Firebase 실제 프로젝트 없이 로컬 에뮬레이터에서 `../firestore.rules` 와 `../storage.rules` 의 allow/deny 동작을 검증합니다.
|
||||||
|
|
||||||
## 실행
|
## 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app/firebase/test
|
cd firebase/test
|
||||||
npm ci
|
npm ci
|
||||||
npm run test:emulator
|
npm run test:emulator
|
||||||
```
|
```
|
||||||
|
|
||||||
`test:emulator`는 다음 순서로 실행됩니다.
|
`test:emulator`는 다음 순서로 실행됩니다.
|
||||||
|
|
||||||
1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore 에뮬레이터를 기동합니다.
|
1. Firebase CLI가 `demo-fancake` 프로젝트 ID로 Firestore · Storage 에뮬레이터를 기동합니다.
|
||||||
2. `initializeTestEnvironment()`가 `../firestore.rules` 원문을 에뮬레이터 규칙 REST API로 적재합니다.
|
2. `initializeTestEnvironment()`가 `../firestore.rules` 원문을 에뮬레이터 규칙 REST API로 적재합니다.
|
||||||
3. 음성 대조군이 로그인 사용자의 지갑 쓰기가 `permission-denied`인지 먼저 확인합니다.
|
3. 음성 대조군이 로그인 사용자의 지갑 쓰기가 `permission-denied`인지 먼저 확인합니다.
|
||||||
4. 소유자 지갑 읽기 양성 대조군을 확인한 뒤 Vitest 회귀 테스트 전체를 실행합니다.
|
4. 소유자 지갑 읽기 양성 대조군을 확인한 뒤 Vitest 회귀 테스트 전체를 실행합니다.
|
||||||
|
|
||||||
|
`tests/storage.rules.test.mjs` 는 프로필 사진 업로드 경로(`users/{uid}/profile/{file}`)의 용량·형식·소유자 판정을 같은 방식으로 확인합니다. 정책 원장 B-44 가 *클라이언트 1차 검증 → Storage 규칙 서버 검증* 2단을 요구하므로, 앱을 고쳐 우회해도 규칙에서 막히는지를 여기서 봅니다.
|
||||||
|
|
||||||
Firebase CLI의 `emulators:exec` 기동 성공만으로 규칙 적재·컴파일 성공을 판단하지 않습니다. 규칙 적재 실패 시 2단계에서 실행이 중단되고, 음성 대조군이 허용되면 3단계에서 실행이 중단됩니다.
|
Firebase CLI의 `emulators:exec` 기동 성공만으로 규칙 적재·컴파일 성공을 판단하지 않습니다. 규칙 적재 실패 시 2단계에서 실행이 중단되고, 음성 대조군이 허용되면 3단계에서 실행이 중단됩니다.
|
||||||
|
|
||||||
|
## 한국어 로케일 JVM 에서 실행할 때
|
||||||
|
|
||||||
|
에뮬레이터 JVM 이 한국어 로케일이면 규칙 평가 중 리소스 번들 예외로 일부 테스트가 죽는 사례가 보고되었습니다. 실행 전에 로케일을 고정하십시오.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US" npm run test:emulator
|
||||||
|
```
|
||||||
|
|
||||||
## 복합 인덱스 검증 한계 실측
|
## 복합 인덱스 검증 한계 실측
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"test": "npm run test:control && npm run test:regression",
|
"test": "npm run test:control && npm run test:regression",
|
||||||
"test:control": "node scripts/negative-control.mjs",
|
"test:control": "node scripts/negative-control.mjs",
|
||||||
"test:regression": "vitest run",
|
"test:regression": "vitest run",
|
||||||
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm test\"",
|
"test:emulator": "firebase emulators:exec --config ../firebase.json --only firestore,storage --project demo-fancake \"npm test\"",
|
||||||
"probe:indexes": "node scripts/index-emulator-probe.mjs",
|
"probe:indexes": "node scripts/index-emulator-probe.mjs",
|
||||||
"probe:indexes:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm run probe:indexes\""
|
"probe:indexes:emulator": "firebase emulators:exec --config ../firebase.json --only firestore --project demo-fancake \"npm run probe:indexes\""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,38 @@ export async function createTestEnvironment() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storageAddress() {
|
||||||
|
const value = process.env.FIREBASE_STORAGE_EMULATOR_HOST ?? '127.0.0.1:9199';
|
||||||
|
const separator = value.lastIndexOf(':');
|
||||||
|
|
||||||
|
if (separator < 1) {
|
||||||
|
throw new Error(`Invalid FIREBASE_STORAGE_EMULATOR_HOST: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = value.slice(0, separator);
|
||||||
|
const port = Number(value.slice(separator + 1));
|
||||||
|
|
||||||
|
if (!Number.isInteger(port)) {
|
||||||
|
throw new Error(`Invalid Storage emulator port: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { host, port };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Storage 규칙 전용 환경. Firestore 와 나눠 둔 이유는 에뮬레이터가 서로 다른
|
||||||
|
/// 포트에 뜨고, 한쪽만 켠 실행에서도 남은 하나가 돌아야 하기 때문이다.
|
||||||
|
export async function createStorageTestEnvironment() {
|
||||||
|
const rules = readFileSync(new URL('../../storage.rules', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
return initializeTestEnvironment({
|
||||||
|
projectId,
|
||||||
|
storage: {
|
||||||
|
...storageAddress(),
|
||||||
|
rules,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedDocuments(testEnvironment, documents) {
|
export async function seedDocuments(testEnvironment, documents) {
|
||||||
await testEnvironment.withSecurityRulesDisabled(async (context) => {
|
await testEnvironment.withSecurityRulesDisabled(async (context) => {
|
||||||
const firestore = context.firestore();
|
const firestore = context.firestore();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
|||||||
|
import { assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
|
||||||
|
import { deleteObject, ref, uploadBytes } from 'firebase/storage';
|
||||||
|
import { afterAll, beforeAll, describe, test } from 'vitest';
|
||||||
|
|
||||||
|
import { createStorageTestEnvironment } from '../support/environment.mjs';
|
||||||
|
|
||||||
|
/// 프로필 사진 업로드 — 정책 원장 B-44 의 *Storage 규칙 서버 검증* 이 실제로
|
||||||
|
/// 거르는지 본다. 클라이언트 1차 검증(`checkProfilePhoto`)은 앱 쪽 테스트가 잡고,
|
||||||
|
/// 여기서는 **앱을 고쳐 우회해도 막히는가**를 확인한다.
|
||||||
|
|
||||||
|
let testEnvironment;
|
||||||
|
|
||||||
|
const storageFor = (uid) =>
|
||||||
|
testEnvironment.authenticatedContext(uid).storage();
|
||||||
|
const anonymousStorage = () =>
|
||||||
|
testEnvironment.unauthenticatedContext().storage();
|
||||||
|
|
||||||
|
/// 규칙은 내용을 보지 않고 크기와 선언된 종류만 본다.
|
||||||
|
const bytesOf = (size) => new Uint8Array(size);
|
||||||
|
|
||||||
|
const image = { contentType: 'image/png' };
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testEnvironment = await createStorageTestEnvironment();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testEnvironment.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('프로필 사진 — users/{uid}/profile', () => {
|
||||||
|
test('본인 경로에 이미지를 올린다', async () => {
|
||||||
|
await assertSucceeds(
|
||||||
|
uploadBytes(ref(storageFor('alice'), 'users/alice/profile/photo'), bytesOf(1024), image),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('10MB 를 넘으면 거부한다', async () => {
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(
|
||||||
|
ref(storageFor('alice'), 'users/alice/profile/photo'),
|
||||||
|
bytesOf(10 * 1024 * 1024 + 1),
|
||||||
|
image,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('이미지가 아닌 종류는 거부한다', async () => {
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(
|
||||||
|
ref(storageFor('alice'), 'users/alice/profile/photo'),
|
||||||
|
bytesOf(1024),
|
||||||
|
{ contentType: 'application/pdf' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('남의 경로에는 올리지 못한다', async () => {
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(ref(storageFor('bob'), 'users/alice/profile/photo'), bytesOf(1024), image),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('로그인하지 않으면 올리지 못한다', async () => {
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(ref(anonymousStorage(), 'users/alice/profile/photo'), bytesOf(1024), image),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('라운지 사진은 본인 경로에 10MB 이하 이미지만, 지우기도 본인만', async () => {
|
||||||
|
const id = 'AbCdEfGhIjKlMnOpQrSt'; // Firestore 자동 ID 모양(20자)
|
||||||
|
const path = `users/alice/lounge/${id}-0`;
|
||||||
|
await assertSucceeds(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image));
|
||||||
|
// 이미 올린 파일 덮어쓰기는 닫는다 — 글의 URL 이 가리키는 내용이 바뀐다.
|
||||||
|
await assertFails(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image));
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(
|
||||||
|
ref(storageFor('alice'), `users/alice/lounge/${id}-1`),
|
||||||
|
bytesOf(10 * 1024 * 1024 + 1),
|
||||||
|
image,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(ref(storageFor('alice'), `users/alice/lounge/${id}-2`), bytesOf(1024), {
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await assertFails(uploadBytes(ref(storageFor('bob'), `users/alice/lounge/${id}-3`), bytesOf(1024), image));
|
||||||
|
// 파일 이름 규칙 밖 — 칸 번호 5 · 임의 이름.
|
||||||
|
await assertFails(uploadBytes(ref(storageFor('alice'), `users/alice/lounge/${id}-5`), bytesOf(1024), image));
|
||||||
|
await assertFails(uploadBytes(ref(storageFor('alice'), 'users/alice/lounge/anything'), bytesOf(1024), image));
|
||||||
|
await assertFails(deleteObject(ref(storageFor('bob'), path)));
|
||||||
|
await assertSucceeds(deleteObject(ref(storageFor('alice'), path)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('열지 않은 경로는 기본 거부다', async () => {
|
||||||
|
await assertFails(
|
||||||
|
uploadBytes(ref(storageFor('alice'), 'posts/post-1/media/1.png'), bytesOf(1024), image),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('캠페인 결과보고 사진·영수증 — campaignReports/{campaignId} (B-35 운영 전용)', () => {
|
||||||
|
const path = 'campaignReports/campaign-1/r1.png';
|
||||||
|
const ops = () => testEnvironment.authenticatedContext('ops-user', { role: 'ops' }).storage();
|
||||||
|
|
||||||
|
test('운영이 이미지를 올린다', async () => {
|
||||||
|
await assertSucceeds(uploadBytes(ref(ops(), path), bytesOf(1024), image));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('운영이 아니면(개설자 포함) · 10MB 초과 · 이미지 아닌 종류는 거부한다', async () => {
|
||||||
|
await assertFails(uploadBytes(ref(storageFor('alice'), path), bytesOf(1024), image));
|
||||||
|
await assertFails(uploadBytes(ref(ops(), path), bytesOf(10 * 1024 * 1024 + 1), image));
|
||||||
|
await assertFails(uploadBytes(ref(ops(), path), bytesOf(1024), { contentType: 'text/html' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user