0

I'm looking for a way to prevent writing more than a given limit of documents to a (sub)collection in a given periode.

For example: Messenger A is not allowed to write more then 1000 Messages per 24 hours.

This should be done in the context of an Firebase Function API endpoint because it's called by third parties.

The endpoint

app.post('/message', async function(req:any, res:any) {
        // get the messenger's API key
        const key = req.query.key

        // if no key was provided, return error
        if(!key) {
            res.status(400).send('Please provide a valid key')
            return
        }

        // get the messenger by the provided key
        const getMessengerResult = await admin.firestore().collection('messengers')
            .where('key', '==', key).limit(1).get()

        // if there is no result the messenger is not valid, return error
        if (getMessengerResult.empty){
            res.status(400).send('Please provide a valid key')
            return
        }

        // get the messenger from the result
        const messenger = getMessengerResult.docs[0]

        // TODO: check if messenger hasn't reached limit of 1000 messages per 24 hours

        // get message info from the body
        const title:String = req.body.title
        const body: String = req.body.body

        // add message
        await messenger.ref.collection('messages').add({
            'title':title,
            'body':body,
            'timestamp': Timestamp.now()
        })

        // send response
        res.status(201).send('The notification has been created');
    })

One thing I've tried was the following piece of code in place of the TODO::

// get the limit message and validate its timestamp
const limitMessageResult = await messenger.ref.collection('messages')
    .orderBy('timestamp',"desc").limit(1).offset(1000).get()

if(!limitMessageResult.empty) {
    const limitMessage = limitMessageResult.docs[0]
    const timestamp: Timestamp = limitMessage.data()['timestamp']

    // create a date object for 24 hours ago
    const 24HoursAgo = new Date()
    24HoursAgo.setDate(24HoursAgo.getDate()-1)

    if(24HoursAgo < timestamp.toDate()) {
        res.status(405).send('You\'ve exceeded your messages limit, please try again later!')
        return
    }
}

This code works, but there is a big BUT. The offset does indeed skip the 1000 results, but Firebase will still charge you for it! So every time the messenger tries to add 1 message, 1000+ are read... and that's costly.

So I need a better (cheaper) way to do this.

One thing I've come up with, but haven't yet tried would be adding an index/counter field to a message that increases by 1 every message. Then instead of doing:

const limitMessageResult = await messenger.ref.collection('messages')
    .orderBy('timestamp',"desc").limit(1).offset(1000).get()

I could do something like:

const limitMessageResult = await messenger.ref.collection('messages')
    .where('index','==', currentIndex-1000).limit(1).get()

But I was wondering if that would be a save way. For example, what would happen if there are multiple request at the same time. I would first need to get the current index from the last message and add the new message with index+1. But could two requests read, and thus write the same index? Or could this be handled with transactions?

Or is there a totally different way to solve my problem?

SEG.Veenstra
  • 718
  • 1
  • 7
  • 18
  • Is there a specific reason for writing the `Messages` through a Cloud Function? If you can write them through one of the client SDKs, a solution based on security rules exists. – Renaud Tarnec Apr 09 '20 at 11:49
  • "limit a Messenger for adding new Messages to prevent him from spamming." See my answer here: https://stackoverflow.com/questions/56487578/how-do-i-implement-a-write-rate-limit-in-cloud-firestore-security-rules If that doesn't work for you, show exactly what you tried, in a minimal block of code and rules. – Frank van Puffelen Apr 09 '20 at 13:54
  • @FrankvanPuffelen Hi Frank, that's exactly the solution I had in mind while writing my comment above. But the OP is using a Cloud Function to write to Firestore, therefore the Security Rules are bypassed. – Renaud Tarnec Apr 09 '20 at 14:09
  • Oops, reopened. With Cloud Functions the security rules are indeed bypassed, so you'll have to implement the rate limit in code. @SEG: I highly recommend replacing the lists with the actual code and data, as it's quite hard to parse the intermixed code and text right now. – Frank van Puffelen Apr 09 '20 at 14:16
  • @Frank I've indeed seen that answer but I need to use the function because it's not being called from my client. I will see if I can add some code. – SEG.Veenstra Apr 09 '20 at 15:49
  • Also, the linked answer is just checking the last send message, that's easy. I need to know if an x-amount has been send in periode y, without draining my bank account. – SEG.Veenstra Apr 09 '20 at 15:58

1 Answers1

1

I have a strong aversion against using offset() in my server-side code, precisely because it makes it seem like it's skipping documents, where it's actually reading-and-discarding them.

The simplest way I can think of to implement your maximum-writes-per-day count is to keep a writes-per-day counter for each messenger, that you then update whenever they write a message.

For example, you could do the following whenever you write a message:

await messenger.ref.collection('messages').add({
    'title':title,
    'body':body,
    'timestamp': Timestamp.now()
})
const today = new Date().toISOString().substring(0, 10); // e.g. "2020-04-11"
await messenger.ref.set({
  [today]: admin.firestore.FieldValue.increment(1)
}, { merge: true })

So this adds an additional field to your messenger document for each day, whee it then keeps a count of the number of messages that messenger has written for that day.

You'd then use this count instead of your current limitMessageResult.

const messageCount = (await messenger.get()).data()[today] || 0;
if (messageCount < 1000) {
  ... post the message and increase the counter
}
else {
  ... reject the message, and return a message
}

Steps left to do:

  • You'll want to secure write access to the counter fields, as the messenger shouldn't be able to modify these on their own.
  • You may want to clean out older message counts periodically, if you're worried about the messenger's document becoming too big. I prefer to leave these types of counters, as they give an opportunity to provide some stats cheaply if needed.
Frank van Puffelen
  • 565,676
  • 79
  • 828
  • 807
  • I like the answer and it seems like a good solution. Am I correct to think that performing this action should also be done in a transaction? – SEG.Veenstra Apr 11 '20 at 17:12
  • 1
    The increment is atomic already, but if you want both message add and update of the count to be atomic, you can use a [batch write](https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes) for that. I don't think you need a transaction, since you're not writing anything that you first need to read (thanks to the increment operator). – Frank van Puffelen Apr 11 '20 at 17:16
  • Also keeping count per day is not exactly the same as what I was looking for. If a messenger decides to send 1000 messages at 23:59 the day before and another 1000 at 00:01 the current day that would be valid for your solution. I think having the moving period will allow better distribution. – SEG.Veenstra Apr 11 '20 at 17:19
  • ah yes I heard Todd mention something like that in the Firebase video I watched. – SEG.Veenstra Apr 11 '20 at 17:22
  • Whatever aggregation method you end up using, it'll still take the same approach as I described here to reduce the number of reads required. – Frank van Puffelen Apr 11 '20 at 17:34
  • How would your approach work for my situation where the periode is moving? Isn't the suggestion I have on the end a valid approach, if not, could you tell me whats wrong with it? – SEG.Veenstra Apr 11 '20 at 19:22
  • 1
    You could simply write the timestamp of each message into the user document, and then aggregate those in your code. It'd still require only one document read (vs the 1000 you now have), but would require more code of course. – Frank van Puffelen Apr 11 '20 at 19:27
  • Okay, thanks a lot, I will experiment with the different solutions and see what fits best! – SEG.Veenstra Apr 12 '20 at 08:10
  • 1
    I went with your solution to track the count per day, instead of over the last 24 hours. I specially liked your argument of being able to use them as 'stats'. I think it will be nice to give the messengers some insight on their behaviour. Because I don't wish to remove the older counters I store them in a new document per day on advice of the documentation. So a messenger now has a sub-collection of `messages-counters`. Because I needed to perform more actions I was not able to use the increment so I used a transaction, which works great. – SEG.Veenstra Apr 14 '20 at 12:13