From b423f020c365510ed3cc8c9818a7ebd117f9ddc1 Mon Sep 17 00:00:00 2001 From: Kyle Baran Date: Tue, 5 Sep 2017 19:09:13 -0700 Subject: [PATCH] Initial commit. --- .gitignore | 2 + LICENSE | 21 ++ README.md | 10 + eslint.json | 12 + fixtures/maps/README.md | 9 + fixtures/maps/github.json | 27 ++ fixtures/maps/google_analytics.json | 45 +++ fixtures/maps/google_sign_in.json | 63 ++++ fixtures/maps/postman.json | 20 ++ fixtures/maps/statuscake.json | 31 ++ gulpfile.js | 328 +++++++++++++++++++ lib/index.js | 255 +++++++++++++++ lib/intents/github-issues.js | 46 +++ lib/intents/github-meta.js | 16 + lib/intents/google-analytics.js | 46 +++ lib/intents/index.js | 10 + lib/intents/postman-monitor.js | 45 +++ lib/intents/statuscake-alerts.js | 38 +++ lib/package.json | 11 + package.json | 42 +++ src/index.js | 52 +++ src/middleware/authentication.js | 37 +++ src/models/sql/association-sessions.js | 100 ++++++ src/models/sql/sessions.js | 99 ++++++ src/models/sql/users.js | 166 ++++++++++ src/package.json | 11 + src/templates/home.html | 256 +++++++++++++++ src/views/complete_login.js | 213 ++++++++++++ src/views/complete_service.js | 89 +++++ src/views/connections.js | 175 ++++++++++ src/views/home.js | 89 +++++ src/views/index.js | 13 + src/views/login.js | 98 ++++++ src/views/logout.js | 96 ++++++ src/views/signup.js | 99 ++++++ src/views/users.js | 171 ++++++++++ static/less/mixins/boxed-group.less | 30 ++ static/less/mixins/code.less | 16 + static/less/mixins/colors.less | 23 ++ static/less/mixins/controls.less | 432 +++++++++++++++++++++++++ static/less/mixins/flexbox.less | 128 ++++++++ static/less/mixins/fonts.less | 39 +++ static/less/mixins/layout.less | 19 ++ static/less/mixins/media.less | 20 ++ static/less/mixins/mixins.less | 120 +++++++ static/less/mixins/paragraphing.less | 51 +++ static/less/site.less | 132 ++++++++ static/less/theme.less | 22 ++ tutorial/step-1.md | 125 +++++++ tutorial/step-2.md | 16 + tutorial/step-3.md | 106 ++++++ tutorial/step-4.md | 114 +++++++ tutorial/step-5.md | 127 ++++++++ 53 files changed, 4361 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 eslint.json create mode 100644 fixtures/maps/README.md create mode 100644 fixtures/maps/github.json create mode 100644 fixtures/maps/google_analytics.json create mode 100644 fixtures/maps/google_sign_in.json create mode 100644 fixtures/maps/postman.json create mode 100644 fixtures/maps/statuscake.json create mode 100644 gulpfile.js create mode 100644 lib/index.js create mode 100644 lib/intents/github-issues.js create mode 100644 lib/intents/github-meta.js create mode 100644 lib/intents/google-analytics.js create mode 100644 lib/intents/index.js create mode 100644 lib/intents/postman-monitor.js create mode 100644 lib/intents/statuscake-alerts.js create mode 100644 lib/package.json create mode 100644 package.json create mode 100644 src/index.js create mode 100644 src/middleware/authentication.js create mode 100644 src/models/sql/association-sessions.js create mode 100644 src/models/sql/sessions.js create mode 100644 src/models/sql/users.js create mode 100644 src/package.json create mode 100644 src/templates/home.html create mode 100644 src/views/complete_login.js create mode 100644 src/views/complete_service.js create mode 100644 src/views/connections.js create mode 100644 src/views/home.js create mode 100644 src/views/index.js create mode 100644 src/views/login.js create mode 100644 src/views/logout.js create mode 100644 src/views/signup.js create mode 100644 src/views/users.js create mode 100644 static/less/mixins/boxed-group.less create mode 100644 static/less/mixins/code.less create mode 100644 static/less/mixins/colors.less create mode 100644 static/less/mixins/controls.less create mode 100644 static/less/mixins/flexbox.less create mode 100644 static/less/mixins/fonts.less create mode 100644 static/less/mixins/layout.less create mode 100644 static/less/mixins/media.less create mode 100644 static/less/mixins/mixins.less create mode 100644 static/less/mixins/paragraphing.less create mode 100644 static/less/site.less create mode 100644 static/less/theme.less create mode 100755 tutorial/step-1.md create mode 100755 tutorial/step-2.md create mode 100644 tutorial/step-3.md create mode 100644 tutorial/step-4.md create mode 100755 tutorial/step-5.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..df74064 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 BitScoop Labs, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..09a6f07 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# BitScoop Ops Buddy Demo + +This demo will show you how to set up your own copy of the BitScoop Ops Buddy demo. +The app that is set up allows users to create an account through a web app using a Google account for signup. +In their Ops Buddy account, they can configure various services about which they want to retrieve information. +The demo also sets up an Alexa skill. +This skill, after being linked with the same Google account used to sign up for Ops Buddy, can be queried to retrieve up-to-date information about the services configured in the Ops Buddy account. + +We provide a public copy of this demo at www.opsbuddy.bitscoop.com, as well as a publicly-available Alexa skill called 'BitScoop Ops Buddy', if you want to see the demo in action without having to set up a copy of your own. +Follow the final sections of steps 4 and 5 of the tutorial to create an Ops Buddy account, configure it, and then install and link the Alexa Skill. diff --git a/eslint.json b/eslint.json new file mode 100644 index 0000000..9ccc98f --- /dev/null +++ b/eslint.json @@ -0,0 +1,12 @@ +{ + "env": { + "es6": true, + "node": true + }, + + "extends": "eslint:recommended", + + "rules": { + "no-console": "off" + } +} diff --git a/fixtures/maps/README.md b/fixtures/maps/README.md new file mode 100644 index 0000000..967e9c2 --- /dev/null +++ b/fixtures/maps/README.md @@ -0,0 +1,9 @@ +# BitScoop Alexa Demo API Maps + +| API Map | File Name | | +|----------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Postman Pro API Monitors | postman.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/postman.json) | +| Google Analytics Data | google_analytics.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/google_analytics.json) | +| Google Sign-In | google_sign_in.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/google_sign_in.json) | +| StatusCake Health Alerts | statuscake.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/statuscake.json) | +| GitHub Issues | github.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/github.json) | diff --git a/fixtures/maps/github.json b/fixtures/maps/github.json new file mode 100644 index 0000000..b082a9e --- /dev/null +++ b/fixtures/maps/github.json @@ -0,0 +1,27 @@ +{ + "version": "1.0", + + "name": "GitHub Issues (BitScoop Alexa Demo)", + "url": "https://api.github.com", + + "endpoints": { + "Issues": { + "GET": { + "method": "GET", + "route": "/repos/{{ parameters.user }}/{{ parameters.repo }}/issues", + "parameters": { + "per_page": 100 + }, + "model": { + "key": "id", + "fields": { + "id": "integer", + "number": "integer", + "url": "string", + "repository_url": "string" + } + } + } + } + } +} diff --git a/fixtures/maps/google_analytics.json b/fixtures/maps/google_analytics.json new file mode 100644 index 0000000..688fb51 --- /dev/null +++ b/fixtures/maps/google_analytics.json @@ -0,0 +1,45 @@ +{ + "version": "1.0", + + "name": "Google Analytics & Sign-In (BitScoop Alexa Demo)", + "url": "https://www.googleapis.com/analytics/v3", + "auth": { + "type": "oauth2", + "redirect_url": "https://google.com", + "authorization_url": "https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force", + "access_token": "https://accounts.google.com/o/oauth2/token", + "signature": "parameter", + "auth_key": "*** INSERT YOUR AUTH KEY HERE ***", + "auth_secret": "*** INSERT YOUR AUTH SECRET HERE ***" + }, + + "endpoints": { + "Metrics": { + "GET": { + "method": "GET", + "scopes": [ + "https://www.googleapis.com/auth/analytics.readonly" + ], + "route": "/data/ga", + "parameters": { + "ids": { + "value": "ga:{{ parameters.view_id }}", + "required": true + }, + "start-date": { + "value": "1daysAgo", + "required": true + }, + "end-date": { + "value": "today", + "required": true + }, + "metrics": { + "value": "ga:users,ga:newUsers", + "required": true + } + } + } + } + } +} diff --git a/fixtures/maps/google_sign_in.json b/fixtures/maps/google_sign_in.json new file mode 100644 index 0000000..b5e8f8f --- /dev/null +++ b/fixtures/maps/google_sign_in.json @@ -0,0 +1,63 @@ +{ + "version": "1.0", + + "name": "Google Sign-In (BitScoop Alexa Demo)", + "url": "https://www.googleapis.com/analytics/v3", + "auth": { + "type": "oauth2", + "redirect_url": "https://google.com", + "authorization_url": "https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force", + "access_token": "https://accounts.google.com/o/oauth2/token", + "signature": "parameter", + "auth_key": "*** INSERT YOUR AUTH KEY HERE ***", + "auth_secret": "*** INSERT YOUR AUTH SECRET HERE ***" + }, + + "meta": { + "uniqueness_location": "id", + + "endpoint": { + "scopes": [ + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile" + ], + "route": { + "path": "https://people.googleapis.com/v1/people/me?requestMask.includeField=person.email_addresses,person.names" + }, + "model": { + "key": "id", + "fields": { + "id": { + "type": "string", + "source": "resourceName" + }, + "names": { + "type": "embedded", + "many": true, + "fields": { + "first_name": { + "type": "string", + "source": "givenName" + }, + "last_name": { + "type": "string", + "source": "familyName" + } + } + }, + "emails": { + "type": "embedded", + "many": true, + "fields": { + "address": { + "type": "string", + "source": "value" + } + }, + "source": "emailAddresses" + } + } + } + } + } +} diff --git a/fixtures/maps/postman.json b/fixtures/maps/postman.json new file mode 100644 index 0000000..b1b688c --- /dev/null +++ b/fixtures/maps/postman.json @@ -0,0 +1,20 @@ +{ + "version": "1.0", + + "name": "Postman Monitor Alerts (BitScoop Alexa Demo)", + "url": "https://api.getpostman.com", + "headers": { + "X-Api-Key": "{{ parameters.api_key }}" + }, + + "endpoints": { + "RunMonitor": { + "POST": { + "method": "POST", + "route": { + "path": "/monitors/{{ parameters.monitor_id }}/run" + } + } + } + } +} diff --git a/fixtures/maps/statuscake.json b/fixtures/maps/statuscake.json new file mode 100644 index 0000000..3ca6889 --- /dev/null +++ b/fixtures/maps/statuscake.json @@ -0,0 +1,31 @@ +{ + "version": "1.0", + + "name": "StatusCake Alerts (BitScoop Alexa Demo)", + "url": "https://app.statuscake.com", + + "headers": { + "API": "{{ parameters.api_key }}", + "Username": "{{ parameters.username }}" + }, + + "endpoints": { + "Alerts": { + "GET": { + "method": "GET", + "route": { + "data": "items", + "path": "/API/Alerts" + }, + "parameters": { + "TestID": { + "value": "{{ parameters.test_id }}" + }, + "Since": { + "value": "{{ parameters.since }}" + } + } + } + } + } +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..2ce81d0 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,328 @@ +'use strict'; + +const gulp = require('gulp'); +const sequence = require('run-sequence'); + + +let pkg = require('./package.json'); +let banner = '/**\n * Copyright (c) ${new Date().getFullYear()} ${pkg.author}\n * All rights reserved.\n */\n'; + +let date = new Date().getTime(); + + +gulp.task('default', function(done) { + sequence('devel', 'watch', done); +}); + + +gulp.task('build', function(done) { + sequence('lint', 'clean', ['copy:assets', 'copy:templates', 'nunjucks', 'less', 'uglify'], 'snapshot', done); +}); + + +gulp.task('bundle:frontend', function() { + const path = require('path'); + + const zip = require('gulp-zip'); + const rename = require('gulp-rename'); + + let basename = path.basename(process.cwd()); + let renameExpression = new RegExp('^' + basename); + + return gulp.src([ + 'src/**' + ], { + base: 'src', + nodir: true + }) + .pipe(rename(function(path) { + path.dirname = path.dirname.replace(renameExpression, pkg.name); + + return path; + })) + .pipe(zip(pkg.name + '-frontend-' + pkg.version + '.zip')) + .pipe(gulp.dest('dist')); +}); + + +gulp.task('bundle:backend', function() { + const path = require('path'); + + const zip = require('gulp-zip'); + const rename = require('gulp-rename'); + + let basename = path.basename(process.cwd()); + let renameExpression = new RegExp('^' + basename); + + return gulp.src([ + 'lib/**' + ], { + base: 'lib', + nodir: true + }) + .pipe(rename(function(path) { + path.dirname = path.dirname.replace(renameExpression, pkg.name); + + return path; + })) + .pipe(zip(pkg.name + '-backend-' + pkg.version + '.zip')) + .pipe(gulp.dest('dist')); +}); + + +gulp.task('clean', function() { + const clean = require('gulp-clean'); + + return gulp.src([ + 'artifacts/', + 'dist/', + 'dump/' + ], { + read: false + }) + .pipe(clean({ + force: true + })); +}); + + +gulp.task('copy:assets', function() { + return gulp.src([ + 'static/**/*', + '!static/**/*.js', + '!static/**/*.less' + ], { + nodir: true + }) + .pipe(gulp.dest('artifacts/')); +}); + + +gulp.task('copy:templates', function() { + return gulp.src([ + 'templates/**' + ]) + .pipe(gulp.dest('dist/static/' + date)) +}); + + +gulp.task('devel', function(done) { + sequence('clean', ['copy:assets', 'copy:templates', 'less', 'uglify:devel'], done); +}); + + +gulp.task('less', function() { + const LessAutoPrefix = require('less-plugin-autoprefix'); + const cleanCSS = require('gulp-clean-css'); + const header = require('gulp-header'); + const less = require('gulp-less'); + const rename = require('gulp-rename'); + + return gulp.src([ + 'static/less/site.less' + ]) + .pipe(less({ + plugins: [ + new LessAutoPrefix({ + browsers: ['last 3 versions', 'ie 11', 'ie 10'] + }) + ] + })) + .pipe(cleanCSS()) + .pipe(rename({ + extname: '.min.css' + })) + .pipe(header(banner, { + pkg: pkg + })) + .pipe(gulp.dest('artifacts/css')); +}); + + +gulp.task('lint', ['lint:js', 'lint:json']); + + +gulp.task('lint:js', function() { + const eslint = require('gulp-eslint'); + + return gulp.src([ + '*.js', + 'static/**/*.js', + 'src/**/*.js', + 'test/**/*.js', + '!node_modules/**/*.js', + '!src/node_modules/**/*.js', + '!static/lib/**/*.js' + ]) + .pipe(eslint({ + configFile: 'eslint.json' + })) + .pipe(eslint.formatEach()); +}); + + +gulp.task('lint:json', function() { + const jsonlint = require('gulp-jsonlint'); + + return gulp.src([ + '*.json', + 'src/**/*.json', + 'fixtures/**/*.json' + ]) + .pipe(jsonlint()) + .pipe(jsonlint.failOnError()) + .pipe(jsonlint.reporter()); +}); + + +gulp.task('lint:less', function() { + const lesshint = require('gulp-lesshint'); + + return gulp.src([ + 'static/**/*.less' + ]) + .pipe(lesshint()) + .pipe(lesshint.failOnError()) + .pipe(lesshint.reporter()); +}); + + +gulp.task('nunjucks', function() { + const path = require('path'); + + const concat = require('gulp-concat'); + const gutil = require('gulp-util'); + const header = require('gulp-header'); + const nunjucks = require('gulp-nunjucks'); + const rename = require('gulp-rename'); + const uglify = require('gulp-uglify'); + + return gulp.src([ + 'nunjucks/**/*.html' + ]) + .pipe(nunjucks.precompile({ + env: (function(nunjucks) { + var environment; + + environment = new nunjucks.Environment(); + + environment.addFilter('get', function() {}); + environment.addFilter('date', function() {}); + environment.addFilter('fileSize', function() {}); + + return environment; + })(require('nunjucks')), + + name: (function() { + let delimiter, names; + + delimiter = 'nunjucks' + path.sep; + names = {}; + + return function(file) { + let filename = file.path; + let i = filename.indexOf(delimiter); + let template = ~i ? filename.slice(i + delimiter.length) : template.replace(new RegExp(path.sep, 'g'), '/'); + + if (names.hasOwnProperty(template)) { + gutil.log('Name collison on nunjucks template "' + template + '":\n\tOld: ' + names[template] + '\n\tNew: ' + filename); + } + + names[template] = filename; + + return template; + }; + })() + })) + .pipe(concat('templates.js')) + .pipe(uglify()) + .pipe(header(banner, { + pkg: pkg + })) + .pipe(rename({ + extname: '.min.js' + })) + .pipe(gulp.dest('artifacts/js')); +}); + + +gulp.task('snapshot', function() { + return gulp.src([ + 'artifacts/**' + ]) + .pipe(gulp.dest('dist/static/' + date)); +}); + + +gulp.task('uglify', function() { + const addsrc = require('gulp-add-src'); + const babel = require('gulp-babel'); + const header = require('gulp-header'); + const rename = require('gulp-rename'); + const uglify = require('gulp-uglify'); + + return gulp.src([ + 'static/**/*.js', + '!static/js/site2.js', + '!static/lib/requirejs/**/*.js' + ]) + .pipe(babel({ + presets: ['es2015'], + plugins: ['transform-es2015-modules-amd'] + })) + .pipe(addsrc([ + 'static/lib/requirejs/**/*.js' + ], { + base: 'static' + })) + .pipe(uglify()) + .pipe(header(banner, { + pkg: pkg + })) + .pipe(rename({ + extname: '.min.js' + })) + .pipe(gulp.dest('artifacts')); +}); + + +gulp.task('uglify:devel', function() { + const addsrc = require('gulp-add-src'); + const babel = require('gulp-babel'); + const rename = require('gulp-rename'); + + return gulp.src([ + 'static/**/*.js', + '!static/lib/requirejs/**/*.js' + ]) + .pipe(babel({ + presets: ['es2015'], + plugins: ['transform-es2015-modules-amd'] + })) + .pipe(addsrc([ + 'static/lib/requirejs/**/*.js' + ], { + base: 'static' + })) + .pipe(rename({ + extname: '.min.js' + })) + .pipe(gulp.dest('artifacts')); +}); + + +gulp.task('watch', function() { + gulp.watch([ + 'static/**/*.js' + ], ['uglify:devel']) + .on('error', function() { + this.emit('end'); + }); + + gulp.watch([ + 'static/**/*.less' + ], ['less']) + .on('error', function() { + this.emit('end'); + }); +}); diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..372f8ff --- /dev/null +++ b/lib/index.js @@ -0,0 +1,255 @@ +'use strict'; + +const assert = require('assert'); + +const Alexa = require('alexa-sdk'); +const BitScoop = require('bitscoop-sdk'); +const Sequelize = require('sequelize'); +const request = require('request'); + +const intents = require('./intents'); + + +global.env = { + name: 'BitScoop', + bitscoop: new BitScoop(process.env.BITSCOOP_API_KEY) +}; + + +var handlers = { + StackIntent: function() { + let sequelize, users; + let self = this; + + Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + assert(process.env.BITSCOOP_API_KEY != null, 'Unspecified BitScoop API key.'); + assert(process.env.ALEXA_APP_ID != null, 'Unspecified Alexa app ID.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + return Promise.resolve(); + }) + .then(function() { + let accessToken = self.event.session.user.accessToken; + + if (accessToken) { + return new Promise(function(resolve, reject) { + request({ + url: 'https://people.googleapis.com/v1/people/me?requestMask.includeField=person.email_addresses,person.names', + qs: { + access_token: accessToken + } + }, function(err, response) { + if (err) { + reject(new Error('I ran into some issues authenticating you with Google')); + } + + resolve(JSON.parse(response.body)); + }); + }); + } + else { + return Promise.reject(new Error('No access token')); + } + }) + .then(function(response) { + users = sequelize.define('user', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: Sequelize.STRING + }, + githubUser: { + type: Sequelize.STRING, + field: 'github_user' + }, + githubRepo: { + type: Sequelize.STRING, + field: 'github_repo' + }, + githubEnabled: { + type: Sequelize.BOOLEAN, + field: 'github_enabled' + }, + googleId: { + type: Sequelize.STRING, + field: 'google_id' + }, + googleAnalyticsConnectionId: { + type: Sequelize.STRING, + field: 'google_analytics_connection_id' + }, + googleViewId: { + type: Sequelize.STRING, + field: 'google_view_id' + }, + googleEnabled: { + type: Sequelize.BOOLEAN, + field: 'google_enabled' + }, + postmanId: { + type: Sequelize.STRING, + field: 'postman_id' + }, + postmanApiKey: { + type: Sequelize.STRING, + field: 'postman_api_key' + }, + postmanEnabled: { + type: Sequelize.BOOLEAN, + field: 'postman_enabled' + }, + statuscakeId: { + type: Sequelize.STRING, + field: 'statuscake_id' + }, + statuscakeEnabled: { + type: Sequelize.BOOLEAN, + field: 'statuscake_enabled' + }, + statuscakeApiKey: { + type: Sequelize.STRING, + field: 'statuscake_api_key' + }, + statuscakeUsername: { + type: Sequelize.STRING, + field: 'statuscake_username' + }, + email: { + type: Sequelize.STRING + }, + upperEmail: { + type: Sequelize.STRING, + field: '_upper_email' + }, + joined: { + type: Sequelize.DATE + }, + accountConnectionId: { + type: Sequelize.STRING, + field: 'account_connection_id' + } + }, { + timestamps: false + }); + + return users.sync() + .then(function() { + return users.find({ + where: { + googleId: response.resourceName + } + }); + }); + }) + .then(function(user) { + let promises = []; + + if (user.googleEnabled === true) { + promises.push(intents.googleAnalytics(user)); + } + + if (user.statuscakeEnabled === true) { + promises.push(intents.statuscakeAlerts(user)); + } + + if (user.postmanEnabled === true) { + promises.push(intents.postmanMonitor(user)); + } + + if (user.githubEnabled === true) { + promises.push(intents.githubIssues(user)); + } + + if (promises.length === 0) { + return Promise.resolve('But you haven\'t configured any status checks!'); + } + + return Promise.all(promises); + }) + .then(function(results) { + console.log(results); + if (Array.isArray(results)) { + let response = results.join(' '); + + return Promise.resolve(response); + } + + return Promise.resolve(results); + }) + .catch(function(err) { + console.log(err); + + return Promise.resolve('There was a problem executing your request; please try again. If this persists, please try again later, or let us know at support@bitscoop.com.'); + }) + .then(function(message) { + self.emit(':tellWithCard', message, global.env.name, message); + }); + }, + + AboutIntent: function () { + let message = 'BitScoop Labs is an Orange County, California-based company that develops API integration products.'; + + this.emit(':tellWithCard', message, global.env.name, message); + }, + + LaunchRequest: function () { + let message = ''; + + message += 'Welcome to ' + global.env.name + '. '; + message += 'You can ask a question like, get me the repos trending on GitHub. '; + + let reprompt = 'For instructions on what you can say, please say help me.'; + + this.emit(':ask', message, reprompt); + }, + + 'AMAZON.CancelIntent': function () { + this.emit(':tell', 'Goodbye'); + }, + + 'AMAZON.HelpIntent': function () { + let message = ''; + + message += 'Here are some things you can say: '; + message += 'Get me the repos trending on GitHub. '; + message += 'Tell me what\'s trending on GitHub. '; + + message += 'You can also say stop if you\'re done. '; + message += 'So how can I help?'; + + this.emit(':ask', message, message); + }, + + 'AMAZON.StopIntent': function () { + this.emit(':tell', 'Goodbye'); + } +}; + + +exports.handler = function (event, context) { + let alexa = Alexa.handler(event, context); + + alexa.APP_ID = process.env.ALEXA_APP_ID; + + alexa.registerHandlers(handlers); + alexa.execute(); +}; diff --git a/lib/intents/github-issues.js b/lib/intents/github-issues.js new file mode 100644 index 0000000..f8137d4 --- /dev/null +++ b/lib/intents/github-issues.js @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('assert'); + + +module.exports = function(user) { + let bitscoop = global.env.bitscoop; + + try { + assert(user.githubUser != null, 'GitHub user configuration cannot be `null`.'); + } catch(err) { + return Promise.resolve('GitHub is missing some important configuration parameters.'); + } + + try { + assert(user.githubRepo != null, 'GitHub repository configuration cannot be `null`.'); + } catch(err) { + return Promise.resolve('GitHub is missing some important configuration parameters.'); + } + + let map = bitscoop.api(process.env.GITHUB_MAP_ID); + let cursor = map.endpoint('Issues').method('GET'); + + return cursor({ + query: { + user: user.githubUser, + repo: user.githubRepo + } + }) + .then(function(result) { + let [data] = result; + + let response = 'There are '; + + if (data.length === 100) { + response += 'at least '; + } + + response += data.length + ' open issues for your project.'; + + return Promise.resolve(response); + }) + .catch(function() { + return Promise.resolve('I ran into some issues reaching GitHub.'); + }); +}; diff --git a/lib/intents/github-meta.js b/lib/intents/github-meta.js new file mode 100644 index 0000000..fcc0ca9 --- /dev/null +++ b/lib/intents/github-meta.js @@ -0,0 +1,16 @@ +'use strict'; + +const assert = require('assert'); + + +module.exports = function(accessToken) { + let bitscoop = global.env.bitscoop; + + return request('https://api.github.com/user?access_token=' + accessToken) + .then(function(result) { + return Promise.resolve(result); + }) + .catch(function() { + return Promise.resolve('I ran into some issues authenticating you with GitHub'); + }); +}; diff --git a/lib/intents/google-analytics.js b/lib/intents/google-analytics.js new file mode 100644 index 0000000..1aa64db --- /dev/null +++ b/lib/intents/google-analytics.js @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('assert'); + + +module.exports = function(user) { + let bitscoop = global.env.bitscoop; + + try { + assert(user.googleViewId != null, 'Google Analytics view configuration cannot be `null`.'); + } catch(err) { + return Promise.resolve('Google is missing some important configuration parameters.'); + } + + try { + assert(user.googleAnalyticsConnectionId != null, 'Google Analytics connection ID cannot be `null`.'); + } catch(err) { + return Promise.resolve('Google is missing some important configuration parameters.'); + } + + let map = bitscoop.api(process.env.GOOGLE_ANALYTICS_MAP_ID); + let cursor = map.endpoint('Metrics').method('GET'); + + return cursor({ + headers: { + 'X-Connection-Id': user.googleAnalyticsConnectionId + }, + query: { + view_id: user.googleViewId + } + }) + .then(function(result) { + let [data] = result; + + let totals = data.totalsForAllResults; + + let response = 'There were ' + totals['ga:users'] + ' visitors to the site in the last 24 hours, and ' + totals['ga:newUsers'] + ' were new visitors.'; + + return Promise.resolve(response); + }) + .catch(function(err) { + console.log(err); + + return Promise.resolve('I ran into some issues reaching Google Analytics.'); + }); +}; diff --git a/lib/intents/index.js b/lib/intents/index.js new file mode 100644 index 0000000..9eb3d63 --- /dev/null +++ b/lib/intents/index.js @@ -0,0 +1,10 @@ +'use strict'; + + +module.exports = { + githubIssues: require('./github-issues'), + githubMeta: require('./github-meta'), + googleAnalytics: require('./google-analytics'), + postmanMonitor: require('./postman-monitor'), + statuscakeAlerts: require('./statuscake-alerts') +}; diff --git a/lib/intents/postman-monitor.js b/lib/intents/postman-monitor.js new file mode 100644 index 0000000..1254daf --- /dev/null +++ b/lib/intents/postman-monitor.js @@ -0,0 +1,45 @@ +'use strict'; + +const assert = require('assert'); + +const _ = require('lodash'); + + +module.exports = function(user) { + let bitscoop = global.env.bitscoop; + + try { + assert(user.postmanId != null, 'Postman monitor configuration cannot be `null`.'); + assert(user.postmanApiKey != null, 'Postman API Key cannot be `null`.'); + } catch(err) { + return Promise.resolve('Postman is missing some important configuration parameters.'); + } + + let map = bitscoop.api(process.env.POSTMAN_MAP_ID); + let cursor = map.endpoint('RunMonitor').method('POST'); + + return cursor({ + query: { + api_key: user.postmanApiKey, + monitor_id: user.postmanId + } + }) + .then(function(result) { + let [data, response] = result; + + if (data.code && !/^2/.test(data.code)) { + return Promise.reject(); + } + + if (_.has(data, 'run.stats.assertions.failed') && data.run.stats.assertions.failed > 0) { + return Promise.resolve('The API status check has failed.'); + } + + return Promise.resolve('The API status check has succeeded.'); + }) + .catch(function(err) { + console.log(err); + + return Promise.resolve('I ran into some issues reaching Postman.'); + }); +}; diff --git a/lib/intents/statuscake-alerts.js b/lib/intents/statuscake-alerts.js new file mode 100644 index 0000000..ab8c824 --- /dev/null +++ b/lib/intents/statuscake-alerts.js @@ -0,0 +1,38 @@ +'use strict'; + +const assert = require('assert'); + +const moment = require('moment'); + + +module.exports = function(user) { + let bitscoop = global.env.bitscoop; + + try { + assert(user.statuscakeId != null, 'StatusCake test configuration cannot be `null`.'); + } catch(err) { + return Promise.resolve('StatusCake is missing some important configuration parameters.'); + } + + let map = bitscoop.api(process.env.STATUSCAKE_MAP_ID); + let cursor = map.endpoint('Alerts').method('GET'); + + return cursor({ + query: { + api_key: user.statuscakeApiKey, + username: user.statuscakeUsername, + test_id: user.statuscakeId, + since: moment().utc().subtract(1, 'day').unix() + } + }) + .then(function(result) { + let [data] = result; + + let response = 'There have been ' + data.length + ' outage alerts in the last 24 hours.'; + + return Promise.resolve(response); + }) + .catch(function() { + return Promise.resolve('I ran into some issues reaching StatusCake.'); + }); +}; diff --git a/lib/package.json b/lib/package.json new file mode 100644 index 0000000..f6b29dd --- /dev/null +++ b/lib/package.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "alexa-sdk": "^1.0.14", + "bitscoop-sdk": "^0.2.0", + "lodash": "^4.6.1", + "moment": "^2.18.1", + "mysql": "^2.13.0", + "nunjucks": "^3.0.0", + "sequelize": "^3.30.4" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..34cb9f1 --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "bitscoop-alexa-demo-2", + "version": "0.1.0", + "private": true, + "author": "BitScoop Labs, Inc.", + "dependencies": { + "bitscoop-sdk": "^0.2.0", + "cookie": "^0.3.1", + "http-errors": "^1.6.1", + "humanparser": "^1.1.1", + "lodash": "^4.6.1", + "moment": "^2.18.1", + "mysql": "^2.13.0", + "nunjucks": "^3.0.0", + "run-sequence": "^1.2.2", + "sequelize": "^3.30.4", + "uuid": "^3.0.1" + }, + "devDependencies": { + "babel-preset-es2015": "^6.24.1", + "gulp": "^3.9.1", + "gulp-add-src": "^0.2.0", + "gulp-babel": "^6.1.2", + "gulp-clean": "^0.3.2", + "gulp-clean-css": "^3.0.4", + "gulp-concat": "^2.6.1", + "gulp-debug": "^3.1.0", + "gulp-eslint": "^3.0.1", + "gulp-gzip": "^1.4.0", + "gulp-header": "^1.8.8", + "gulp-jsonlint": "^1.2.0", + "gulp-less": "^3.3.0", + "gulp-lesshint": "^3.0.0", + "gulp-nunjucks": "^3.0.0", + "gulp-rename": "^1.2.2", + "gulp-tar": "^1.9.0", + "gulp-uglify": "^2.1.2", + "gulp-util": "^3.0.8", + "gulp-zip": "^4.0.0", + "less-plugin-autoprefix": "^1.5.1" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..8ef57c6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,52 @@ +'use strict'; + +const BitScoop = require('bitscoop-sdk'); + +const views = require('./views'); + + +global.env = { + name: 'BitScoop', + bitscoop: new BitScoop(process.env.BITSCOOP_API_KEY) +}; + + +exports.handler = function(event, context, callback) { + let path = event.path; + let method = event.httpMethod; + + if (path === '/') { + views.home(event, context, callback); + } + else if (path === '/complete-login') { + views.completeLogin(event, context, callback); + } + else if (path === '/complete-service') { + views.completeService(event, context, callback); + } + else if (path === '/connections') { + if (method === 'GET') { + views.connections.create(event, context, callback); + } + else if (method === 'DELETE') { + views.connections.delete(event, context, callback); + } + } + else if (path === '/login') { + views.login(event, context, callback); + } + else if (path === '/logout') { + views.logout(event, context, callback); + } + else if (path === '/signup') { + views.signup(event, context, callback); + } + else if (path === '/users') { + if (method === 'DELETE') { + views.users.delete(event, context, callback); + } + else if (method === 'PATCH') { + views.users.patch(event, context, callback); + } + } +}; diff --git a/src/middleware/authentication.js b/src/middleware/authentication.js new file mode 100644 index 0000000..2bfabc1 --- /dev/null +++ b/src/middleware/authentication.js @@ -0,0 +1,37 @@ +'use strict'; + +const Sessions = require('../models/sql/sessions'); +const Users = require('../models/sql/users'); + + +module.exports = function(sequelize, cookie) { + let sessions = new Sessions(sequelize); + let users = new Users(sequelize); + + return Promise.resolve() + .then(function() { + return sessions.findOne({ + where: { + token: cookie + } + }) + .then(function(session) { + if (session) { + return users.findOne({ + where: { + id: session.user + } + }) + .then(function(user) { + return Promise.resolve([session, user]); + }); + } + else { + return Promise.resolve([null, null]); + } + }); + }) + .catch(function(err) { + return Promise.reject(err); + }); +}; diff --git a/src/models/sql/association-sessions.js b/src/models/sql/association-sessions.js new file mode 100644 index 0000000..7a6d56f --- /dev/null +++ b/src/models/sql/association-sessions.js @@ -0,0 +1,100 @@ +'use strict'; + +const Sequelize = require('sequelize'); + + +class Sessions { + constructor(sql) { + this.Sessions = sql.define('association_sessions', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + token: { + type: Sequelize.STRING + }, + connectionId: { + type: Sequelize.STRING, + field: 'connection_id' + } + }, { + timestamps: false + }); + } + + count(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.count(options); + }); + } + + create(val) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.create(val); + }); + } + + destroy(val, options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.destroy(val, options); + }); + } + + update(val, options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.update(val, options); + }); + } + + find(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.find(options); + }); + } + + findAll() { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findAll(); + }); + } + + findOne(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findOne(options); + }); + } + + findOrCreate(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findOrCreate(options); + }); + } +} + + +module.exports = Sessions; diff --git a/src/models/sql/sessions.js b/src/models/sql/sessions.js new file mode 100644 index 0000000..9b50718 --- /dev/null +++ b/src/models/sql/sessions.js @@ -0,0 +1,99 @@ +'use strict'; + +const Sequelize = require('sequelize'); + + +class Sessions { + constructor(sql) { + this.Sessions = sql.define('session', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user: { + type: Sequelize.INTEGER + }, + token: { + type: Sequelize.STRING + } + }, { + timestamps: false + }); + } + + count(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.count(options); + }); + } + + create(val) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.create(val); + }); + } + + destroy(val, options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.destroy(val, options); + }); + } + + update(val, options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.update(val, options); + }); + } + + find(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.find(options); + }); + } + + findAll() { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findAll(); + }); + } + + findOne(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findOne(options); + }); + } + + findOrCreate(options) { + let self = this; + + return this.Sessions.sync() + .then(function() { + return self.Sessions.findOrCreate(options); + }); + } +} + + +module.exports = Sessions; diff --git a/src/models/sql/users.js b/src/models/sql/users.js new file mode 100644 index 0000000..6e1be66 --- /dev/null +++ b/src/models/sql/users.js @@ -0,0 +1,166 @@ +'use strict'; + +const Sequelize = require('sequelize'); + + +class Users { + constructor(sql) { + this.Users = sql.define('user', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + username: { + type: Sequelize.STRING + }, + githubUser: { + type: Sequelize.STRING, + field: 'github_user' + }, + githubRepo: { + type: Sequelize.STRING, + field: 'github_repo' + }, + githubEnabled: { + type: Sequelize.BOOLEAN, + field: 'github_enabled' + }, + googleId: { + type: Sequelize.STRING, + field: 'google_id' + }, + googleAnalyticsConnectionId: { + type: Sequelize.STRING, + field: 'google_analytics_connection_id' + }, + googleViewId: { + type: Sequelize.STRING, + field: 'google_view_id' + }, + googleEnabled: { + type: Sequelize.BOOLEAN, + field: 'google_enabled' + }, + postmanId: { + type: Sequelize.STRING, + field: 'postman_id' + }, + postmanApiKey: { + type: Sequelize.STRING, + field: 'postman_api_key' + }, + postmanEnabled: { + type: Sequelize.BOOLEAN, + field: 'postman_enabled' + }, + statuscakeId: { + type: Sequelize.STRING, + field: 'statuscake_id' + }, + statuscakeEnabled: { + type: Sequelize.BOOLEAN, + field: 'statuscake_enabled' + }, + statuscakeApiKey: { + type: Sequelize.STRING, + field: 'statuscake_api_key' + }, + statuscakeUsername: { + type: Sequelize.STRING, + field: 'statuscake_username' + }, + email: { + type: Sequelize.STRING + }, + upperEmail: { + type: Sequelize.STRING, + field: '_upper_email' + }, + joined: { + type: Sequelize.DATE + }, + accountConnectionId: { + type: Sequelize.STRING, + field: 'account_connection_id' + } + }, { + timestamps: false + }); + } + + count(options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.count(options); + }); + } + + create(val) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.create(val); + }); + } + + destroy(options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.destroy(options); + }); + } + + update(val, options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.update(val, options); + }); + } + + find(options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.find(options); + }); + } + + findAll() { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.findAll(); + }); + } + + findOne(options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.findOne(options); + }); + } + + findOrCreate(options) { + let self = this; + + return this.Users.sync() + .then(function() { + return self.Users.findOrCreate(options); + }); + } +} + + +module.exports = Users; diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..8fea85d --- /dev/null +++ b/src/package.json @@ -0,0 +1,11 @@ +{ + "dependencies": { + "bitscoop-sdk": "^0.2.0", + "cookie": "^0.3.1", + "lodash": "^4.6.1", + "moment": "^2.18.1", + "mysql": "^2.13.0", + "nunjucks": "^3.0.0", + "sequelize": "^3.30.4" + } +} diff --git a/src/templates/home.html b/src/templates/home.html new file mode 100644 index 0000000..e309805 --- /dev/null +++ b/src/templates/home.html @@ -0,0 +1,256 @@ + + + + + + Alexa Ops Buddy · powered by BitScoop + + + + + + + + + + + + + + + + + + + +
+

BitScoop Ops Buddy

+ +
+ This demo allows you to ask a related Alexa skill to get information about your software stack from GitHub, Google Analytics, Postman, and/or StatusCake. +
+ + {% if user %} +
+ Now that you're signed in, you should enable and configure the services from which you want to retrieve data. + If you are retrieving Google Analytics data, you must click the 'Create Connection' button and follow the workflow. + The Google account you use for this Connection can be different than the one you used to create your OpsBuddy account. +
+ {% else %} +
+ You must first sign up for an account here using a Google account, and once signed in enable and configure the services from which you want to retrieve data. +
+ {% endif %} + +
+ You will then need to install the Alexa skill 'BitScoop Ops Buddy' and link it with the same Google account you used to sign up for your Ops Buddy account. + After that, you will be able to ask Alexa 'Ask OpsBuddy how the stack is doing', and it will retrieve the requested data and read it back to you. +
+ + {% if user %} +
+
+ GitHub + +
+
+
+ +
+
Repo's Name
+ +
+
+
Repo Owner's Name
+ +
+ +
+
+ +
+
+ Google Analytics + +
+
+
+ +
+
View ID
+ +
+ +
+ + {% if user.googleAnalyticsConnectionId %} + + {% else %} + + + + {% endif %} +
+ +
+
+ Postman + +
+
+
+ +
+
Postman Monitor ID
+ +
Postman API Key
+ +
+ +
+
+ +
+
+ StatusCake + +
+
+
+ +
+
Statuscake Test ID
+ +
Statuscake Api Key
+ +
Statuscake Username
+ +
+ +
+
+ + + +
+ +
+ {% else %} +

Sign up or log in below.

+ + + {% endif %} +
+ + diff --git a/src/views/complete_login.js b/src/views/complete_login.js new file mode 100644 index 0000000..ed395c9 --- /dev/null +++ b/src/views/complete_login.js @@ -0,0 +1,213 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); +const cookie = require('cookie'); +const moment = require('moment'); +const uuid = require('uuid'); + +const AssociationSessions = require('../models/sql/association-sessions'); +const Sessions = require('../models/sql/sessions'); +const Users = require('../models/sql/users'); + + +module.exports = function(event, context, callback) { + let associationSessions, filter, promise, sequelize, sessions, users; + + let cookies = _.get(event, 'headers.Cookie', ''); + let associationId = cookie.parse(cookies).social_demo_session_id; + + let query = event.queryStringParameters || {}; + let type = query.type; + + if (!associationId || (type !== 'signup' && type !== 'login')) { + callback(null, { + statusCode: 404, + body: JSON.stringify({ + sessionId: associationId, + type: type, + event: event, + cookies: cookies + }) + }); + } + else { + promise = Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + associationSessions = new AssociationSessions(sequelize); + sessions = new Sessions(sequelize); + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + filter = { + where: { + token: associationId, + connectionId: query.connection_id + } + }; + + return associationSessions.count(filter) + .then(function(n) { + if (n === 0) { + return Promise.reject(new Error('Invalid association session or association session timeout')); + } + + let bitscoop = global.env.bitscoop; + + return Promise.all([ + associationSessions.destroy(filter), + + bitscoop.getConnection(query.existing_connection_id || query.connection_id) + ]); + }); + }) + .then(function(result) { + let [, connection] = result; + + if (connection == null) { + return Promise.reject(new Error('Invalid connection')); + } + + if (!_.get(connection, 'auth.status.authorized', false)) { + return Promise.reject(new Error('Connection is not authorized. In order to use this account you must grant the requested permissions.')); + } + + return Promise.resolve(connection); + }); + + if (type === 'login') { + promise = promise + .then(function(connection) { + return users.find({ + where: { + accountConnectionId: connection.id + } + }); + }); + } + else if (type === 'signup') { + promise = promise + .then(function(connection) { + return users.count({ + where: { + accountConnectionId: connection.id + } + }) + .then(function(n) { + if (n > 0) { + return Promise.reject(new Error('It looks like you\'ve already associated this account with BitScoop. Try logging in with it instead.')); + } + + return Promise.resolve(connection); + }); + }); + + promise = promise + .then(function(connection) { + let email, promise; + + let user = { + accountConnectionId: connection.id, + joined: moment.utc().toDate() + }; + + user.googleId = connection.metadata.id; + + if (connection.metadata.email) { + email = connection.metadata.email; + } + + if (email) { + promise = users.count({ + where: { + upperEmail: email.toUpperCase() + } + }); + } + else { + promise = Promise.resolve(0); + } + + return promise + .then(function(n) { + if (email) { + if (n === 0) { + user.email = email; + user.upperEmail = email.toUpperCase(); + } + } + + return users.create(user); + }); + }); + } + + promise = promise + .then(function(user) { + if (!user) { + return Promise.reject(new Error('Not Found')); + } + + return sessions.create({ + user: user.id, + token: uuid().replace(/-/g, '') + }); + }); + + promise + .then(function(session) { + sequelize.close(); + + let domainRegex = /^https:\/\/([\w.-]+)/g; + let match = domainRegex.exec(process.env.SITE_URL); + let siteDomain = match[1]; + + let cookieString = 'social_demo_session_id=' + session.token + '; domain=' + siteDomain + '; expires=' + 0 + '; secure=true; http_only=true'; + + callback(null, { + statusCode: 302, + headers: { + 'Set-Cookie': cookieString, + Location: '/prod' + } + }); + + return Promise.resolve(); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); + } +}; diff --git a/src/views/complete_service.js b/src/views/complete_service.js new file mode 100644 index 0000000..6cb68f7 --- /dev/null +++ b/src/views/complete_service.js @@ -0,0 +1,89 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); + +const Users = require('../models/sql/users'); + + +module.exports = function(event, context, callback) { + let sequelize, users; + + let bitscoop = global.env.bitscoop; + + let query = event.queryStringParameters || {}; + + Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + return bitscoop.getConnection(query.existing_connection_id || query.connection_id) + }) + .then(function(connection) { + if (connection == null) { + return Promise.reject(new Error('Invalid connection')); + } + + if (!_.get(connection, 'auth.status.authorized', false)) { + return Promise.reject(new Error('Connection is not authorized. In order to use this account you must grant the requested permissions.')); + } + + if (query.existing_connection_id) { + return users.update({ + googleAnalyticsConnectionId: query.existing_connection_id + }, { + where: { + googleAnalyticsConnectionId: query.connection_id + } + }); + } + + return Promise.resolve(); + }) + .then(function() { + sequelize.close(); + + callback(null, { + statusCode: 302, + headers: { + Location: '/prod' + } + }); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +}; diff --git a/src/views/connections.js b/src/views/connections.js new file mode 100644 index 0000000..c02a7da --- /dev/null +++ b/src/views/connections.js @@ -0,0 +1,175 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); +const cookie = require('cookie'); + +const Users = require('../models/sql/users'); +const authenticate = require('../middleware/authentication'); + + +function create(event, context, callback) { + let sequelize, user, users; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return authenticate(sequelize, sessionId); + }) + .then(function(result) { + [, user] = result; + + let mapId = process.env.GOOGLE_ANALYTICS_MAP_ID; + + let bitscoop = global.env.bitscoop; + + return bitscoop.createConnection(mapId, { + redirect_url: process.env.SITE_URL + '/complete-service' + }); + }) + .then(function(result) { + return users.update({ + googleAnalyticsConnectionId: result.id + }, { + where: { + id: user.id + } + }) + .then(function() { + sequelize.close(); + + callback(null, { + statusCode: 302, + headers: { + Location: result.redirectUrl + } + }); + }); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 500, + body: err.toString() + }); + + return Promise.reject(err); + }); +} + + + +function del(event, context, callback) { + let sequelize, user, users; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return authenticate(sequelize, sessionId); + }) + .then(function(result) { + [, user] = result; + + return users.findOne({ + where: { + id: user.id + } + }); + }) + .then(function(result) { + let bitscoop = global.env.bitscoop; + + return bitscoop.deleteConnection(result.googleAnalyticsConnectionId); + }) + .then(function() { + return users.update({ + googleAnalyticsConnectionId: null + }, { + where: { + id: user.id + } + }); + }) + .then(function() { + sequelize.close(); + + callback(null, { + statusCode: 204 + }); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 500, + body: err.toString() + }); + + return Promise.reject(err); + }); +} + + +module.exports = { + create: create, + delete: del +}; diff --git a/src/views/home.js b/src/views/home.js new file mode 100644 index 0000000..24f2506 --- /dev/null +++ b/src/views/home.js @@ -0,0 +1,89 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); +const cookie = require('cookie'); +const nunjucks = require('nunjucks'); + +const authenticate = require('../middleware/authentication'); + + +let renderer = nunjucks.configure('templates'); + +module.exports = function(event, context, callback) { + let sequelize; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + return Promise.resolve(); + }) + .then(function() { + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return authenticate(sequelize, sessionId); + }) + .then(function(result) { + sequelize.close(); + + let html, context; + let [session, user] = result; + + if (!session || !user) { + context = {}; + } + else { + context = { + user: user + }; + } + + html = renderer.render('home.html', context); + + var response = { + statusCode: 200, + headers: { + 'Content-Type': 'text/html', + 'Access-Control-Allow-Origin': '*' + }, + body: html + }; + + callback(null, response); + + return Promise.resolve(); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 500, + body: err.toString() + }); + + return Promise.reject(err); + }); +}; diff --git a/src/views/index.js b/src/views/index.js new file mode 100644 index 0000000..49f6e49 --- /dev/null +++ b/src/views/index.js @@ -0,0 +1,13 @@ +'use strict'; + + +module.exports = { + completeLogin: require('./complete_login'), + completeService: require('./complete_service'), + connections: require('./connections'), + home: require('./home'), + login: require('./login'), + logout: require('./logout'), + signup: require('./signup'), + users: require('./users') +}; diff --git a/src/views/login.js b/src/views/login.js new file mode 100644 index 0000000..36782a9 --- /dev/null +++ b/src/views/login.js @@ -0,0 +1,98 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const moment = require('moment'); +const uuid = require('uuid'); + +const AssociationSessions = require('../models/sql/association-sessions'); + + +module.exports = function(event, context, callback) { + let associationSessions, sequelize; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + associationSessions = new AssociationSessions(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let mapId = process.env.GOOGLE_SIGN_IN_MAP_ID; + + //Create a Connection using the appropriate Map ID. + //This will return an object containing the Connection ID and a redirect URL that you need to redirect the user to + //so that they can authorize your app to access their information. + //The redirect URL specified in the Map can be overridden by passing one in the body when creating the Connection. + //In this demo, this is useful for setting the 'type' parameter based on whether the user is signing up or logging in, + //which changes how the login completion logic should function. + let bitscoop = global.env.bitscoop; + + return bitscoop.createConnection(mapId, { + redirect_url: process.env.SITE_URL + '/complete-login?type=login' + }); + }) + .then(function(result) { + let connectionId = result.id; + let redirectUrl = result.redirectUrl; + + let token = uuid().replace(/-/g, ''); + let expiration = moment.utc().add(30, 'seconds').toDate(); + + let domainRegex = /^https:\/\/([\w.-]+)/g; + let match = domainRegex.exec(process.env.SITE_URL); + let siteDomain = match[1]; + + let cookieString = 'social_demo_session_id=' + token + '; domain=' + siteDomain + '; expires=' + expiration + '; secure=true; http_only=true'; + + return associationSessions.create({ + token: token, + connectionId: connectionId + }) + .then(function() { + sequelize.close(); + + var response = { + statusCode: 302, + headers: { + 'Set-Cookie': cookieString, + Location: redirectUrl + } + }; + + callback(null, response); + }); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +}; diff --git a/src/views/logout.js b/src/views/logout.js new file mode 100644 index 0000000..59b1651 --- /dev/null +++ b/src/views/logout.js @@ -0,0 +1,96 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); +const cookie = require('cookie'); + + +module.exports = function(event, context, callback) { + let sequelize; + + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + return Promise.resolve(); + }) + .then(function() { + let sessions = sequelize.define('session', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true + }, + user: { + type: Sequelize.INTEGER + }, + token: { + type: Sequelize.STRING + } + }, { + timestamps: false + }); + + return sessions.sync() + .then(function() { + return sessions.destroy({ + where: { + token: sessionId + } + }); + }); + }) + .then(function() { + sequelize.close(); + + let domainRegex = /^https:\/\/([\w.-]+)/g; + let match = domainRegex.exec(process.env.SITE_URL); + let siteDomain = match[1]; + + let cookieString = 'social_demo_session_id=; domain=' + siteDomain + '; expires=' + 0 + '; secure=true; http_only=true'; + + var response = { + statusCode: 302, + headers: { + 'Set-Cookie' : cookieString, + Location: '/prod' + } + }; + + callback(null, response); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +}; diff --git a/src/views/signup.js b/src/views/signup.js new file mode 100644 index 0000000..85e38f7 --- /dev/null +++ b/src/views/signup.js @@ -0,0 +1,99 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const moment = require('moment'); +const uuid = require('uuid'); + +const AssociationSessions = require('../models/sql/association-sessions'); + + +module.exports = function(event, context, callback) { + let associationSessions, sequelize; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + associationSessions = new AssociationSessions(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let mapId = process.env.GOOGLE_SIGN_IN_MAP_ID; + + //Create a Connection using the appropriate Map ID. + //This will return an object containing the Connection ID and a redirect URL that you need to redirect the user to + //so that they can authorize your app to access their information. + //The redirect URL specified in the Map can be overridden by passing one in the body when creating the Connection. + //In this demo, this is useful for setting the 'type' parameter based on whether the user is signing up or logging in, + //which changes how the login completion logic should function. + let bitscoop = global.env.bitscoop; + + return bitscoop.createConnection(mapId, { + redirect_url: process.env.SITE_URL + '/complete-login?type=signup' + }); + }) + .then(function(result) { + let connectionId = result.id; + let redirectUrl = result.redirectUrl; + + let token = uuid().replace(/-/g, ''); + let expiration = moment.utc().add(30, 'seconds').toDate(); + + let domainRegex = /^https:\/\/([\w.-]+)/g; + let match = domainRegex.exec(process.env.SITE_URL); + let siteDomain = match[1]; + + let cookieString = 'social_demo_session_id=' + token + '; domain=' + siteDomain + '; expires=' + expiration + '; secure=true; http_only=true'; + + console.log(cookieString); + return associationSessions.create({ + token: token, + connectionId: connectionId + }) + .then(function() { + sequelize.close(); + + var response = { + statusCode: 302, + headers: { + 'Set-Cookie' : cookieString, + Location: redirectUrl + } + }; + + callback(null, response); + }); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +}; diff --git a/src/views/users.js b/src/views/users.js new file mode 100644 index 0000000..5f3670f --- /dev/null +++ b/src/views/users.js @@ -0,0 +1,171 @@ +'use strict'; + +const assert = require('assert'); + +const Sequelize = require('sequelize'); +const _ = require('lodash'); +const cookie = require('cookie'); + +const Users = require('../models/sql/users'); +const authenticate = require('../middleware/authentication'); + + +function del(event, context, callback) { + let sequelize, user, users; + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return authenticate(sequelize, sessionId); + }) + .then(function(result) { + [, user] = result; + + if (!user) { + return Promise.reject(new Error('Not Found')); + } + + let bitscoop = global.env.bitscoop; + + return Promise.all([ + bitscoop.deleteConnection(result.googleAnalyticsConnectionId), + bitscoop.deleteConnection(result.accountConnectionId) + ]); + }) + .then(function() { + return users.destroy({ + where: { + id: user.id + } + }); + }) + .then(function() { + sequelize.close(); + + var response = { + statusCode: 200 + }; + + callback(null, response); + + return Promise.resolve(); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +} + + +function patch(event, context, callback) { + let sequelize, users; + let updateData = JSON.parse(event.body); + + return Promise.resolve() + .then(function() { + try { + assert(process.env.HOST != null, 'Unspecified RDS host.'); + assert(process.env.PORT != null, 'Unspecified RDS port.'); + assert(process.env.USER != null, 'Unspecified RDS user.'); + assert(process.env.PASSWORD != null, 'Unspecified RDS password.'); + assert(process.env.DATABASE != null, 'Unspecified RDS database.'); + } catch(err) { + return Promise.reject(err); + } + + sequelize = new Sequelize(process.env.DATABASE, process.env.USER, process.env.PASSWORD, { + host: process.env.HOST, + port: process.env.PORT, + dialect: 'mysql', + logging: false + }); + + users = new Users(sequelize); + + return Promise.resolve(); + }) + .then(function() { + let cookies = _.get(event, 'headers.Cookie', ''); + let sessionId = cookie.parse(cookies).social_demo_session_id; + + return authenticate(sequelize, sessionId); + }) + .then(function(result) { + let [, user] = result; + + if (!user) { + return Promise.reject(new Error('Not Found')); + } + + return users.update(updateData, { + where: { + id: user.id + } + }); + }) + .then(function() { + sequelize.close(); + + var response = { + statusCode: 200 + }; + + callback(null, response); + + return Promise.resolve(); + }) + .catch(function(err) { + if (sequelize) { + sequelize.close(); + } + + console.log(err); + + callback(null, { + statusCode: 404, + body: err.toString() + }); + + return Promise.reject(err); + }); +} + + +module.exports = { + delete: del, + patch: patch +}; diff --git a/static/less/mixins/boxed-group.less b/static/less/mixins/boxed-group.less new file mode 100644 index 0000000..cbfb1ef --- /dev/null +++ b/static/less/mixins/boxed-group.less @@ -0,0 +1,30 @@ +.boxed-group { + @border-radius: 2px; + + box-shadow: none; + background-color: transparent; + border: 1px solid darken(@border-color, 10%); + border-radius: @border-radius @border-radius 0 0; + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + + & > * { + border-top: 1px solid darken(@border-color, 10%); + } + + & > :first-child { + padding: 0.2em 0.5em; + font-weight: 700; + font-size: 1.1em; + border-top: none; + background-color: @background-color; + } + + &:not(.titled).padded { + padding: 1em; + } +} + diff --git a/static/less/mixins/code.less b/static/less/mixins/code.less new file mode 100644 index 0000000..faa2669 --- /dev/null +++ b/static/less/mixins/code.less @@ -0,0 +1,16 @@ +code { + &.block { + line-height: 135%; + display: block; + white-space: pre; + overflow-x: auto; + tab-size: 3; + background-color: darken(@item-background-color, 3%); + padding: 25px; + margin: 1em auto; + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/static/less/mixins/colors.less b/static/less/mixins/colors.less new file mode 100644 index 0000000..9f0a992 --- /dev/null +++ b/static/less/mixins/colors.less @@ -0,0 +1,23 @@ +.blue { + color: @blue !important; +} + +.orange { + color: @orange !important; +} + +.green { + color: @green !important; +} + +.red { + color: @red !important; +} + +.subdue { + color: @font-color-subdue !important; +} + +.transparent { + color: transparent !important; +} diff --git a/static/less/mixins/controls.less b/static/less/mixins/controls.less new file mode 100644 index 0000000..7f4a3b9 --- /dev/null +++ b/static/less/mixins/controls.less @@ -0,0 +1,432 @@ + +@input-margin: 0.5em; + +input { + outline: none; + background-color: transparent; + + &[type="text"], + &[type="email"], + &[type="password"], + &[type="date"] { + .source-sans(300); + + font-size: 1.1em; + + margin: 0.25em 0; + padding: 0.45em 0.55em; + + &.plain { + .roboto(300); + + font-size: 1em; + } + + border: 1px solid @input-border-color; + } + + &[type="checkbox"] { + position: relative; + top: -2px; + } + + &[type="radio"], + &[type="checkbox"] { + @media @mobile { + margin: 1em 1em; + transform: scale(1.25); + } + } +} + +button { + @button-background-color: #F1F3F6; + @button-border-color: #C0C9D2; + @button-text-color: #2A333C; + + .source-sans; + + cursor: pointer; + border: 1px solid @button-border-color; + background-color: @button-background-color; + display: inline-block; + color: @button-text-color; + + padding: 0.5em 1em; + font-size: 1em; + + white-space: nowrap; + border-radius: 2px; + user-select: none; + outline: none; + + &:hover, + &:active { + background-color: darken(@button-background-color, 3%); + border-color: darken(@button-border-color, 3%); + } + + &:disabled, + &.disabled { + &, + &:hover { + cursor: default; + color: lighten(@button-text-color, 45%); + background-color: lighten(@button-background-color, 2%); + border-color: lighten(@button-border-color, 8%); + } + } + + &.primary { + color: #FFF; + background-color: @blue; + border-color: @blue; + + &:hover, + &:active { + background-color: darken(@blue, 3%); + border-color: darken(@blue, 3%); + } + + &:disabled, + &.disabled { + &, + &:hover { + color: lighten(@button-text-color, 45%); + background-color: lighten(@button-background-color, 2%); + border-color: lighten(@button-border-color, 8%); + } + } + } + + &.danger { + color: @red; + + &:disabled, + &.disabled { + &, + &:hover { + color: lighten(@button-text-color, 45%); + background-color: lighten(@button-background-color, 2%); + border-color: lighten(@button-border-color, 8%); + } + } + } + + & > i:first-child:not(:last-child) { + margin-right: 0.35em; + } + + a { + color: @button-text-color !important; + + &:hover, + &:active { + color: @button-text-color !important; + } + } +} + +select { + outline: none; + border: 1px solid @input-border-color; + border-radius: 0; + background-color: transparent; + + font-size: 1em; + + margin: 0.25em; + padding: 0.5em; + + .source-sans(300); +} + +label { + .source-sans(300); + + font-size: 1.1em; + cursor: pointer; + + @media @mobile { + font-size: 1.4em; + } + + input[type="checkbox"] { + position: relative; + cursor: pointer; + } + + input[type="radio"] { + position: relative; + top: -2px; + + margin-right: 0.5em; + + cursor: pointer; + + @media @mobile { + margin-right: 1.5em; + top: -4px; + } + } +} + +div.text-box { + border: 1px solid @input-border-color; + margin: @input-margin; + padding: 0; + + &.nomargin { + margin: 0; + } + + &.transparent { + border: none; + margin: 0; + } + + &.nopadding { + input { + padding: 0; + } + } + + input { + outline: none; + border: none; + -webkit-appearance: none; + + margin: 0; + padding: 0.45em 0.55em; + + width: 100%; + + &[type="text"], + &[type="email"], + &[type="password"], + &[type="date"] { + &.plain { + .roboto(300); + + font-size: 1em; + } + } + } +} + +.success { + color: forestgreen; +} + +.success-icon { + color: forestgreen; + opacity: 0; + + transition: opacity 250ms ease; + + &.shown { + opacity: 1; + } +} + +.error, +.errorlist { + color: firebrick; +} + +ul.errorlist { + text-align: left; +} + +.datetimepicker { + &.input-group { + position: relative; + display: table; + border-collapse: separate; + + .form-control { + position: relative; + display: table-cell; + z-index: 2; + float: left; + width: 100%; + margin-bottom: 0; + } + + .input-group-addon { + display: table-cell; + width: 1%; + vertical-align: middle; + padding: 6px 12px; + font-size: 14px; + font-weight: normal; + line-height: 1; + color: #555; + text-align: center; + background-color: #eee; + } + + .list-unstyled { + padding-left: 0; + list-style: none; + } + + a { + color: @blue; + } + } +} + +.dropdown { + position: relative; +} + +.dropdown-toggle:focus { + outline: 0; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + + &.pull-right { + right: 0; + left: auto; + } + + .divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; + } + + & > li { + & > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.42857143; + color: #333; + white-space: nowrap; + + &:hover, + &:focus { + color: #262626; + text-decoration: none; + background-color: #f5f5f5; + } + } + } + + & > .active { + & > a { + color: #fff; + text-decoration: none; + background-color: #337ab7; + outline: 0; + } + } + + & > .disabled { + & > a { + color: #777; + + &:hover, + &:focus { + text-decoration: none; + cursor: not-allowed; + background-color: transparent; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); + } + } + } +} + +.collapse { + display: none; + + &.in { + display: block; + } +} + +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition-timing-function: ease; + -o-transition-timing-function: ease; + transition-timing-function: ease; + -webkit-transition-duration: .35s; + -o-transition-duration: .35s; + transition-duration: .35s; + -webkit-transition-property: height, visibility; + -o-transition-property: height, visibility; + transition-property: height, visibility; +} + +.btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} + +.btn-primary { + color: #fff; + background-color: @blue; + border-color: darken(@blue, 10%); + + &:focus, + &.focus { + color: #fff; + background-color: #286090; + border-color: #122b40; + } + + &:hover { + color: #fff; + background-color: #286090; + border-color: #204d74; + } + + &:active, + &.active { + color: #fff; + background-color: #286090; + border-color: #204d74; + } +} diff --git a/static/less/mixins/flexbox.less b/static/less/mixins/flexbox.less new file mode 100644 index 0000000..5139b13 --- /dev/null +++ b/static/less/mixins/flexbox.less @@ -0,0 +1,128 @@ +.flexbox { + display: -webkit-box; + display: flex; +} + +.flex-start { + -webkit-box-pack: start; + justify-content: flex-start; +} + +.flex-end { + -webkit-box-pack: end; + justify-content: flex-end; +} + +.flex-center { + -webkit-box-pack: center; + justify-content: center; +} + +.flex-space-between { + -webit-box-pack: justify; + justify-content: space-between; +} + +.flex-space-around { + justify-content: space-around; +} + +.flex-x-start { + -webkit-box-align: start; + align-items: flex-start; +} + +.flex-x-stretch { + -webkit-box-align: stretch; + align-items: stretch; +} + +.flex-x-end { + -webkit-box-align: end; + align-items: flex-end; +} + +.flex-x-center { + -webkit-box-align: center; + align-items: center; +} + +.flex-row { + -webkit-box-orient: horizontal; + flex-direction: row; +} + +.flex-row-reverse { + -webkit-box-orient: horizontal; + -webkit-box-direction: reverse; + flex-direction: row-reverse; +} + +.flex-column { + -webkit-box-align: vertical; + flex-direction: column; +} + +.flex-column-reverse { + -webkit-box-orient: vertical; + -webkit-box-direction: reverse; + flex-direction: column-reverse; +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex(@size: 1) { + -webkit-box-flex: @size; + flex: @size; +} + +.flex-grow { + .flex; +} + +.flex-item-auto { + align-self: auto; +} + +.flex-item-start { + align-self: flex-start; +} + +.flex-item-end { + align-self: flex-end; +} + +.flex-item-center { + align-self: center; +} + +.flex-item-baseline { + align-self: baseline; +} + +.flex-item-stretch { + align-self: stretch; +} + +.flex-grow-scrollable(@overflow: true) { + .flex-grow; + + & when (@overflow = true) { + overflow-y: scroll; + } + + & when not (@overflow = true) { + overflow-y: auto; + } + + position: relative; + + & > .scroller { + position: absolute; + + top: 0; left: 0; + width: 100%; height: 100%; + } +} diff --git a/static/less/mixins/fonts.less b/static/less/mixins/fonts.less new file mode 100644 index 0000000..701b626 --- /dev/null +++ b/static/less/mixins/fonts.less @@ -0,0 +1,39 @@ +.fa { + font-family: FontAwesome !important; + font-style: normal !important; +} + +.open-sans(@weight: 400) { + font-family: 'Open Sans', Arial, sans-serif; +} + +.open-sans { + .open-sans(); +} + +.quicksand(@weight: 400) { + font-family: 'Quicksand', Arial, Helvetica, sans-serif; + font-weight: @weight; +} + +.quicksand { + .quicksand(); +} + +.roboto(@weight: 400) { + font-family: 'Roboto', Arial, Helvetica, sans-serif; + font-weight: @weight; +} + +.roboto { + .roboto(); +} + +.source-sans(@weight: 400) { + font-family: 'Source Sans Pro', sans-serif; + font-weight: @weight; +} + +.source-sans { + .source-sans(); +} diff --git a/static/less/mixins/layout.less b/static/less/mixins/layout.less new file mode 100644 index 0000000..552d2ad --- /dev/null +++ b/static/less/mixins/layout.less @@ -0,0 +1,19 @@ +.separator { + &.horizontal { + width: 100%; height: 1px; + border-bottom: 1px solid @input-border-color; + margin: 1.5em 0; + + & > span { + .no-select; + + cursor: default; + color: @font-color-subdue; + font-weight: 700; + background-color: @item-background-color; + position: relative; + top: -9px; + padding: 3px 5px; + } + } +} diff --git a/static/less/mixins/media.less b/static/less/mixins/media.less new file mode 100644 index 0000000..f164899 --- /dev/null +++ b/static/less/mixins/media.less @@ -0,0 +1,20 @@ +@mobile: ~"(max-device-width: 1080px) and (min-device-pixel-ratio: 1.5)", +~"(max-device-width: 1080px) and (-webkit-min-device-pixel-ratio: 1.5)", +~"(min-device-pixel-ratio: 3)", +~"(-webkit-min-device-pixel-ratio: 3)"; + +@mobile-portrait: ~"(orientation: portrait) and (max-device-width: 1080px) and (min-device-pixel-ratio: 1.5)", +~"(orientation: portrait) and (max-device-width: 1080px) and (-webkit-min-device-pixel-ratio: 1.5)", +~"(orientation: portrait) and (min-device-pixel-ratio: 3)", +~"(orientation: portrait) and (-webkit-min-device-pixel-ratio: 3)"; + +@mobile-landscape: ~"(orientation: landscape) and (max-device-width: 1080px) and (min-device-pixel-ratio: 1.5)", +~"(orientation: landscape) and (max-device-width: 1080px) and (-webkit-min-device-pixel-ratio: 1.5)", +~"(orientation: landscape) and (min-device-pixel-ratio: 3)", +~"(orientation: landscape) and (-webkit-min-device-pixel-ratio: 3)"; + +@maxwidth768: ~"(max-width: 768px)"; + +@maxwidth1080: ~"(max-width: 1080px)"; + +@maxwidth1440: ~"(max-width: 1440px)"; diff --git a/static/less/mixins/mixins.less b/static/less/mixins/mixins.less new file mode 100644 index 0000000..c3badd0 --- /dev/null +++ b/static/less/mixins/mixins.less @@ -0,0 +1,120 @@ +.active(@color) { + &:active, + &.active { + color: @color; + } +} + +.align-center { + text-align: center !important; +} + +.block { + display: block; +} + +.blockify > * { + display: block; +} + +.bold { + font-weight: bold !important; +} + +.capitalize { + text-transform: capitalize; +} + +.full-width { + width: 100%; + margin-left: auto; + margin-right: auto; +} + +.hidden { + display: none !important; +} + +.hide-empty { + &:empty { + display: none !important; + } +} + +.hover(@color) { + &:hover { + color: @color; + } +} + +.interactive(@pointer: false) { + .hover(@blue); + .active(@orange); + + & when (@pointer = true) { + cursor: pointer; + } +} + +.lock-width(@autocenter: true) { + max-width: 1080px !important; + + & when (@autocenter = true) { + .position-center; + } +} + +.no-select { + user-select: none; +} + +.no-wrap { + white-space: nowrap; +} + +.paragraph { + margin-bottom: 0.5em; +} + +.paragraphed { + & > * { + margin-bottom: 1em; + + &:last-child { + margin-bottom: 0; + } + } +} + +.padded { + .padding; +} + +.padding(@padding: 15px) { + padding: @padding; +} + +.pointer { + cursor: pointer !important; +} + +.position-center { + margin-left: auto !important; + margin-right: auto !important; +} + +.table { + display: table; + + & > * { + display: table-row; + + & > * { + display: table-cell; + } + } +} + +.uppercase { + text-transform: uppercase; +} diff --git a/static/less/mixins/paragraphing.less b/static/less/mixins/paragraphing.less new file mode 100644 index 0000000..5c5dd28 --- /dev/null +++ b/static/less/mixins/paragraphing.less @@ -0,0 +1,51 @@ +h1, h2, h3 { + font-weight: 700; + margin: 1.5em 0 0.5em 0; +} + +h1, h2, h3, p { + &:first-child, + &.no-first { + margin-top: 0; + } + + &:last-child, + &.no-last { + margin-bottom: 0; + } +} + +h1 { + font-size: 1.8em; +} + +h2 { + font-size: 1.55em; +} + +h3 { + font-size: 1.25em; +} + +p { + line-height: 1.5; + margin: 0 0 1em 0; +} + +footer, +small { + font-size: 0.85em; +} + +b, strong { + font-weight: 700 !important; +} + +.paragraph { + margin: 0 0 1em 0; +} + +.indented { + border-left: 5px solid #CCC; + padding-left: 0.5em; +} diff --git a/static/less/site.less b/static/less/site.less new file mode 100644 index 0000000..5c514d9 --- /dev/null +++ b/static/less/site.less @@ -0,0 +1,132 @@ +@import 'theme.less'; + +@import 'mixins/boxed-group.less'; +@import 'mixins/code.less'; +@import 'mixins/colors.less'; +@import 'mixins/controls.less'; +@import 'mixins/flexbox.less'; +@import 'mixins/fonts.less'; +@import 'mixins/layout.less'; +@import 'mixins/media.less'; +@import 'mixins/mixins.less'; +@import 'mixins/paragraphing.less'; + + +* { + box-sizing: border-box; +} + +html { + height: 100%; + + .roboto(300); + font-size: 10px; + + color: @font-color; +} + +body { + .flexbox; + .flex-column; + + text-rendering: optimizeLegibility; + font-size: 1.6rem; + padding: 0; margin: 0; + min-height: 100%; + overflow-y: auto; + + @media @mobile, @maxwidth768 { + .hide-mobile { + display: none !important; + } + } +} + +pre { + .roboto(300); + + margin: 0; +} + +a { + .interactive; + + text-decoration: none; + color: @blue; + outline: none; +} + +::selection { + text-shadow: none; + color: @highlight-color !important; + background-color: @highlight-background; +} + +.header { + @media @mobile, @maxwidth768 { + font-size: 30px; + } + + h1 { + margin-top: 1em; + } + + h3 { + margin-top: 0.5em; + } + + .instructions { + margin-bottom: 0.5em; + max-width: 600px; + } + + .logout { + width: 300px; + margin: 1em 0 0 0; + } + + .delete { + width: 300px; + margin: 1em 0; + } + + button { + width: 200px; + + @media @mobile, @maxwidth768 { + width: 400px; + } + + &.google-create { + background-color: #6CC644; + color: white; + } + + &.google-delete { + background-color: #BD2C00; + color: white; + } + } +} + +.title { + font-size: 2em; + font-weight: bold; +} + +div.service { + margin-top: 1em; + width: 300px; + + form { + button[type="submit"] { + margin: 0.3em 0; + } + } +} + +.logins { + button { + margin-bottom: 1em; + } +} diff --git a/static/less/theme.less b/static/less/theme.less new file mode 100644 index 0000000..ea9a3b7 --- /dev/null +++ b/static/less/theme.less @@ -0,0 +1,22 @@ +@blue: #2AC1DE; +@orange: #FF9933; +@green: #6CC644; +@red: #BD2C00; +@purple: #6E5494; + +@highlight-background: @blue; +@highlight-color: #FFF; + +@background-color: #F1F1F1; +@item-background-color: #FFF; +@font-color: #4A4A4A; +@font-color-subdue: lighten(@font-color, 35%); +@border-color: #EDEEEE; +@input-border-color: darken(@border-color, 7%); + +@background-color-dark: #2C3136; +@background-color-accent-dark: #33383E; +@background-color-differential-dark: #3E454C; +@background-color-secondary-dark: #1C1F22; +@font-color-dark: #FFF; +@font-color-subdue-dark: #656A70; diff --git a/tutorial/step-1.md b/tutorial/step-1.md new file mode 100755 index 0000000..64ff629 --- /dev/null +++ b/tutorial/step-1.md @@ -0,0 +1,125 @@ +# 1 . Create accounts, add API maps to BitScoop, and set up authorization +First, you need to [create a BitScoop account](https://bitscoop.com/signup) as well as [an AWS account](https://portal.aws.amazon.com/billing/signup). +You will need to create a Postman Pro account, a Google account, and/or a StatusCake account to use those services in this demo. +The use of GitHub in ths demo assumes that you will be monitoring a public repo, so there is no authentication needed. +Google Analytics will require some additional steps to use OAuth2 to authenticate; see the section below for instructions on what to do. + +For each API you are using, you will add an API Map to your BitScoop account using the “Add to BitScoop” buttons. +You can either enter any required API keys when you create the map or edit the source of that map later. + +## Add API Maps to BitScoop + +To quickly get started with what you'll need on BitScoop, you can add the following API Maps using the buttons below. +Note that you do not need to use all of these services for the demo to run, as the Amazon Lambda function that powers the Alexa Skill will adjust automatically based on how you configure it. +So you only need to add the ones to BitScoop that you want to try for yourself. +Make sure to substitute the values for the API keys, client IDs, and client secrets where appropriate. + +| API Map | File Name | | +|----------------|-----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Postman Pro API Monitors | postman.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/postman.json) | +| Google Analytics Data | google_analytics.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/google_analytics.json) | +| Google Sign-In | google_sign_in.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/google_sign_in.json) | +| StatusCake Health Alerts | statuscake.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/statuscake.json) | +| GitHub Issues | github.json | [![Add to BitScoop](https://assets.bitscoop.com/github/AddBitScoopXSmall.png)](https://bitscoop.com/maps/create?source=https://raw.githubusercontent.com/bitscooplabs/bitscoop-ops-buddy/master/fixtures/maps/github.json) | + + +# Postman Pro +You will need a Postman Pro account and a Postman API key. +In Postman, create a Monitor for an API you wish to test, then click on the Monitor. +The Monitor’s ID should be the last thing in the URL path. +When you create the Lambda function, you will need to add the Environment Variable **‘POSTMAN_MONITOR_ID’** and set the value to be this ID. + +Go to “Integrations” on the main menu, select “Postman Pro API”, and then create a new key. This key will be used in an Environment Variable in Lambda. + +Make sure you are logged into BitScoop, then on our [GitHub page](https://github.com/bitscooplabs/bitscoop-ops-buddy/tree/master/fixtures/maps), click the ‘Add to BitScoop’ button next to Postman. +You will be redirected to BitScoop and will see the JSON for the map you just added. +Edit the JSON to insert your API key, then click the ‘+ Create’ button in the upper right-hand corner to save the map. + +- Postman Pro API Documentation: https://docs.api.getpostman.com/ + +- Postman Monitor: https://www.getpostman.com/docs/monitors + +- Postman Documentation: https://www.getpostman.com/docs/ + +# Google Analytics +Make sure you are logged into BitScoop, then on our [GitHub page](https://github.com/bitscooplabs/bitscoop-ops-buddy/tree/master/fixtures/maps), click the ‘Add to BitScoop’ button next to Google Analytics. +You will be redirected to BitScoop and will see the JSON for the map you just added. +We don’t have an auth_key or auth_secret yet, so leave those fields as is. +Click the ‘+ Create’ button in the upper right-hand corner to save the map. + +Calling the Google Analytics API requires OAuth2 authentication, which BitScoop handles for you with minimal work on your end. +You’ll need to add a Google OAuth2 auth_key and auth_secret to the API Map, which we will walk you through now. + +Go to the [Google API Console for Analytics](https://console.developers.google.com/apis/api/analytics.googleapis.com/overview) and make sure Analytics is enabled. +Next click on ‘Credentials’ on the left-hand side, underneath ‘Dashboard’ and ‘Library’. +Click on the blue button ‘Create Credentials’ and select ‘OAuth client id’. +Choose application type ‘Web application’, then in ‘Authorized redirect URIs’ enter the Callback URL that can be found on the Details page for the Map you created for Google Analytics; it should be in the form https://auth.api.bitscoop.com/done/. +Click ‘Create’ twice; it should show a pop-up with your client ID and secret. +These will be entered in the API Map as the auth_key and auth_secret, respectively, in the ‘auth’ portion of the map. + +You will also need to go to [Google Analytics Settings](https://analytics.google.com/analytics/web/#management/Settings/), then select the View you want to use in the right-hand column and then ‘View Settings’ underneath that. +Under Basic Settings you should see the View ID, which you will set as the value for the Environment Variable ‘GOOGLE_GA_VIEW_ID’. +It should look like ga:123456789. + +When you have finished the rest of this entire setup and log in to the app to configure your services, you will need to click the button 'Create Google Connection' and follow the workflow that you are redirected to. +This will create a Connection that is used to authorize your calls to Google Analytics. +[We have more documentation and a video tutorial on the topic of Connections if you wish to learn more.](https://bitscoop.com/learn) + +- Google Analytics API Documentation: + + https://developers.google.com/analytics/ +- Google API Console for Analytics: + + https://console.developers.google.com/apis/api/analytics.googleapis.com/overview + +# Google Sign-In +You've already done most of the setup work already prepping the Google Analytics map. +Add the Google Sign-In map to your BitScoop account and add the same Auth Key and Secret you already obtained to it, then save it. +Finally, add the Callback URL for this map to the credentials' 'Authorized redirect URIs'. + +### Why multiple maps for Google? + +The reason there are separate maps for these two processes is that we want the use of Google accounts to be unique for sign-in, but not for Google Analytics. +If someone has already signed up for an OpsBuddy account with Google account 12345, you can't let anyone else create an OpsBuddy account with it without making login impossible, as you wouldn't know which OpsBuddy account was the right one to log in. +The Sign-In map has a uniqueness constraint so that this doesn't happen. +However, you do want any OpsBuddy account to use any Google account for its Connection for Google Analytics, and it's perfectly fine if multiple OpsBuddy accounts use the same Google Account for Analytics. +To this end, the Analytics map does not have a uniqueness constraint. + +Having both services in the same map would be impossible, as the uniqueness constraint is applied to all connections. +You wouldn't be able to use a Google account for Analytics if it had already been used for Sign-In +If you used the Sign-In account for Analytics, you a) could not use Google accounts for Analytics across multiple OpsBuddy accounts and b) would have to sign up for an OpsBuddy account with the Google account you wanted to pull Analytics data from. + +# StatusCake + +StatusCake delivers accurate global website monitoring and downtime alerts. + +You will need a StatusCake account and a StatusCake API Key. +You should create alerts on your vital dependent services and your public site. +Go to “User Details” on the main menu and take note of your Username, and then select “API Keys” to find your API key. +Get the Test ID by selecting ‘Tests’ from the main menu and select the test you wish to use. +The Test ID can be found in the URL under the ‘tid’ parameter. + +Example: Test ID 7654321 is derived from + +https://app.statuscake.com/AllStatus.php?tid=7654321 + +Make sure you are logged into BitScoop, then on our [GitHub page](https://github.com/bitscooplabs/bitscoop-ops-buddy/tree/master/fixtures/maps), click the ‘Add to BitScoop’ button next to StatusCake. +You will be redirected to BitScoop and will see the JSON for the map you just added. +Edit the JSON to insert your StatusCake Auth Key and Username. +Then click the ‘+ Create’ button in the upper right-hand corner to save the map. + +- StatusCake API Documentation: https://www.statuscake.com/api/ + +# GitHub +The GitHub endpoint used for this example is publicly accessible and does not require an API key or GitHub account. +This call will only succeed if the repo in question is public. +If you wanted to monitor a private repo, you would need to modify the codebase to create Connections similar to how the Google Analytics workflow does and use the Connection to sign requests. + +Make sure you are logged into BitScoop, then on our [GitHub page](https://github.com/bitscooplabs/bitscoop-ops-buddy/tree/master/fixtures/maps), click the ‘Add to BitScoop’ button next to the map for GitHub Demo. +You will be redirected to BitScoop and will see the JSON for the map you just added. +You do not need to add any information to this map. +Click the ‘+ Create’ button in the upper right-hand corner to save the map. + + - GitHub API Documentation https://developer.github.com/ + +Also make sure that you have created an API key for BitScoop with full permissions to access data, maps and connections, as all calls to the BitScoop API must be signed with one. diff --git a/tutorial/step-2.md b/tutorial/step-2.md new file mode 100755 index 0000000..6b6119f --- /dev/null +++ b/tutorial/step-2.md @@ -0,0 +1,16 @@ +# 2. Install Dependencies +You will need to have node.js and \`npm\` installed on your machine so that you can install the demo’s dependencies before uploading the code to Lambda. + +From the top level of this directory run + +`npm install` + +to install all of the project-wide dependencies, then go to the src/ directory and again run + +`npm install` + +and finally go to the /lib directory and again run + +`npm install` + +This will install all of the node_modules that are needed to build and run the demo. diff --git a/tutorial/step-3.md b/tutorial/step-3.md new file mode 100644 index 0000000..521d7e1 --- /dev/null +++ b/tutorial/step-3.md @@ -0,0 +1,106 @@ +# 3. Set up an RDS box and configure networking +There are several AWS services that need to be set up to run this demo. +We’re first going to tackle the networking and creating the SQL server that will hold our user database. +We’re going to create everything from scratch so that you don’t interfere with anything you may already have in AWS. + +### Create IAM role +Go to [IAM roles](https://console.aws.amazon.com/iam/home#/roles) and create a new role. +Click Lambda, then Next:Permissions. +You will need to add three policies to this role: +AWSLambdaBasicExecution +AWSCloudFormationReadOnlyAccess +AWSLambdaVPCAccessExecution + +Click Next:Review, give the role a name (such as 'bitscoop-demo'), and then click Create Role. +This role will be used by the Lambda function to specify what it has permission to access. + +### Create VPC +Go to your [VPCs](https://console.aws.amazon.com/vpc/home#vpcs:) and create a new one. +Tag it with something like ‘opsbuddy’ so you can easily identify it later. +For the IPv4 CIDR block, enter 10.0.0.0/16, or something similar, such as 10.1.0.0/16, if that is already taken. +Leave IPv6 CIDR block and tenancy as their defaults and create the VPC. +Select the VPC from the list, then click the Actions dropdown, then 'Edit DNS Hostnames'. +In here, click the Yes option and then Save. + +### Create subnets +View your [Subnets](https://console.aws.amazon.com/vpc/home#subnets). +You should create four new subnets. +Two of these will be public subnets, and two will be private. +Call the public ones ‘public1’ and ‘public2’, and the private ones ‘private1’ and ‘private2’. +Make sure they are all on the ‘opsbuddy’ VPC we created. +One public and one private subnet should be in the same availability zone, and the other public and private subnets should be in different AZs, e.g. public1 in us-east-1a, public2 in us-east-1c, private1 in us-east-1a, and private2 in us-east-1b. +Remember which AZ is shared between a public and private subnet for later. +The CIDR block needs to be different for each subnet and they all need to fall within the CIDR block of the VPC; if the VPC block is 10.0.0.0/16, you could use 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24, and 10.0.3.0/24. +AWS will let you know if anything overlaps. + +### Create Internet Gateway +Go to your [Internet Gateways](https://console.aws.amazon.com/vpc/home#igws). +Create a new Internet Gateway, give it a memorable name, and then click 'Yes,Create'. +Once it's created, select it in the list, click Attach to VPC, and attach it to the 'opsbuddy' VPC. + +### Create NAT Gateway +Go view your [NAT Gateways](https://console.aws.amazon.com/vpc/home#NatGateways). +Create a new Gateway, and for the subnet pick the public subnet that shares an AZ with a private subnet, e.g. ‘public1’ in the example above. +Click Create New EIP and then Create the gateway. +This new gateway should have an ID nat-. +It should be noted that, while almost everything in this demo is part of AWS’ free tier, NAT gateways are NOT free. +They’re pretty cheap, at about $0.05 per hour and $0.05 per GB of data processed, but don’t forget to delete this when you’re done with the demo (and don’t forget to create a new one and point the private route table to the new one if you revisit this demo). + +### Create Route Tables +Go to [Route Tables](https://console.aws.amazon.com/vpc/home#routetables) and create two new ones. +Name one ‘opsbudyd-public’ and the other ‘opsbuddy-private’, and make sure they’re in the ‘opsbuddy’ VPC. +When they’re created, click on the ‘private’ one and select the Routes tab at the bottom of the page. +Click Edit, and add another route with a destination of 0.0.0.0/0 and a target of the NAT gateway we just created (so nat-, not igw-). +Save the private route table. +Now click the public route table, go to the Routes tab, Edit it, and add a route with destination 0.0.0.0/0 and a target of the Internet Gateway we created just before this, then save the route table. + +### Update Subnets to Point to Route Tables +Go back to the subnets and click on one of the ‘private’ ones. +Click on the Route Table tab, click Edit, and change the 'Change to' dropdown to the ‘private’ Route Table that you created in the previous step. +Then click Save. +Repeat this for the other ‘private’ subnet. +You will also need to change the public subnets to point to the public route table if they don't point there already. + +### Create Security Groups +You also need to create a couple of [Security Groups](https://console.aws.amazon.com/vpc/home#securityGroups:). +Name the first one ‘opsbuddy-rds’ and make sure it’s in the ‘opsbuddy’ VPC, then create it. +Click on it in the list, click on the Inbound Rules tab, and then click Edit. +You’ll want to add a MySQL/Aurora rule (port 3306) for 10.0.0.0/16 (or whatever CIDR block you picked for the VPC) so Lambda can access the RDS box internally. +If you want to make sure that the box you’re going to set up is working as intended, you can also add a MySQL/Aurora rule for your IP address with /32 appended to the end (e.g. 123.456.789.012/32). +You do not need to add any Outbound Rules. + +You also need to add a Security Group called ‘opsbuddy-lambda’. +This does not need any Inbound Rules, but it does need Outbound Rules for HTTP (80) to 0.0.0.0/0, HTTPS (443) to 0.0.0.0/0, and MySQL/Aurora (3306) to 0.0.0.0/0. + +### Create Subnet Group +Go to your [RDS Subnet Groups](https://console.aws.amazon.com/rds/home#db-subnet-groups:). +Click 'Create DB Subnet Group', then give the new group a name and select the 'opsbuddy' VPC. +Under 'Add subnets', you will need to add the two public subnets. +You must first select the AZ where those subnets are located, then the subnet (it will be grayed out/uneditable if there is only one subnet in the AZ), then click 'Add subnet'. +Finally hit Continue to create the subnet group. + +### Create RDS Box +Finally, you will set up the [RDS](https://console.aws.amazon.com/rds/home) box to store the data that will be generated. +Click on Instances and select Launch DB Instance. +For this demo we are using MySQL; if you wish to use a different database, you may have to install a different library in the demo project and change the Sequelize dialect to that db. + +On this page you can click the checkbox ‘Only show options that are eligible for RDS Free Tier’ to ensure you don’t configure a box that costs money. +Click on MySQL (or whatever Engine you want) and then click the Next button to go to ‘Specify DB Details’. + +Select a DB Instance class; db.t2.micro is normally free and should be sufficient for this demo, as should the default storage amount (5GB as of publication). +Pick a DB Instance Identifier, as well as a username and password. +Save the latter two for later reference, as you will need to set Environment Variables in the Lambda function for them so that the function can connect to the DB. +Click Next Step. + +Under Network and Security, select the ‘opsbuddy’ VPC. +For 'Subnet group', make sure it is the one we just created. +Select Yes for 'Public accessibility', and for Availability zone, select the AZ that's shared between a public and private subnet (us-east-1a in the above example). +Under VPC Security Groups, click 'Select existing VPC security groups', then select the ‘rds’ group we created earlier (also delete any other groups it may have already attached). +Make sure to give the database a name and save this name for later use, as it too will need to be added to an Environment Variable. +Leave everything else as-is and click Launch DB Instance. + +Go to your [RDS instances](https://console.aws.amazon.com/rds/home#dbinstances). +When the box you just created is ready, click Instance Actions, then See Details. +Scroll down to the Connect section. +Take note of the Endpoint field. +Save this for later use, as it will be used in another Environment Variable. diff --git a/tutorial/step-4.md b/tutorial/step-4.md new file mode 100644 index 0000000..afb593e --- /dev/null +++ b/tutorial/step-4.md @@ -0,0 +1,114 @@ +# 4. Deploy Frontend API code to Amazon Lambda, create API Gateway, deploy static files to S3 +With all of the networking squared away, we now need to upload all of our project files to the appropriate services. + +### Create Frontend Lambda Function +First we’re going to create a Lambda function to serve as the API views for rendering the main page, signing up new users, logging users in and out, and handling callbacks from the authentication process. +Go to [your Lambda functions](https://console.aws.amazon.com/lambda/home#/functions?display=list) and Create a new function. +Click 'Author from scratch'. +Name the function; for reference we’ll call this ‘opsbuddy-frontend’. +For Role select 'Choose an existing role', then select the 'opsbuddy' role we created earlier, then click 'Create function'. + +Under 'Function code', make sure the runtime is ‘Node.js 6.10’ and the Handler is 'index.handler'. +Leave the Code Entry Type as ‘Edit Code inline’, as we need to modify the project’s code with some information we don’t have yet before we can upload it. + +You will need to add several Environment Variables: + +* BITSCOOP_API_KEY (obtainable at https://bitscoop.com/keys) +* PORT (by default it’s 3306) +* HOST (the endpoint for the RDS box, ...rds.amazonaws.com) +* USER (the username you picked for the RDS box) +* PASSWORD (the password you set for the RDS box) +* DATABASE (the database name you set for the RDS box) +* GITHUB_MAP_ID (the ID of the BitScoop API map for GitHub) +* GOOGLE_ANALYTICS_MAP_ID (the ID of the BitScoop API map for Google Analytics) +* GOOGLE_SIGN_IN_MAP_ID (the ID of the BitScoop API map for Google Sign-In) +* POSTMAN_MAP_ID (the ID of the BitScoop API map for Postman) +* STATUSCAKE_MAP_ID (the ID of the BitScoop API map for StatusCake) +* SITE_DOMAIN (The domain of the API gateway; this will be filled in later) + +Go to the Basic settings block and set the timeout to 15 seconds. +Open the Network block, select the ‘opsbuddy’ VPC we created and add the two ‘private’ subnets we created earlier, and add the ‘opsbuddy-lambda’ security group. +Finally, scroll back up to the top of the page and click Save (not Save and test, as we don't want to test run it). + +### Create API Gateway for Frontend +Next we will create an API gateway to handle traffic to the endpoints that will serve up the views for this project. +Go to the [API Gateway home](https://console.aws.amazon.com/apigateway/home#/apis) and click Create API. +Name the API whatever you want; for reference purposes we’ll call it ‘opsbuddy’. +Finally click Create API. + +You should be taken to the API you just created. +Click on the Resources link if you aren’t there already. +Highlight the resource ‘/’ (it should be the only one present), click on the Actions dropdown and select ‘Create Method’. +Click on the blank dropdown that appears and select the method ‘GET’, then click the checkmark next to it. +Make sure the Integration Type is ‘Lambda Function’. +Check ‘Use Lambda Proxy integration’, select the region your Lambda function is in, and enter the name of that Lambda function (e.g. opsbuddy-frontend), then click Save. +Accept the request to give the API gateway permission to access the Lambda function. + +What we’ve just done is configure GET requests to the ‘/’ path on our API to point to the Lambda function that has all of the project’s views. +We’re using API Gateway’s Proxy integration, which passes parameters and headers as-is on both requests to and responses from the Lambda function. + +We next need to add sub-routes for our other views. +Select the ‘/’ resource, then click the Actions dropdown and select ‘Create Resource’. +Enter ‘complete-login’ for the Resource Name, and the Resource Path should be filled in with this automatically as well, which is what we want. +Leave the checkboxes unchecked and click the Create Resource button. +When that’s been created, click on the ‘/complete-login’ resource and follow the steps above for adding a GET method to that resource. +Repeat this process for the resources 'complete-service', 'connections', 'login', 'logout', 'signup', and 'users'. +'/connections' needs a 'DELETE' method in additional to its 'GET', and '/users' does not need a 'GET' method but does need a 'DELETE' and 'PATCH' methods. +All of the others just need a 'GET'. + +When you’ve done all of that, you should have one top-level resource ‘/’ and seven resources under that, ‘/complete-login’, '/complete-service', '/connections', ‘/login’, ‘/logout’, ‘/signup’, and '/users'. +Click on the ‘/’ resource, then click on the Actions dropdown and select ‘Deploy API’. +For Deployment Stage select ‘New Stage’ and give it a name; we suggest ‘prod’, but it can be anything. +You can leave both descriptions blank. +Click Deploy when you’re done. + +The final thing to do is get the URL at which this API is available. +Click ‘Stages’ on the far left, underneath the ‘Resources’ of this API. +Click on the stage you just created. +The URL should be shown as the ‘Invoke URL’ in the top middle of the page on a blue background. +You need to copy this URL, minus 'https://', into the SITE_DOMAIN Environment Variable in the frontend Lambda function (don’t forget to Save the Lambda function). + +### Build Static Files and Deploy to S3 +Navigate to the top level of the project and run + +``` +gulp build +``` + +to compile and package all of the static files to the dist/ folder. + +Next we’re going to create an S3 bucket to host our static files. +Go to S3 and create a new bucket. +Give it a name and select the region that’s closest to you, then click Next. +You can leave Versioning, Logging, and Tags disabled, so click Next. +For 'Manage Public permissions', click on 'Grant public read access'. +Click Next, review everything, then click Create Bucket. + +Click on the new bucket, then go to the Overview tab and click Upload to have a modal appear. +Click Add Files in this modal and navigate to the ‘dist’ directory in the bitscoop-opsbuddy-demo directory, then into the directory below that (it’s a unix timestamp of when the build process was completed). +Move the file system window so that you can see the Upload modal. +Click and drag the 'static' folder over the Upload modal (S3 requires that you drag-and-drop folders, and this only works in Chrome and Firefox). +Close the file system window, then click Next. +Make sure to grant public read access under 'Manage public permissions'. +Click Next, then Next again, then review everything and click Upload. + +### Update Frontend Code with Static URL and Deploy to Lambda +Lastly, go to src/templates/home.html and replace ***INSERT S3 BUCKET NAME HERE*** with the name of the S3 bucket you created earlier. +From the top level of the project run + +``` +gulp bundle:frontend +``` + +to compile the code for the Lambda function to the /dist folder. +Go to the Lambda function we created earlier, click on the Code tab, then for ‘Code entry type’ select ‘Upload a .ZIP file’. +Click on the Upload button that appears next to ‘Function package’ and select the .zip file in the /dist folder. +Make sure to Save the function. + +### Create and Configure account +If all has gone well, you should be able to hit the Invoke URL and see a page asking you to log in or sign up (if you are using the publicly-hosted copy, just go to www.opsbuddy.bitscoop.com). +If you click the sign up button, you should be redirected to a prompt from Google authorizing the application that was created to have access to your public info. +After authorization, you should be redirected back to the homepage, where you now have an account with the demo project. +From here you can set the credentials and settings for the four services as well as create a Connection to Google for signing the Google Analytics calls. +The Connection you create can be with a different Google account than you used to sign up for the Ops Buddy account, but it must have access to the Google Analytics data you want to retrieve. +If you log out, then click log in, you should be automatically logged back in, though if you have more than one Google account you will have to click on the one you want to use for the login. diff --git a/tutorial/step-5.md b/tutorial/step-5.md new file mode 100755 index 0000000..42f365c --- /dev/null +++ b/tutorial/step-5.md @@ -0,0 +1,127 @@ +# 5. Deploy Backend code to Lambda and Set Up Alexa Skill + +### Create Backend Lambda Function +First we’re going to create a Lambda function to handle Alexa login and invocations. + +From the top level of the project run + +``` +gulp bundle:backend +``` + +to compile the backend code in the /dist folder. + +Go to [your Lambda functions](https://console.aws.amazon.com/lambda/home#/functions?display=list) and Create a new function. +Click 'Author from scratch'. +Name the function; for reference we’ll call this ‘opsbuddy-backend’. +For Role select 'Choose an existing role', then select the 'opsbuddy' role we created earlier, then click 'Create function'. + +If you are not taken to the function automatically, then select the role we just created from the list of functions. +Under 'Function code', make sure the runtime is ‘Node.js 6.10’ and the Handler is 'index.handler'. +Select ‘Upload a .ZIP file’ for ‘Code entry type’. +Click on the Upload button that appears next to ‘Function package’ and select the backend .zip file in the /dist folder. + +You will need to add several Environment Variables: + +* BITSCOOP_API_KEY (obtainable at https://bitscoop.com/keys) +* PORT (by default it’s 3306) +* HOST (the endpoint for the RDS box, ...rds.amazonaws.com) +* USER (the username you picked for the RDS box) +* PASSWORD (the password you set for the RDS box) +* DATABASE (the database name you set for the RDS box) +* GITHUB_MAP_ID (the ID of the BitScoop API map for GitHub) +* GOOGLE_ANALYTICS_MAP_ID (the ID of the BitScoop API map for Google Analytics) +* GOOGLE_SIGN_IN_MAP_ID (the ID of the BitScoop API map for Google Sign-In) +* POSTMAN_MAP_ID (the ID of the BitScoop API map for Postman) +* STATUSCAKE_MAP_ID (the ID of the BitScoop API map for StatusCake) +* ALEXA_APP_ID (The ID of the Alexa app; this will be filled in later) + +Go to the Basic settings block and set the timeout to 15 seconds. +Open the Network block, select the ‘opsbuddy’ VPC we created and add the two ‘private’ subnets we created earlier, and add the ‘opsbuddy-lambda’ security group. +Finally, scroll back up to the top of the page and click Save (not Save and test, as we don't want to test run it). +Click on the Triggers tab, next to Configuration. +Click on 'Add Trigger', then click on the dashed gray box, select 'Alexa Skills Kit', and then click Submit. +Look in the upper-right corner of the screen to find the ARN; we will need this momentarily. + +### Set up Alexa Skill +Go to [your Alexa Skills](https://developer.amazon.com/edw/home.html#/skills) and click ‘Add a New Skill’. + +Under Skill Information, pick ‘Custom Interaction Model’ for the Skill Type, enter ‘Ops Buddy’ for Name and Interaction Name, and click Save. +Click on Interaction Model on the left-hand side. Under Intent Schema enter: + +``` +{ + "intents": [ + { + "intent": "AboutIntent" + }, + { + "intent": "StackIntent" + }, + { + "intent": "LoginIntent" + } + ] +} +``` + +and under Sample Utterances enter: + +``` +StackIntent how's the stack doing + +StackIntent how is the stack doing + +StackIntent how is the stack doing today +``` + +You can add more utterances if you wish; the key thing is that they must start with ‘StackIntent’ to call the right Intent in the Lambda function. +Click Save when you’re done. + +Next click on Configuration. +Service Endpoint Type should be ‘AWS Lambda ARN’. +Paste the ARN from the end of Step 3 into the 'Default' text box right under the endpoint type. +Select 'Yes' under Account Linking, after which a number of fields will appear. +For Authorization URL, enter 'https://accounts.google.com/o/oauth2/auth?access_type=offline&approval_prompt=force'. +Client ID should be the public key you generated for Google. +Add a domain for Domain List and make it 'google.com'. +Add two scopes, which should be 'https://www.googleapis.com/auth/userinfo.email' and 'https://www.googleapis.com/auth/userinfo.profile'. +Take note of the Redirect URLs, as you will need to use one of them soon. +Authorization Grant Type should be 'Auth Code Grant'. +Set Access Token URI to 'https://accounts.google.com/o/oauth2/token'. +Enter your Google secret key under Client Secret. +Set Client Authentication Scheme to 'Credentials in request body', then click Save. + +You need to go back to the Google credentials you made and add one of the Redirect URLs from the Configuration page to 'Authorized Redirect URLs'. +Make sure to save the credentials. + +Go to Publishing Information in the Alexa Skill. +Pick a category and sub category; we recommend Productivity - Organizers & Assisstants, but you can pick whatever you want. +You must enter some testing instructions. +Add the two skill descriptions, and enter a few example phrases. +You must also upload a small and large icon. +When all of that is done, click Save. + +Go to Privacy & Compliance. +The first question should be No, the second Yes, and the third No. +Check Export Compliance and select No for the advertising question. +For the Privacy Policy URL you can enter 'https://bitscoop.com/privacy'. +Finally, save this. + +At this point, you should see the 'Skills Beta Testing' box on the left change and a button to 'Beta Test Your Skill'. +This means that all necessary information has been entered and you can now run the app. +Click on this button, then enter some emails of testers and click 'Update Testers'. +Finally click 'Start test' in the bottom right. +You should be taken to a page with an Invite URL. + +### Sign in with an Alexa product +[Sign in to Alexa account](alexa.amazon.com) with an email that was provided under the Skills Beta testing. +Next, follow the Invite URL to add that skill to your account +(If you are using the publicly-hosted copy, just search for the skill 'BitScoop Ops Buddy' and install it instead of following any Invite URL). +You should be taken to the page for that skill, but if not go to Skills, then click on 'Your Skills', then select the new skill. +Click on Enable, then click on 'Link Account' when that appears. +Sign in with the same Google account you used to sign up with the front-end app. +If all has succeeded, you should see a page saying the skill was successfully linked. + +You should now be able to ask any Alexa device 'Ask how's the stack doing', and it should return information from whatever services you enabled and configured properly in the front-end app. +[EchoSim](https://echosim.io/) is a handy Alexa tester if you do not have another device available.