diff --git a/.gitignore b/.gitignore index b6cc2831543..13f1f26ab65 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ public/js/frame-runner* public/css/main* server/rev-manifest.json +google-credentials.json diff --git a/common/models/challenge.json b/common/models/challenge.json index e4cbd3b4a82..2ad70bfacbd 100644 --- a/common/models/challenge.json +++ b/common/models/challenge.json @@ -108,22 +108,24 @@ "default": "{}" }, "required": { - "type": [{ - "type": { - "link": { - "type": "string", - "description": "Used for css files" - }, - "src": { - "type": "string", - "description": "Used for script files" - }, - "crossDomain": { - "type": "boolean", - "description": "Files coming from FreeCodeCamp must mark this true" + "type": [ + { + "type": { + "link": { + "type": "string", + "description": "Used for css files" + }, + "src": { + "type": "string", + "description": "Used for script files" + }, + "crossDomain": { + "type": "boolean", + "description": "Files coming from FreeCodeCamp must mark this true" + } } } - }], + ], "default": [] } }, diff --git a/common/models/flyer.json b/common/models/flyer.json index 67f3e41a921..fd5b6c51722 100644 --- a/common/models/flyer.json +++ b/common/models/flyer.json @@ -13,9 +13,7 @@ } }, "validations": [], - "relations": { - - }, + "relations": {}, "acls": [ { "accessType": "*", @@ -30,5 +28,5 @@ "permission": "ALLOW" } ], - "methods": [] + "methods": {} } diff --git a/common/models/user.json b/common/models/user.json index 3e7cd63572b..5e44519f297 100644 --- a/common/models/user.json +++ b/common/models/user.json @@ -122,8 +122,8 @@ }, "currentChallengeId": { "type": "string", - "default": "", - "description": "The challenge last visited by the user" + "description": "The challenge last visited by the user", + "default": "" }, "currentChallenge": { "type": {}, diff --git a/package.json b/package.json index 5e808454891..bb72097a7d9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "fetchr": "~0.5.12", "frameguard": "^3.0.0", "gitter-sidecar": "^1.2.3", + "googleapis": "16.1.0", "helmet": "^3.1.0", "helmet-csp": "^2.1.0", "history": "^3.2.1", diff --git a/server/model-config.json b/server/model-config.json index 2bbec7e0a5d..4cc2d87ccfe 100644 --- a/server/model-config.json +++ b/server/model-config.json @@ -74,5 +74,9 @@ "block": { "dataSource": "db", "public": true + }, + "about": { + "dataSource": "db", + "public": true } } diff --git a/server/models/about.js b/server/models/about.js new file mode 100644 index 00000000000..58be09b0e4f --- /dev/null +++ b/server/models/about.js @@ -0,0 +1,25 @@ +import { createActiveUsers } from '../utils/about.js'; + + +module.exports = function(About) { + const activeUsers = createActiveUsers(); + About.getActiveUsers = function getActiveUsers() { + // converting to promise automatically will subscribe to Observable + // initiating the sequence above + return activeUsers.toPromise(); + }; + + About.remoteMethod( + 'getActiveUsers', + { + http: { + path: '/get-active-users', + verb: 'get' + }, + returns: { + type: 'number', + arg: 'activeUsers' + } + } + ); +}; diff --git a/server/models/about.json b/server/models/about.json new file mode 100644 index 00000000000..4587c06c424 --- /dev/null +++ b/server/models/about.json @@ -0,0 +1,28 @@ +{ + "name": "about", + "plural": "about", + "base": "PersistedModel", + "idInjection": true, + "options": { + "validateUpsert": true + }, + "properties": {}, + "validations": [], + "relations": {}, + "acls": [ + { + "accessType": "*", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "DENY" + }, + { + "accessType": "EXECUTE", + "principalType": "ROLE", + "principalId": "$everyone", + "permission": "ALLOW", + "property": "getActiveUsers" + } + ], + "methods": {} +} diff --git a/server/utils/about.js b/server/utils/about.js new file mode 100644 index 00000000000..feab0a519a7 --- /dev/null +++ b/server/utils/about.js @@ -0,0 +1,95 @@ +import _ from 'lodash'; +import debug from 'debug'; +import dedent from 'dedent'; +import fs from 'fs'; +import goog from 'googleapis'; +import { Observable } from 'rx'; + +import { timeCache, observeMethod } from './rx'; + +// one million! +const upperBound = 1000 * 1000; +const scope = 'https://www.googleapis.com/auth/analytics.readonly'; +const pathToCred = process.env.GOOGLE_APPLICATION_CREDENTIALS; + +const log = debug('fcc:server:utils:about'); +const analytics = goog.analytics('v3'); +const makeRequest = observeMethod(analytics.data.realtime, 'get'); +export const toBoundInt = _.flow( + // first convert string to integer + _.toInteger, + // then we bound the integer to prevent weird things like Infinity + // and negative numbers + // can't wait to the day we need to update this! + _.partialRight(_.clamp, 0, upperBound) +); + +export function createActiveUsers() { + const zero = Observable.of(0); + let credentials; + if (!pathToCred) { + // if no path to credentials set to zero; + log(dedent` + no google applications credentials environmental variable found + 'GOOGLE_APPLICATION_CREDENTIALS' + 'activeUser' api will always return 0 + this can safely be ignored during development + `); + return zero; + } + try { + credentials = require(fs.realpathSync(pathToCred)); + } catch (err) { + log('google applications credentials file failed to require'); + console.error(err); + // if we can't require credentials set to zero; + return zero; + } + if ( + !credentials.private_key || + !credentials.client_email || + !credentials.viewId + ) { + log(dedent` + google applications credentials json should have a + * private_key + * client_email + * viewId + but none were found + `); + return zero; + } + + const client = new goog.auth.JWT( + credentials['client_email'], + null, + credentials['private_key'], + [scope], + ); + const authorize = observeMethod(client, 'authorize'); + const options = { + ids: `ga:${credentials.viewId}`, + auth: client, + metrics: 'rt:activeUsers' + }; + return Observable.defer( + // we wait for authorize to complete before attempting to make request + // this ensures our token is initialized and valid + // we defer here to make sure the actual request is done per subscription + // instead of once at startup + () => authorize().flatMap(() => makeRequest(options)) + ) + // data: Array[body|Object, request: Request] + .map(data => data[0]) + .map( + ({ totalsForAllResults } = {}) => totalsForAllResults['rt:activeUsers'] + ) + .map(toBoundInt) + // print errors to error log for logging, duh + .do(null, err => console.error(err)) + // always send a number down + .catch(() => Observable.of(0)) + // cache for 2 seconds to prevent hitting our daily request limit + ::timeCache(2, 'seconds'); +} +