Skip to content

Instantly share code, notes, and snippets.

@samthecodingman
Last active November 4, 2017 06:29
Show Gist options
  • Save samthecodingman/3cb124bfdb6374182d15bb1e717d67b0 to your computer and use it in GitHub Desktop.
Save samthecodingman/3cb124bfdb6374182d15bb1e717d67b0 to your computer and use it in GitHub Desktop.
Defines a helper class to create Firebase Functions that handle recurring tasks that are triggered using the Firebase Realtime Database.
/*! adminTaskFunction.js | Samuel Jones 2017 | MIT License | github.com/samthecodingman */
/**
* @file Defines a helper class to create Firebase Functions that handle recurring tasks that are triggered using the Firebase Realtime Database.
* @author Samuel Jones (github.com/samthecodingman)
*/
const functions = require('firebase-functions')
const lodashGet = require('lodash').get;
// get this file at https://gist.github.com/samthecodingman/54661827f6ceeb2d44afc1c9b2c285b7
const app = require('./adminWorkerApp')
/**
* Convienience wrapper around `functions.config()` to extract sub-properties.
* @param {String} path - the desired key path
* @param {*} defaultVal - the fallback value to be returned instead of `undefined`
* @return {*} the desired value, given default value or `undefined`. Normally is a string.
*/
function getEnv(path, defaultVal) {
return lodashGet(functions.config(), path, defaultVal)
}
// declare database structure
const ADMIN_TASKS_TABLE = '/AdminTasks/Tasks'
const ADMIN_TASKS_HISTORY_TABLE = '/AdminTasks/TaskHistory'
/**
* Event handler.
* @typedef {DatabaseEventHandler}
* @type {Callback}
* @param {Event<DeltaSnapshot>} - Object containing information for this
* Firebase Realtime Database event.
*/
/**
* Admin Task Handler.
*
* If the returned value is `true` (or returned promise resolves to `true`),
* the task will be considered incomplete and the task will be retriggered.
* @typedef {TaskTriggerHandler}
* @type {Callback}
* @param {Object} - the configuration object for this admin task
* @return {(Promise<any>|any)} - value (or resolving promise containing a
* value) indicating if work is incomplete.
*/
/**
* Builder used to create Task Cloud Functions triggered by updates to the
* Firebase Realtime Database.
* @type {Object}
*/
class TaskBuilder {
/**
* Creates a new TaskBuilder instance
* @param {String} taskName - the name of the task
*/
constructor (taskName) {
this.name = taskName
}
/**
* Creates a new history entry.
* @return {Promise<String>} - a Promise containing the new run ID.
*/
saveTaskStart () {
let thenRef = app.database()
.ref(`${ADMIN_TASKS_HISTORY_TABLE}/${this.name}`)
.push({
'start': (new Date()).toISOString()
})
return thenRef.then(() => thenRef.key)
}
/**
* Saves the end of the task chain.
* @param {String} runID - the current run ID.
* @return {Promise} - a promise that resolves when the wrtie completes.
*/
saveTaskEnd (runID) {
let thisPath = `${ADMIN_TASKS_TABLE}/${this.name}`
let histRunPath = `${ADMIN_TASKS_HISTORY_TABLE}/${this.name}/${runID}`
let updateData = {}
updateData[thisPath + '/activeRunID'] = null // delete run id entry
updateData[thisPath + '/trigger'] = false // reset task state
updateData[histRunPath + '/finish'] = (new Date()).toISOString()
return app.database().ref().update(updateData)
}
/**
* Reinvoke this task chain.
* @param {String} runID - the current run ID.
* @param {Number} depth - the current task depth
* @return {Promise} - a promise that resolves when the write completes.
*/
reinvokeTask (runID, depth) {
let ref = app.database().ref(`${ADMIN_TASKS_TABLE}/${this.name}`)
let updateData = { 'trigger': depth + 1 }
if (runID) updateData.activeRunID = runID
return ref.update(updateData)
}
/**
* Event handler that fires every time an admin task is triggered in the
* Firebase Realtime Database.
* @param {TaskTriggerHandler} handler - the admin task handler
* @return {CloudFunction} - a preconfigured Database Cloud Function that
* triggers this task.
*/
onTrigger (handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler must be a non-null function.')
}
return functions.database
.ref(`${ADMIN_TASKS_TABLE}/${this.name}`)
.onUpdate((event) => {
let deltaSnap = event.data
let state = deltaSnap.child('trigger').val()
let taskName = this.name
// state is falsy --> task was completed, no further action needed.
if (!state) {
console.log(`AdminTask[${taskName}]: No action needed.`)
return
}
let depth = (state === true) ? 1 : parseInt(state)
if (isNaN(depth)) {
return Promise.reject(new Error('tasks/invalid-task-state'))
}
let configRefAsApp = app.database().ref(`${ADMIN_TASKS_TABLE}/${taskName}/config`)
let currData = deltaSnap.val()
let config = currData.config || {}
if (!config.maxDepth) {
config.maxDepth = getEnv('management.maxtaskdepth', 10)
}
if (depth > config.maxDepth) { // fail fast
return Promise.reject(new Error('tasks/task-limit-reached'))
}
if (!config.maxTaskSecs) {
config.maxTaskSecs = getEnv('management.maxtaskduration', 50)
}
defineGetter(config, 'app', () => app)
defineGetter(config, 'ref', () => configRefAsApp)
defineGetter(config, 'event', () => event)
defineGetter(config, 'depth', () => depth)
let metaDataTask = state !== true
? Promise.resolve() : this.saveTaskStart()
let handlerTask = Promise.resolve()
.then(() => handler(config)) // casts result to Promise
return Promise.all([metaDataTask, handlerTask])
.then((results) => {
let runID = results[0] // metaDataTask result
if (results[1] === true) { // handlerTask result
console.log(`AdminTask[${taskName}][${depth}]: Reinvoking...`)
return this.reinvokeTask(runID, depth)
} else {
console.log(`AdminTask[${taskName}][${depth}]: Completed.`)
return this.saveTaskEnd(runID || currData.activeRunID)
}
})
})
}
}
/**
* Initializes a new TaskBuilder instance for the given task.
* @param {String} taskName - the name of the task
* @return {TaskBuilder} - the task cloud function builder
*/
exports.task = function createTaskBuilder (taskName) {
if (!process.env.FUNCTION_NAME) { // Type check when offline
if (!taskName) {
throw new TypeError('taskName cannot be falsy.')
}
if (taskName.trim() !== taskName) {
throw new TypeError(
'taskName should not contain leading/trailing whitespace.')
}
if (/\.|\$|\[|\]|#|\//g.test(taskName)) {
throw new Error(`Invalid task name ${taskName} (cannot contain .$[]#/)`)
}
}
return new TaskBuilder(taskName)
}
/**
* Helper function for creating a getter on an object.
*
* @param {Object} obj
* @param {String} name
* @param {Function} getter
* @private
* @author expressjs/express
*/
function defineGetter(obj, name, getter) {
Object.defineProperty(obj, name, {
configurable: false,
enumerable: true,
get: getter
})
}
@samthecodingman
Copy link
Author

samthecodingman commented Nov 3, 2017

Recurring Admin Tasks using Firebase Functions

Purpose

This script allows you to define a task that performs some administrative action inside of a Firebase Cloud Function. The task can be triggered using the Firebase Realtime Database. Using the RTDB allows you to make use of the RTDB's security rules to secure your function. This prevents potential abuse of your functions like that encountered using Authorized HTTPS Endpoints where a malicious user could hammer the endpoint with hundreds of invalid requests causing your wallet to suffer.

Prerequisites

Securing your Realtime Database

The following rules configuration is recommended to maintain and trigger the tasks. It assumes that you will use adminWorkerApp.js to generate the admin worker.

{
  "rules": {
    "AdminTasks": {
      ".read": "auth != null && auth.token.isAdmin == true", // user must be an admin
      "Tasks": {
        "$taskName": {
          "trigger": {
            // User must be an admin to update the task state
            ".write": "auth != null && auth.token.isAdmin == true",
            // if an admin user, data can only be set to true/false when the old value is false
            // if the admin service worker, data can also be set to a boolean or a number
            ".validate": "(data.val() == false && newData.isBoolean()) || (auth.uid === 'admin-worker' && (newData.isBoolean() || newData.isNumber()))"
          },
          "config": {
            // User must be an admin to update task configuration
            ".write": "auth != null && auth.token.isAdmin == true"
          },
          "activeRunID": {
            // User must be the admin service worker to update this value
            ".write": "auth != null && auth.uid === 'admin-worker'",
            // Only allow strings
            ".validate": "newData.isString()"
          },
          "$other": {
            // Other keys can only be written by the admin service worker, currently unused
            ".write": "auth.uid === 'admin-worker'"
          }
        }
      },
      "TaskHistory": {
        // Task history can only be written by the admin service worker
        ".write": "auth != null && auth.uid === 'admin-worker'"
      }
    }
  }
}

Defining a Task

The config object

The config variable is an object containing data at /AdminTasks/Tasks/{taskName}/config in addition to:

  • app1: a read-only handle to the FirebaseApp instance linked with this task.
  • ref1: a read-only handle to the DatabaseReference for this configuration object.
  • depth1: a read-only value indicating the current level of recursion.
  • event1: a read-only handle to the triggering event's Event<DeltaSnapshot>.
    Note: The DeltaSnapshot points to the location /AdminTasks/Tasks/{taskName}
  • maxTaskSecs: a value indicating how long this task iteration should be allowed to run.
    Default: functions.config().management.maxtaskduration or 50 if not specified.
  • maxDepth: a value indicating how many times this task can trigger itself.
    Default: functions.config().management.maxtaskdepth or 10 if not specified.

1: These values will shadow (ignore) those specified in /AdminTasks/Tasks/{taskName}/config

Usage

Somewhere in your Firebase Functions code, declare your admin task:

const adminTaskFunction = require('./adminTaskFunction');

exports.admin_someTaskName = adminTaskFunction.task('someTaskName')
  .onTrigger((config) => {
    const adminWorkerApp = config.app;
    // do some work
    return; // return true or a promise that resolves to true to reinvoke the task
  });

Triggering a Task

To trigger a task function, write true to the location /AdminTasks/Tasks/{taskName}/trigger.

Using the Firebase Console

Know your project ID? Just replace it in this URL: https://console.firebase.google.com/u/0/project/PROJECTID/database/data/AdminTasks/Tasks

Alternatively,

  1. Open the Firebase Console
  2. Choose your project
  3. Choose the Database tab (and select Realtime Database if applicable)
  4. Navigate to AdminTasks/Tasks

At that location, type true for the value of trigger under the desired task. (e.g. at /AdminTasks/Tasks/{taskName}/trigger)

Using a Client SDK

This must be done by a user with the custom claim isAdmin if using the above security rules. See the docs for more information on how to do this.

const firebase = require('firebase')
firebase.initializeApp(...)
firebase.auth().signInWithSomehow()

const ADMIN_TASKS_REF = firebase.database().ref('AdminTasks/Tasks')

function getTaskLabelElement(taskName) {
  return document.getElementById(taskName + 'Label') // assumes element has ID of format `{taskName}Label`
}
function getTaskButtonElement(taskName) {
  return document.getElementById(taskName + 'Button') // assumes element has ID of format `{taskName}Button`
}

function createRefreshHandler(taskName) {
  return (snapshot) => {
    let isRunning = snapshot.exists()
    // update your UI for taskName - e.g. disable trigger button and show a spinner/change color
    let label = getTaskLabelElement(taskName)
    let button = getTaskButtonElement(taskName)
    if (isRunning) {
      button.disabled = true
      label.style.color = "green"
    } else {
      button.disabled = false;
      label.style.color = "inherit"
    }
  }
}

function createTaskTrigger(taskName) {
  return () => {
    ADMIN_TASKS_REF.child(taskName).child('trigger').set(true)
      .then(() => {
        // task triggered OK
      })
      .catch((err) => {
        if (err === "PERMISSION_DENIED") {
          // task is running or you don't have permission.
        }
        let e = getTaskLabelElement(taskName);
        e.style.color = "red"
        alert('Failed to trigger task ' + taskName);
      });
  }
}

const taskRefreshHandler = createRefreshHandler('someTaskName');
const taskTriggerer = createTaskTrigger('someTaskName');
ADMIN_TASKS_REF.child('someTaskName').child('activeRunID').on('value', taskRefreshHandler);
getTaskButtonElement('someTaskName').addEventListener("tap", taskTriggerer);

Example

Implementing the reap() function of connect-session-firebase as an administrator task. The task will iterate through the values of the table /sessions and mark each expired session for removal as applicable. If the function runs longer than 50 seconds, it will store the last checked key, commit the current changes and then reinvoke itself (from inside adminTaskFunction.js).

const adminTaskFunction = require('./adminTaskFunction')

// An all-or-nothing batch update helper
// Available at https://gist.github.com/samthecodingman/389102e50c0d48f314d03695acb17866
const PendingUpdate = require('./PendingUpdate')

module.exports = adminTaskFunction.task('reapSessions')
  .onTrigger((config) => {
    const now = Date.now()
    const end = now + (config.maxTaskSecs * 1000)
    const sessions = config.app.database().ref(config.sessionsTable || 'sessions')

    return (config.startAt ? sessions.startAt(config.startAt) : sessions).once('value')
      .then((snapshot) => {
        if (!snapshot.exists()) {
          return
        }

        let pendingUpdate = new PendingUpdate()
        let c = 0
        let nextStartAtKey = null

        let abortedEarly = snapshot.forEach((sessionSnapshot) => {
          let expires = sessionSnapshot.child('expires').val();
          if (expires < now) {
            if (expires != null) {
              pendingUpdate.addRemove(sessions.child(sessionSnapshot.key))
            } else {
              // Data safety precaution: Don't delete if 'expires' doesn't exist
              console.error(`WARNING: Session #${sessionSnapshot.key} is missing 'expires'. Skipping value deletion.`)
            }
          }

          if (c++ > 10) { // check infrequently.
            if (Date.now() > end) {
              nextStartAtKey = sessionSnapshot.key
              return true // abort batch early
            }
            c = 0
          }
        })

        if (abortedEarly) {
          pendingUpdate.addSet(config.ref.child('startAt'), nextStartAtKey);
        } else {
          pendingUpdate.addRemove(config.ref.child('startAt'));
        }

        return pendingUpdate.commit().then(() => abortedEarly) // commit changes and indicate if more work is needed
      })
  })

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment