rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { function signedIn() { return request.auth != null; } function isOwner(userId) { return signedIn() && request.auth.uid == userId; } function isString(value, min, max) { return value is string && value.size() >= min && value.size() <= max; } function validCrop(crop) { return crop in ['corn', 'rice', 'soybean', 'wheat', 'tomato']; } function validCropList(crops) { return crops is list && crops.size() >= 1 && crops.size() <= 5 && validCrop(crops[0]) && (crops.size() < 2 || validCrop(crops[1])) && (crops.size() < 3 || validCrop(crops[2])) && (crops.size() < 4 || validCrop(crops[3])) && (crops.size() < 5 || validCrop(crops[4])); } function validStateCode(stateCode) { return stateCode in [ 'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY' ]; } function validLatLng(lat, lng) { return (lat == null || (lat is number && lat >= -90 && lat <= 90)) && (lng == null || (lng is number && lng >= -180 && lng <= 180)); } function validSeverity(severity) { return severity in ['low', 'medium', 'high']; } function validCropTroubleLocation(location) { return location is map && location.keys().hasOnly(['lat', 'lng', 'stateCode', 'generalArea', 'precision']) && validLatLng(location.lat, location.lng) && validStateCode(location.stateCode) && isString(location.generalArea, 2, 120) && location.precision in ['approximate', 'county_state', 'state']; } function validCropTroubleCreate() { return request.resource.data.keys().hasOnly([ 'userId', 'farmId', 'crop', 'issueType', 'disease', 'severity', 'description', 'location', 'photoUrl', 'photoPath', 'photoShared', 'createdAt', 'status', 'seeingTooCount', 'moderationCount' ]) && request.resource.data.userId == request.auth.uid && (request.resource.data.farmId == null || request.resource.data.farmId is string) && (request.resource.data.farmId == null || isFarmMember(request.resource.data.farmId)) && validCrop(request.resource.data.crop) && isString(request.resource.data.issueType, 1, 120) && request.resource.data.disease == request.resource.data.issueType && validSeverity(request.resource.data.severity) && request.resource.data.description is string && request.resource.data.description.size() <= 700 && validCropTroubleLocation(request.resource.data.location) && ( ( request.resource.data.photoShared == false && request.resource.data.photoUrl == null && request.resource.data.photoPath == null ) || ( request.resource.data.photoShared == true && isString(request.resource.data.photoUrl, 1, 2048) && request.resource.data.photoPath == 'cropTroubleReports/' + request.resource.id + '/shared-photo' ) ) && request.resource.data.createdAt == request.time && request.resource.data.status == 'new' && request.resource.data.seeingTooCount == 0 && request.resource.data.moderationCount == 0 && getAfter(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)).data.lastReportAt == request.time && ( !exists(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)) || get(/databases/$(database)/documents/reportRateLimits/$(request.auth.uid)).data.lastReportAt < request.time - duration.value(10, 'm') ); } function validFarmCreate() { return request.resource.data.keys().hasOnly([ 'name', 'ownerId', 'joinCode', 'address', 'lat', 'lng', 'stateCode', 'crops', 'acreage', 'verificationStatus', 'createdAt' ]) && request.resource.data.ownerId == request.auth.uid && isString(request.resource.data.name, 1, 120) && isString(request.resource.data.address, 1, 240) && request.resource.data.joinCode.matches('^[A-Z2-9]{6}$') && validStateCode(request.resource.data.stateCode) && validCropList(request.resource.data.crops) && validLatLng(request.resource.data.lat, request.resource.data.lng) && (request.resource.data.acreage == null || (request.resource.data.acreage is number && request.resource.data.acreage >= 0 && request.resource.data.acreage <= 1000000)) && request.resource.data.verificationStatus == 'unverified' && request.resource.data.createdAt == request.time; } function memberId(farmId, userId) { return farmId + '_' + userId; } function requestId(farmId, userId) { return farmId + '_' + userId; } function isFarmMember(farmId) { return signedIn() && exists(/databases/$(database)/documents/farmMembers/$(memberId(farmId, request.auth.uid))); } function isFarmOwner(farmId) { return signedIn() && get(/databases/$(database)/documents/farms/$(farmId)).data.ownerId == request.auth.uid; } function joinCodeAllowsMembership(data) { return data.joinCodeUsed is string && exists(/databases/$(database)/documents/farmJoinCodes/$(data.joinCodeUsed)) && get(/databases/$(database)/documents/farmJoinCodes/$(data.joinCodeUsed)).data.farmId == data.farmId; } function approvedRequestAllowsMembership(data) { return data.approvedRequestId is string && exists(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)) && getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.farmId == data.farmId && getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.requesterId == data.userId && getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.ownerId == request.auth.uid && getAfter(/databases/$(database)/documents/farmAccessRequests/$(data.approvedRequestId)).data.status == 'approved'; } function validFarmSearchIndex() { return request.resource.data.keys().hasOnly([ 'farmId', 'ownerId', 'name', 'address', 'stateCode', 'crops', 'searchTokens', 'updatedAt' ]) && request.resource.data.farmId is string && request.resource.data.ownerId == request.auth.uid && isString(request.resource.data.name, 1, 120) && isString(request.resource.data.address, 1, 240) && validStateCode(request.resource.data.stateCode) && validCropList(request.resource.data.crops) && request.resource.data.searchTokens is list && request.resource.data.searchTokens.size() >= 1 && request.resource.data.searchTokens.size() <= 40 && request.resource.data.updatedAt == request.time; } function validAccessRequestCreate(farmId, requesterId) { return request.resource.data.keys().hasOnly([ 'farmId', 'requesterId', 'ownerId', 'status', 'farmName', 'farmStateCode', 'farmAddress', 'requestedAt', 'requestExpiresAt' ]) && farmId == request.resource.data.farmId && requesterId == request.resource.data.requesterId && requesterId == request.auth.uid && request.resource.data.status == 'pending' && request.resource.data.requestedAt == request.time && request.resource.data.requestExpiresAt is timestamp && request.resource.data.requestExpiresAt > request.time && request.resource.data.requestExpiresAt <= request.time + duration.value(30, 'd') && !exists(/databases/$(database)/documents/farmMembers/$(memberId(farmId, requesterId))) && exists(/databases/$(database)/documents/farmSearchIndex/$(farmId)) && get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.ownerId == request.resource.data.ownerId && get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.name == request.resource.data.farmName && get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.stateCode == request.resource.data.farmStateCode && get(/databases/$(database)/documents/farmSearchIndex/$(farmId)).data.address == request.resource.data.farmAddress && request.resource.data.ownerId != request.auth.uid; } match /users/{userId} { allow read: if isOwner(userId); allow create: if isOwner(userId) && request.resource.data.keys().hasOnly(['name', 'email', 'emailVerified', 'createdAt']) && isString(request.resource.data.name, 1, 100) && isString(request.resource.data.email, 3, 320) && request.resource.data.emailVerified is bool && request.resource.data.createdAt == request.time; allow update: if isOwner(userId) && request.resource.data.diff(resource.data).changedKeys().hasOnly(['name', 'email', 'emailVerified']) && isString(request.resource.data.name, 1, 100) && isString(request.resource.data.email, 3, 320) && request.resource.data.emailVerified is bool; allow delete: if false; } match /farms/{farmId} { allow read: if isFarmMember(farmId); allow create: if signedIn() && validFarmCreate(); allow update: if isFarmOwner(farmId) && request.resource.data.diff(resource.data).changedKeys().hasOnly(['joinCode', 'joinCodeUpdatedAt']) && request.resource.data.joinCode.matches('^[A-Z2-9]{6}$') && request.resource.data.joinCodeUpdatedAt == request.time && existsAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)) && getAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)).data.farmId == farmId && getAfter(/databases/$(database)/documents/farmJoinCodes/$(request.resource.data.joinCode)).data.createdBy == request.auth.uid; allow delete: if false; } match /farmJoinCodes/{joinCode} { allow get: if signedIn() && joinCode.matches('^[A-Z2-9]{6}$'); allow list: if false; allow create: if signedIn() && joinCode.matches('^[A-Z2-9]{6}$') && request.resource.data.keys().hasOnly(['farmId', 'createdBy', 'createdAt']) && request.resource.data.createdBy == request.auth.uid && request.resource.data.createdAt == request.time && getAfter(/databases/$(database)/documents/farms/$(request.resource.data.farmId)).data.ownerId == request.auth.uid; allow update: if false; allow delete: if signedIn() && resource.data.farmId is string && get(/databases/$(database)/documents/farms/$(resource.data.farmId)).data.ownerId == request.auth.uid; } match /farmSearchIndex/{farmId} { allow read: if signedIn(); allow create, update: if signedIn() && farmId == request.resource.data.farmId && validFarmSearchIndex() && getAfter(/databases/$(database)/documents/farms/$(farmId)).data.ownerId == request.auth.uid; allow delete: if false; } match /farmAccessRequests/{accessRequestId} { allow read: if signedIn() && (resource.data.requesterId == request.auth.uid || resource.data.ownerId == request.auth.uid); allow create: if signedIn() && accessRequestId == requestId(request.resource.data.farmId, request.resource.data.requesterId) && validAccessRequestCreate(request.resource.data.farmId, request.resource.data.requesterId); allow update: if signedIn() && ( ( resource.data.ownerId == request.auth.uid && resource.data.status == 'pending' && request.resource.data.diff(resource.data).changedKeys().hasOnly(['status', 'resolvedBy', 'resolvedAt']) && request.resource.data.status in ['approved', 'denied'] && request.resource.data.resolvedBy == request.auth.uid && request.resource.data.resolvedAt == request.time ) || ( resource.data.requesterId == request.auth.uid && resource.data.status == 'pending' && request.time > resource.data.requestExpiresAt && request.resource.data.diff(resource.data).changedKeys().hasOnly(['status']) && request.resource.data.status == 'expired' ) ); allow delete: if false; } match /farmMembers/{memberDocId} { allow read: if signedIn() && resource.data.userId == request.auth.uid; allow create: if signedIn() && memberDocId == memberId(request.resource.data.farmId, request.resource.data.userId) && request.resource.data.keys().hasOnly(['farmId', 'userId', 'role', 'joinedAt', 'joinCodeUsed', 'approvedRequestId']) && request.resource.data.joinedAt == request.time && ( ( request.resource.data.role == 'owner' && request.resource.data.userId == request.auth.uid && !('joinCodeUsed' in request.resource.data) && !('approvedRequestId' in request.resource.data) && getAfter(/databases/$(database)/documents/farms/$(request.resource.data.farmId)).data.ownerId == request.auth.uid ) || ( request.resource.data.role == 'member' && request.resource.data.userId == request.auth.uid && !('approvedRequestId' in request.resource.data) && joinCodeAllowsMembership(request.resource.data) ) || ( request.resource.data.role == 'member' && isFarmOwner(request.resource.data.farmId) && !('joinCodeUsed' in request.resource.data) && approvedRequestAllowsMembership(request.resource.data) ) ); allow update: if signedIn() && resource.data.userId == request.auth.uid && request.resource.data.diff(resource.data).changedKeys().hasOnly(['joinedAt']); allow delete: if signedIn() && resource.data.userId == request.auth.uid && resource.data.role != 'owner'; } match /diagnoses/{diagnosisId} { allow read: if signedIn() && resource.data.userId == request.auth.uid; allow create: if signedIn() && request.resource.data.keys().hasOnly(['userId', 'farmId', 'crop', 'disease', 'confidence', 'detectedAt']) && request.resource.data.userId == request.auth.uid && isFarmMember(request.resource.data.farmId) && validCrop(request.resource.data.crop) && isString(request.resource.data.disease, 1, 200) && request.resource.data.confidence is number && request.resource.data.confidence >= 0 && request.resource.data.confidence <= 100 && request.resource.data.detectedAt == request.time; allow update, delete: if false; } match /reportRateLimits/{userId} { allow read: if isOwner(userId); allow create, update: if isOwner(userId) && request.resource.data.keys().hasOnly(['userId', 'lastReportAt']) && request.resource.data.userId == request.auth.uid && request.resource.data.lastReportAt == request.time; allow delete: if false; } match /cropTroubleReports/{reportId} { allow read: if signedIn(); allow create: if signedIn() && validCropTroubleCreate(); allow update: if signedIn() && ( ( resource.data.userId == request.auth.uid && request.resource.data.diff(resource.data).changedKeys().hasOnly(['status']) && request.resource.data.status in ['confirmed', 'resolved'] ) || ( request.resource.data.diff(resource.data).changedKeys().hasOnly(['seeingTooCount']) && request.resource.data.seeingTooCount == resource.data.seeingTooCount + 1 && !exists(/databases/$(database)/documents/cropTroubleReports/$(reportId)/seeingToo/$(request.auth.uid)) && getAfter(/databases/$(database)/documents/cropTroubleReports/$(reportId)/seeingToo/$(request.auth.uid)).data.userId == request.auth.uid ) || ( request.resource.data.diff(resource.data).changedKeys().hasOnly(['moderationCount']) && request.resource.data.moderationCount == resource.data.moderationCount + 1 && !exists(/databases/$(database)/documents/cropTroubleReports/$(reportId)/moderationReports/$(request.auth.uid)) && getAfter(/databases/$(database)/documents/cropTroubleReports/$(reportId)/moderationReports/$(request.auth.uid)).data.userId == request.auth.uid ) ); allow delete: if signedIn() && resource.data.userId == request.auth.uid; match /seeingToo/{userId} { allow read: if signedIn(); allow create: if isOwner(userId) && request.resource.data.keys().hasOnly(['userId', 'createdAt']) && request.resource.data.userId == request.auth.uid && request.resource.data.createdAt == request.time; allow update, delete: if false; } match /moderationReports/{userId} { allow read: if signedIn() && userId == request.auth.uid; allow create: if isOwner(userId) && request.resource.data.keys().hasOnly(['userId', 'reason', 'createdAt']) && request.resource.data.userId == request.auth.uid && isString(request.resource.data.reason, 1, 80) && request.resource.data.createdAt == request.time; allow update, delete: if false; } } match /{document=**} { allow read, write: if false; } } }