🔐 Firebase & quy tắc bảo mật Firestore (dành cho quản trị)
Trang này đã nối Firebase thật: đăng nhập bằng Google qua Firebase Authentication; dữ liệu — người dùng (kèm số lượt lên trang nhất đã trả tiền), app (kèm link test, số tester đã xác nhận, mốc đủ 12 tester, trạng thái đóng test, lịch sử các đợt test và trạng thái trang nhất / hàng chờ của từng app), lượt test, bảng test qua lại, bài viết, bình luận, thông báo, cuộc trò chuyện và tin nhắn riêng, hạn mức và slot trang nhất — nằm ở Cloud Firestore của dự án Test Cheo (test-cheo). Để Firestore cho phép đọc/ghi, hãy dán bộ quy tắc dưới đây vào Firebase Console → Firestore Database → Rules rồi bấm Publish. Chưa dán quy tắc thì Firestore từ chối truy cập và trang hiển thị trạng thái trống thật (mọi con số 0) cho đến khi đọc được dữ liệu. Cũng đừng quên thêm tên miền của trang vào Authentication → Settings → Authorized domains để cửa sổ đăng nhập Google hoạt động trên tên miền thật. Trang không tự nạp dữ liệu mẫu vào Firestore — cơ sở dữ liệu bắt đầu trống và chỉ chứa bản ghi do người dùng thật tạo ra. Email của người dùng không nằm trong hồ sơ công khai — nó ở bộ sưu tập riêng userPrivate mà chỉ chủ tài khoản mới đọc được.
// ============================================================================
// Test Chéo — Firestore rules v2 (CHƯA PUBLISH — xem hướng dẫn cuối file)
// Nguồn: rules v1 nguyên văn trong thẻ #firebase-rules của trang (15/11/2026),
// chỉ sửa 3 chỗ: (A) users update, (B) meta, (C) thêm collection orders.
// Mọi collection khác giữ nguyên v1.
// ----------------------------------------------------------------------------
// TẠI SAO KHÔNG CẤM HẲN freeUsed / extraSlots / featureCredit Ở CLIENT?
// persistUserDoc() của trang gộp các trường hạn mức vào MỌI lần ghi hồ sơ:
// đăng app miễn phí (freeUsed +1), dùng lượt trang nhất đã trả (featureCredit
// -1), hủy xếp hàng (featureCredit +1) đều ghi client-side. Cấm hẳn các
// trường này sẽ làm HỎNG đăng app miễn phí và luồng hàng chờ. Thay vào đó v2
// khóa theo BƯỚC NHẢY: mỗi lần ghi, mỗi bộ đếm chỉ được giữ nguyên hoặc +1
// (featureCredit ±1 vì có cả dùng và hoàn lượt), freeTotal không bao giờ đổi.
// Các lần mở khóa TRẢ PHÍ không còn phụ thuộc ghi client nữa: webhook PayOS
// (Worker, Admin SDK) mới là bên duy nhất cộng slot / xếp hàng trang nhất.
// ----------------------------------------------------------------------------
// LỖ HỔNG CÒN LẠI — ĐỌC KỸ TRƯỚC KHI PUBLISH:
// (1) Rules không giới hạn tốc độ: một tài khoản vẫn có thể gọi API trực
// tiếp để +1 từng lần cho bộ đếm. Bảo vệ thật cho tiền nằm ở chỗ: lượt
// trả phí chỉ được cấp qua webhook PayOS đã xác minh chữ ký. Muốn khóa
// hẳn thì phải dời MỌI lần đổi hạn mức sang server (Worker endpoints cho
// cả đăng app miễn phí / dùng lượt / hủy hàng chờ) — chưa làm ở đợt này.
// (2) meta/featured chỉ khóa theo HÌNH DẠNG (slots là list ≤ 5). Một tài
// khoản vẫn có thể ghi một mảng slots đúng hình dạng để tự đưa app mình
// lên trang nhất. Khóa hẳn cần UID admin: tạo một tài khoản admin, ghi
// UID của nó vào rule (chỉ UID đó được ghi meta/featured) VÀ dời thao
// tác dọn slot / thăng hạng của sweep sang server. Sweep hiện vẫn ghi
// meta/featured client-side nên rule admin-only sẽ làm hỏng thăng hạng
// hôm nay — vì vậy v2 chưa khóa hẳn, chỉ khóa hình dạng.
// (3) Hạn mức lúc ĐĂNG app (freeUsed < freeTotal) vẫn chỉ do logic trang
// kiểm tra; rules không kiểm tra ở apps/{id} create. Cùng lý do như (1).
// ----------------------------------------------------------------------------
// HƯỚNG DẪN PUBLISH (người giữ Firebase Console làm — KHÔNG tự publish bằng
// script): Firebase Console → Firestore Database → Rules → dán toàn bộ file
// này → Publish. Chỉ publish SAU KHI webhook PayOS đã xác nhận chạy được
// end-to-end (đã test 1 lần thanh toán 10k thật mở khóa đúng). Đồng thời cập
// nhật thẻ #firebase-rules trong trang bằng đúng nội dung file này (đã làm
// trong lần sửa web nối PayOS).
// ============================================================================
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function signedIn() { return request.auth != null; }
// Cổng nạp dữ liệu mẫu ĐÚNG MỘT LẦN: chỉ mở khi chưa có tài liệu
// meta/seed và bản ghi gắn cờ seed == true; nạp xong cổng tự đóng hẳn.
function seedOpen() {
return !exists(/databases/$(database)/documents/meta/seed);
}
function isSeed() {
return seedOpen() && request.resource.data.seed == true;
}
// Giá trị cũ của một bộ đếm (mặc định 0 nếu tài liệu cũ chưa có trường).
function oldInt(field) {
return field in resource.data && resource.data[field] is int ? resource.data[field] : 0;
}
// Một bộ đếm trong lần ghi này: hoặc không đụng tới, hoặc là số nguyên
// nằm trong [cũ + minDelta, cũ + maxDelta].
function counterStep(field, minDelta, maxDelta) {
return !(field in request.resource.data.diff(resource.data).affectedKeys())
|| (request.resource.data[field] is int
&& request.resource.data[field] >= oldInt(field) + minDelta
&& request.resource.data[field] <= oldInt(field) + maxDelta);
}
// Một trường không được đổi giá trị trong lần ghi này.
function fieldUnchanged(field) {
return !(field in request.resource.data.diff(resource.data).affectedKeys());
}
// Hồ sơ người dùng CÔNG KHAI: ai cũng xem được tên, ảnh, điểm số —
// tài liệu này TUYỆT ĐỐI không chứa email hay thông tin riêng.
// Chỉ chủ tài khoản mới sửa được hồ sơ của mình; riêng lần nạp dữ
// liệu mẫu đầu tiên được tạo thêm các hồ sơ mẫu qua cổng isSeed().
// [v2] Update bị khóa: chỉ các trường hồ sơ an toàn + các bộ đếm với
// bước nhảy cho phép (xem ghi chú đầu file). freeUsed, extraSlots,
// featureCredit, given, received KHÔNG được đặt giá trị tùy ý;
// freeTotal không bao giờ được đổi. Worker (Admin SDK) bỏ qua rule này.
match /users/{uid} {
allow read: if true;
allow create: if signedIn() && (request.auth.uid == uid || isSeed());
allow update: if signedIn() && request.auth.uid == uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(
['name', 'displayName', 'photoURL', 'bio', 'avatarKind', 'avatarIcon',
'freeTotal', 'freeUsed', 'extraSlots', 'featureCredit', 'given', 'received'])
&& fieldUnchanged('freeTotal')
&& counterStep('freeUsed', 0, 1)
&& counterStep('extraSlots', 0, 1)
&& counterStep('featureCredit', -1, 1)
&& counterStep('given', 0, 1)
&& counterStep('received', 0, 1);
allow delete: if false;
}
// Thông tin riêng của từng tài khoản (email…): chỉ chủ tài khoản đọc / ghi.
match /userPrivate/{uid} {
allow read, write: if signedIn() && request.auth.uid == uid;
}
// App ở chợ test: đọc công khai; chỉ chủ app mới đăng / sửa / xóa app của mình.
// Ngoài các trường cũ, tài liệu app còn có: link (link test Play của app —
// trang dùng nó để nhận ra app trùng khi chủ app dán lại link sau khi cập
// nhật), confirmed (số tester ĐÃ ẤN XÁC NHẬN ở đợt hiện tại — đủ 12
// confirmed là app đạt mốc "Đủ 12 tester"), fullAt (thời điểm người thứ
// 12 xác nhận; ngày đủ điều kiện nộp đơn production = fullAt + 14 ngày),
// closed / closedAt (chủ app tự đóng test sau khi nộp đơn xong) và rounds
// (lịch sử các đợt test: mỗi phần tử gồm n, startedAt, confirmed, fullAt,
// carryTotal, carryConfirmed). Chỉ CHỦ app mới sửa được link và closed
// (mở đợt test mới, đóng test). Người khác được cập nhật testers (khi
// nhận test / nhận dự phòng), confirmed, fullAt, rounds và bốn trường
// của hàng chờ trang nhất (featuredStatus, queuedAt, featuredAt,
// featuredUntil) — nhưng rounds
// ở đây chỉ là các SỐ ĐẾM trong đợt hiện tại (confirmed, fullAt của phần
// tử đợt đang chạy) mà trang ghi kèm mỗi lần ấn xác nhận đã cài &
// dùng thử hoặc xác nhận lại ở đợt mới; người khác vẫn KHÔNG đụng được
// vào link hay trạng thái đóng test của app. Bốn trường featuredStatus,
// queuedAt, featuredAt, featuredUntil phục vụ hàng chờ trang nhất: khi
// một app hết 3 ngày hoặc đủ 12 tester, trang tự dọn chỗ của nó rồi
// thăng hạng app đang xếp hàng đầu tiên — thao tác đó ghi vào tài liệu
// app của NGƯỜI KHÁC nên bốn trường này phải nằm trong danh sách được
// phép cập nhật ở dưới.
match /apps/{appId} {
allow read: if true;
allow create: if signedIn() && (request.resource.data.ownerUid == request.auth.uid || isSeed());
allow update: if signedIn() && (
resource.data.ownerUid == request.auth.uid ||
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['testers', 'confirmed', 'fullAt', 'rounds', 'featuredStatus', 'queuedAt', 'featuredAt', 'featuredUntil'])
);
allow delete: if signedIn() && resource.data.ownerUid == request.auth.uid;
}
// Lượt test & bảng qua lại: phải đăng nhập mới xem được;
// người test tạo lượt của mình, hai bên liên quan được cập nhật.
match /tests/{testId} {
allow read: if signedIn();
allow create: if signedIn() && (request.resource.data.testerUid == request.auth.uid || isSeed());
allow update: if signedIn() && (resource.data.testerUid == request.auth.uid || resource.data.ownerUid == request.auth.uid);
allow delete: if false;
}
// Bài viết cộng đồng: đọc công khai; đăng bài phải gắn đúng tài khoản của mình;
// người đã đăng nhập được thích & bình luận (likes/comments nằm trong cùng tài liệu).
match /posts/{postId} {
allow read: if true;
allow create: if signedIn() && (request.resource.data.authorUid == request.auth.uid || isSeed());
allow update: if signedIn();
allow delete: if signedIn() && resource.data.authorUid == request.auth.uid;
}
// Thông báo: chỉ chủ sở hữu mới đọc / sửa / xóa được thông báo của mình.
// Tạo mới phải đúng khuôn dữ liệu của trang: icon là emoji ngắn (tối đa
// 6 ký tự, không chứa mã HTML), tiêu đề và nội dung xem trước có giới hạn.
match /notifications/{notifId} {
allow read, update, delete: if signedIn() && resource.data.ownerUid == request.auth.uid;
allow create: if signedIn()
&& request.resource.data.keys().hasOnly(['ownerUid', 'type', 'icon', 'title', 'preview', 'time', 'read', 'target', 'createdAt'])
&& request.resource.data.icon is string && request.resource.data.icon.size() <= 6
&& request.resource.data.title is string && request.resource.data.title.size() <= 200
&& request.resource.data.preview is string && request.resource.data.preview.size() <= 500;
}
// Cuộc trò chuyện riêng 1-1: chỉ hai người có uid nằm trong mảng
// participants mới đọc / cập nhật được đoạn chat của mình.
match /conversations/{convId} {
allow read: if signedIn() && request.auth.uid in resource.data.participants;
allow create: if signedIn()
&& (request.auth.uid in request.resource.data.participants || isSeed())
&& request.resource.data.keys().hasOnly(['participants', 'aUid', 'aName', 'bUid', 'bName', 'appName', 'testApp', 'lastText', 'lastAt', 'createdAt', 'seed']);
allow update: if signedIn() && request.auth.uid in resource.data.participants;
allow delete: if false;
}
// Tin nhắn riêng: CHỈ gồm chữ, emoji và nhãn dán — trang KHÔNG gửi ảnh
// nên tài liệu tin nhắn bị khóa đúng các trường dưới đây (không chèn
// được trường ảnh/dữ liệu lạ), chữ tối đa 2000 ký tự, nhãn dán tối đa
// 8 ký tự. Chỉ người trong cuộc trò chuyện mới đọc được; người gửi
// phải là một bên của cuộc trò chuyện; người trong cuộc chỉ được sửa
// duy nhất trường reactions để thả cảm xúc vào tin nhắn.
match /messages/{msgId} {
function inConv(cid) {
return signedIn() && request.auth.uid in
get(/databases/$(database)/documents/conversations/$(cid)).data.participants;
}
allow read: if inConv(resource.data.convId);
allow create: if signedIn()
&& request.resource.data.keys().hasOnly(['convId', 'senderUid', 'senderName', 'text', 'sticker', 'reactions', 'createdAt', 'seed'])
&& request.resource.data.text is string && request.resource.data.text.size() <= 2000
&& (request.resource.data.sticker == null || (request.resource.data.sticker is string && request.resource.data.sticker.size() <= 8))
&& ((request.resource.data.senderUid == request.auth.uid && inConv(request.resource.data.convId)) || isSeed());
allow update: if inConv(resource.data.convId)
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['reactions']);
allow delete: if false;
}
// [v2] Đơn thanh toán PayOS: chỉ Worker (Admin SDK, bỏ qua quy tắc) mới
// tạo / cập nhật. Người dùng chỉ được đọc đơn của chính mình. Trang
// không đọc trực tiếp collection này mà hỏi Worker qua /order-status.
match /orders/{orderCode} {
allow read: if signedIn() && resource.data.userId == request.auth.uid;
allow create, update, delete: if false;
}
// Dữ liệu chung (slot trang nhất, mốc đã nạp dữ liệu mẫu): đọc công khai.
// [v2] Ghi bị khóa theo hình dạng (v1 cho mọi tài khoản đã đăng nhập ghi
// tùy ý — xem lỗ hổng (2) ở đầu file). Chỉ được thay mảng slots của
// meta/featured (tối đa 5 chỗ) để thao tác dọn slot / thăng hạng của
// trang vẫn chạy; tài liệu meta/seed chỉ được tạo một lần (đóng cổng
// seed), không sửa / xóa.
match /meta/{docId} {
allow read: if true;
allow create: if signedIn() && (
(docId == 'featured'
&& request.resource.data.slots is list
&& request.resource.data.slots.size() <= 5)
|| docId == 'seed'
);
allow update: if signedIn() && docId == 'featured'
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['slots'])
&& request.resource.data.slots is list
&& request.resource.data.slots.size() <= 5;
allow delete: if false;
}
}
}
// Ghi chú (giữ lại từ v1, cập nhật cho v2):
// - Về ảnh trong tin nhắn: nhắn tin riêng hiện chỉ gồm chữ, emoji và nhãn
// dán — trang KHÔNG dùng Firebase Storage (dự án đang ở gói Spark miễn
// phí, bật Storage phải nâng cấp gói) nên KHÔNG cần bộ quy tắc Storage nào
// cả. Quy tắc của bộ sưu tập messages ở trên đã khóa đúng các trường được
// phép và giới hạn độ dài, nên không ai chèn được dữ liệu ảnh vào tài liệu
// tin nhắn.
// - Về PayOS thật (v2): việc mở slot và đưa app lên trang nhất do webhook
// PayOS ghi từ phía máy chủ (Cloudflare Worker, Admin SDK bỏ qua quy tắc
// này) — xem ~/workspace/test-cheo/worker/. Quy tắc users ở trên khóa các
// trường hạn mức theo bước nhảy, và meta/featured khóa theo hình dạng
// slots; các lỗ hổng còn lại ghi ở đầu file.
Về ảnh trong tin nhắn: nhắn tin riêng hiện chỉ gồm chữ, emoji và nhãn dán — trang không dùng Firebase Storage (dự án đang ở gói Spark miễn phí, bật Storage phải nâng cấp gói) nên không cần bộ quy tắc Storage nào cả. Quy tắc của bộ sưu tập messages ở trên đã khóa đúng các trường được phép và giới hạn độ dài, nên không ai chèn được dữ liệu ảnh vào tài liệu tin nhắn.
Lưu ý khi nối PayOS thật sau này: việc mở slot và đưa app lên trang nhất phải do webhook PayOS ghi từ phía máy chủ (Admin SDK bỏ qua quy tắc này) — khi đó hãy siết thêm quy tắc để người dùng không tự sửa được các trường hạn mức (freeUsed, extraSlots) trong hồ sơ của mình.