rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function isSignedIn() { return request.auth != null; } function isAdmin() { return isSignedIn() && request.auth.token.admin == true; } function isAdminEmail() { return isSignedIn() && request.auth.token.email_verified == true && exists(/databases/$(database)/documents/admin_users/$(authEmail())); } function isConfiguredAdmin() { return isAdmin() || isAdminEmail(); } function isOwner(uid) { return isSignedIn() && request.auth.uid == uid; } function hasAuthEmail() { return isSignedIn() && request.auth.token.get('email', '') is string && request.auth.token.get('email', '') != ''; } function authEmail() { return request.auth.token.email; } function isEmailOwner(email) { return hasAuthEmail() && request.auth.token.email_verified == true && email is string && email == authEmail(); } // favouriteWallToDoc stores string ids for every provider, including Pexels numeric ids. // Optional media/metadata fields vary by provider and by shipped client version. function validFavourite(docId) { let data = request.resource.data; return data.id is string && data.id == docId && data.provider is string && data.provider.lower() in ['prism', 'wall_of_the_day', 'walloftheday', 'wallhaven', 'pexels']; } // A follow that is already in place (arrayUnion of a present email) is a no-op write; allow it so a // batch of follows does not fail on one creator the user already follows. function canToggleFollowersOnly() { let affected = request.resource.data.diff(resource.data).affectedKeys(); return hasAuthEmail() && request.auth.token.email_verified == true && affected.hasOnly(['followers']) && (affected.size() == 0 || (resource.data.followers is list && request.resource.data.followers is list && request.resource.data.followers.removeAll([authEmail()]) == resource.data.followers.removeAll([authEmail()]) && ((!resource.data.followers.hasAny([authEmail()]) && request.resource.data.followers.size() == resource.data.followers.size() + 1) || !request.resource.data.followers.hasAny([authEmail()])))); } // New profiles start empty: coins, premium and coinState are server-owned. function canCreateUserDoc(userId) { return isOwner(userId) && request.resource.data.id is string && request.resource.data.id == userId && (!('email' in request.resource.data) || (hasAuthEmail() && request.resource.data.email == authEmail()) || (!hasAuthEmail() && request.resource.data.email == '')) // A candidate is not an identity. Only the server reservation transaction publishes the username. && request.resource.data.get('username', '') == '' && !('usernameLower' in request.resource.data) && ownerAppOpenValuesOk(request.resource.data) && request.resource.data.get('coins', 0) == 0 && request.resource.data.get('premium', false) == false && request.resource.data.get('subscriptionTier', 'free') == 'free' && request.resource.data.get('followers', []).size() == 0 && request.resource.data.get('badges', []).size() == 0 && !('coinState' in request.resource.data) && !request.resource.data.keys().hasAny(['xp', 'xpWeek', 'cosmetics', 'quests', 'inventory', 'drops']); } // Owners may only touch the client-side coinState keys; award flags stay server-owned. function ownerCoinStateOk() { return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['coinState']) || request.resource.data.coinState.diff(resource.data.get('coinState', {})).affectedKeys().hasOnly([ 'premiumPreviewUnlocks', 'streakReminderEnabled', 'streakTimezoneOffsetMinutes', 'streakReminderNextAtUtc' ]); } // lastAppOpenAt is a server timestamp, so it matches request.time. A raw write can be at most // 5 minutes old, or 1 minute ahead, and the offset stays on the 15 minute grid. function ownerAppOpenTimestampOk(data) { return data.lastAppOpenAt is timestamp && data.lastAppOpenAt <= request.time + duration.value(1, 'm') && data.lastAppOpenAt >= request.time - duration.value(5, 'm'); } function ownerAppOpenOffsetOk(data) { let offset = data.appOpenTimezoneOffsetMinutes; return offset is int && offset >= -720 && offset <= 840 && offset % 15 == 0; } function ownerAppOpenValuesOk(data) { let openAtOk = !('lastAppOpenAt' in data) || ownerAppOpenTimestampOk(data); let offsetOk = !('appOpenTimezoneOffsetMinutes' in data) || ownerAppOpenOffsetOk(data); return openAtOk && offsetOk; } function ownerAppOpenOk() { let changed = request.resource.data.diff(resource.data).affectedKeys(); let timestampOk = !changed.hasAny(['lastAppOpenAt']) || ownerAppOpenTimestampOk(request.resource.data); let offsetOk = !changed.hasAny(['appOpenTimezoneOffsetMinutes']) || ownerAppOpenOffsetOk(request.resource.data); return timestampOk && offsetOk; } function wallServerKeysAbsent() { return !request.resource.data.keys().hasAny(['dropReserved', 'dropWeek', 'boostedUntil', 'depth', 'originalProtected', 'originalObjectId', 'moderation', 'visionLabels', 'searchTerms']); } function wallServerKeysUntouched() { return !request.resource.data.diff(resource.data).affectedKeys().hasAny(['dropReserved', 'dropWeek', 'boostedUntil', 'depth', 'originalProtected', 'originalObjectId', 'moderation', 'visionLabels', 'searchTerms']); } // Wall of the Day — public read, admin-only write (set via Cloud Function) match /wall_of_the_day/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // Past Picks — public read, admin-only write (archived by Cloud Function) match /past_picks/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // Legacy/ops config match /appConfig/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // Public app metadata (icons, etc.) match /apps/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // Public feeds; unreviewed docs are owner/admin only. match /walls/{docId} { // Reviewed walls stay readable while 3.7.0 clients are installed: they read unconverted walls and the original URL. // Tighten this to review == true && originalProtected == true && wallpaper_url == '' (and the four provenance // fields == '') once 3.7.0 share is low. 3.8.0 clients already filter to protected walls on their side. allow read: if resource.data.review == true || isEmailOwner(resource.data.email) || isConfiguredAdmin(); // Admins re-create a rejected wall when they undo the rejection. allow create: if isConfiguredAdmin() || (isSignedIn() && isEmailOwner(request.resource.data.email) && request.resource.data.review != true && wallServerKeysAbsent()); // delete: request.resource is null — do not use request.resource.data here. allow update: if isConfiguredAdmin() || (isEmailOwner(resource.data.email) && wallServerKeysUntouched() && request.resource.data.email == resource.data.email && resource.data.review != true && request.resource.data.review != true); allow delete: if isConfiguredAdmin() || (isEmailOwner(resource.data.email) && resource.data.review != true); } match /setups/{docId} { allow read: if resource.data.review == true || isEmailOwner(resource.data.email) || isConfiguredAdmin(); allow create: if isSignedIn() && isEmailOwner(request.resource.data.email) && request.resource.data.review != true; allow update: if isConfiguredAdmin() || (isEmailOwner(resource.data.email) && request.resource.data.email == resource.data.email && resource.data.review != true && request.resource.data.review != true); allow delete: if isConfiguredAdmin() || (isEmailOwner(resource.data.email) && resource.data.review != true); } // Drafts are private to their owner. match /draftSetups/{docId} { allow read: if isConfiguredAdmin() || isEmailOwner(resource.data.email); allow create: if isConfiguredAdmin() || isEmailOwner(request.resource.data.email); allow update: if isConfiguredAdmin() || (isEmailOwner(resource.data.email) && request.resource.data.email == resource.data.email); allow delete: if isConfiguredAdmin() || isEmailOwner(resource.data.email); } // Rejection archives are private to owner/admin. // The owner may delete a rejected item from their own list. match /rejectedWalls/{docId} { allow read: if isConfiguredAdmin() || isEmailOwner(resource.data.email); allow write: if isConfiguredAdmin(); allow delete: if isEmailOwner(resource.data.email); } match /rejectedSetups/{docId} { allow read: if isConfiguredAdmin() || isEmailOwner(resource.data.email); allow write: if isConfiguredAdmin(); allow delete: if isEmailOwner(resource.data.email); } // Legacy public content bucket. match /setupsv3/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // Curated collections shown in feeds. match /collections/{docId} { allow read: if true; allow write: if isConfiguredAdmin(); } // User profiles + private user state. // Signed-in users can read social profiles. ponytail: field-level private // coins/email needs a profiles collection plus a migration. match /usersv2/{userId} { allow read: if isSignedIn(); allow create: if canCreateUserDoc(userId) || isConfiguredAdmin(); allow update: if (isOwner(userId) && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ 'name', 'profilePhoto', 'coverPhoto', 'bio', 'links', 'lastLoginAt', 'loggedIn', 'following', 'interestCategories', 'onboardingV2', 'uploadsWeekStart', 'uploadsThisWeek', 'id', 'createdAt', 'updatedAt', 'coinState', 'lastAppOpenAt', 'appOpenTimezoneOffsetMinutes' ]) && ownerCoinStateOk() && ownerAppOpenOk()) || canToggleFollowersOnly() || isConfiguredAdmin(); allow delete: if isConfiguredAdmin(); // Coin economy state (quests, inventory, unlocked drops, passes): owner read, server write. match /private/economy { allow read: if isOwner(userId) || isConfiguredAdmin(); allow write: if false; } match /private/session { allow read, write: if isOwner(userId) || isConfiguredAdmin(); } // Block list: owner can read; writes only via Cloud Functions (Admin SDK). match /blockedUsers/{blockedId} { allow read: if isOwner(userId); allow create, update, delete: if false; } // Per-user favourites subcollections. match /images/{docId} { allow read, delete: if isOwner(userId) || isConfiguredAdmin(); allow create, update: if (isOwner(userId) && validFavourite(docId)) || isConfiguredAdmin(); } match /setups/{docId} { allow read, create, update, delete: if isOwner(userId) || isConfiguredAdmin(); } } // Legacy users collection. match /users/{userId} { allow read, write: if isConfiguredAdmin() || isOwner(userId); } // Notifications are readable by signed-in users; only admins/functions write. match /notifications/{docId} { allow read: if isSignedIn() && (hasAuthEmail() && resource.data.modifier == authEmail() || resource.data.modifier in ['all', 'premium', 'free'] || resource.data.modifier == request.auth.uid || resource.data.modifier.matches('^[0-9]+([.][0-9]+)*$')); allow write: if isConfiguredAdmin(); } // Admin-triggered notification queue. match /notificationRequests/{docId} { allow read, write: if isConfiguredAdmin(); } // Referral/redeem codes. Admin-only: the Enter Code feature was removed client-side, // and any signed-in user could otherwise read every code in the collection. match /codes/{docId} { allow read, write: if isConfiguredAdmin(); } // AI generation history is private per user. match /aiGenerations/{docId} { allow read: if isConfiguredAdmin() || (isSignedIn() && resource.data.userId == request.auth.uid); allow create: if isConfiguredAdmin() || (isSignedIn() && request.resource.data.userId == request.auth.uid); allow update: if isConfiguredAdmin() || (isSignedIn() && resource.data.userId == request.auth.uid && request.resource.data.userId == request.auth.uid); allow delete: if isConfiguredAdmin() || (isSignedIn() && resource.data.userId == request.auth.uid); } // Coin transaction ledger is private per user. match /coinTransactions/{docId} { allow read: if isConfiguredAdmin() || (isSignedIn() && resource.data.userId == request.auth.uid); allow write: if false; } // Legacy token storage (if used, doc id should be UID). match /tokens/{userId} { allow read, write: if isConfiguredAdmin() || isOwner(userId); } // UGC reports (created via submitContentReport callable). Admin read/update for moderation UI. match /contentReports/{docId} { allow read: if isConfiguredAdmin(); allow create, delete: if false; allow update: if isConfiguredAdmin(); } // Rate-limit state for content reports (Cloud Functions only). match /contentReportRateByTarget/{docId} { allow read, write: if false; } match /contentReportRateDaily/{docId} { allow read, write: if false; } // User block rate limits (Cloud Functions only). match /userBlockRateDaily/{docId} { allow read, write: if false; } match /userBlockRateByTarget/{docId} { allow read, write: if false; } // Public view counters (writes via Cloud Functions Admin SDK only). match /wallpaper_stats/{docId} { allow read: if true; allow write: if false; } match /setup_stats/{docId} { allow read: if true; allow write: if false; } // Trending and popular wallpaper ids, written by the computeTrending function. match /trending/{docId} { allow read: if true; allow write: if false; } match /popular/{docId} { allow read: if true; allow write: if false; } // Username registry (claimUsername callable). Signed-in users can read it. match /usernames/{name} { allow read: if isSignedIn(); allow write: if false; } // Daily push counts. Cloud Functions write them. Admins read the day rows. match /metrics/{docId} { allow read: if isConfiguredAdmin(); allow write: if false; match /days/{dayId} { allow read: if isConfiguredAdmin(); allow write: if false; } match /signals/{signalId} { allow read, write: if false; } } // Coin economy v2. Config, drops, boosts, draws and leaderboards are read-only to signed-in users. // questEventRate and giftRateDaily have no match: default deny. match /economyConfig/{docId} { allow read: if true; allow write: if false; } match /drops/{weekKey} { allow read: if isSignedIn(); allow write: if false; } match /boosts/{wallId} { allow read: if isSignedIn(); allow write: if false; } match /draws/{weekKey} { allow read: if isSignedIn(); allow write: if false; } match /drawTickets/{ticketId} { // The id is `{weekKey}_{uid}`, so the owner can read a ticket that does not exist yet. allow get: if isSignedIn() && ticketId == ticketId.split('_')[0] + '_' + request.auth.uid; allow list: if isSignedIn() && resource.data.uid == request.auth.uid; allow write: if false; } match /leaderboards/{weekKey} { allow read: if isSignedIn(); allow write: if false; } // Deny everything else by default. match /{document=**} { allow read, write: if false; } } }