├── .babelrc ├── packages ├── frontend │ ├── .npmrc │ ├── .gitignore │ ├── karma.conf.js │ ├── src │ │ ├── favicon.ico │ │ ├── styles.css │ │ ├── reducers │ │ │ ├── index.js │ │ │ └── comments.js │ │ ├── ui │ │ │ ├── app.js │ │ │ ├── comment.js │ │ │ ├── comments.js │ │ │ ├── commentsContainer.js │ │ │ ├── comments.css │ │ │ └── postCommentForm.js │ │ ├── routes.js │ │ ├── root.js │ │ ├── store.js │ │ ├── client.js │ │ ├── server.js │ │ └── actions │ │ │ └── comments.js │ ├── README.md │ ├── .babelrc │ ├── tests.webpack.js │ ├── .env.SAMPLE │ ├── .eslintrc │ ├── react-project.js │ ├── webpack.config.js │ ├── package.json │ └── static │ │ ├── fetch.min.js │ │ ├── Promise.min.js │ │ └── es5-shim.min.js ├── utils │ ├── .babelrc │ ├── src │ │ ├── references.js │ │ ├── slack.js │ │ ├── akismet.js │ │ ├── dynamoDb.js │ │ ├── cloudFormation.js │ │ └── s3.js │ └── package.json └── lambda │ ├── package.json │ └── src │ ├── worker │ ├── test.js │ └── index.js │ └── queueComment │ ├── index.js │ └── test.js ├── lerna.json ├── .gitignore ├── bin ├── get-client-js-url.js ├── dump-config.js ├── run-babel.js ├── gen-api-key.js ├── upload-script.js ├── setup-apex.js ├── flip-lambdas-to-4.3.js └── save-cloudformation-config.js ├── babel-register.js ├── deploy ├── apex │ ├── webpack.config.js │ └── webpack.config.es6.js └── cloudformation │ ├── cloudformation.sh │ └── lambda-comments.json ├── .env.SAMPLE ├── LICENSE ├── test └── test.js ├── package.json └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0" ] 3 | } 4 | -------------------------------------------------------------------------------- /packages/frontend/.npmrc: -------------------------------------------------------------------------------- 1 | save = true 2 | save-exact = true 3 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.0.0-beta.9", 3 | "version": "0.0.3" 4 | } 5 | -------------------------------------------------------------------------------- /packages/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .env 3 | lib 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /packages/utils/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "stage-0" ] 3 | } 4 | -------------------------------------------------------------------------------- /packages/frontend/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = require('react-project/test').KarmaConf 2 | 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | deploy/state 4 | build 5 | deploy.log 6 | npm-debug.log 7 | lerna-debug.log 8 | -------------------------------------------------------------------------------- /packages/frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jimpick/lambda-comments/HEAD/packages/frontend/src/favicon.ico -------------------------------------------------------------------------------- /packages/frontend/src/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: #f0f0f0; 3 | font-family: sans-serif; 4 | font-weight: 200; 5 | } 6 | -------------------------------------------------------------------------------- /packages/frontend/README.md: -------------------------------------------------------------------------------- 1 | # lambda-comments-frontend 2 | 3 | The front end code for https://github.com/jimpick/lambda-comments 4 | 5 | -------------------------------------------------------------------------------- /packages/frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "es2015", "react", "stage-1" ], 3 | "plugins": [ "transform-decorators-legacy" ] 4 | } 5 | 6 | -------------------------------------------------------------------------------- /packages/frontend/tests.webpack.js: -------------------------------------------------------------------------------- 1 | const context = require.context('./modules', true, /.test\.js$/) 2 | context.keys().forEach(context) 3 | 4 | -------------------------------------------------------------------------------- /bin/get-client-js-url.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { getClientJsUrl } from 'lambda-comments-utils/src/cloudFormation' 3 | 4 | dotenv.config() 5 | 6 | console.log(getClientJsUrl()) 7 | -------------------------------------------------------------------------------- /packages/utils/src/references.js: -------------------------------------------------------------------------------- 1 | import slugid from 'slugid' 2 | 3 | export function generateReference (date) { 4 | const ref = date.format('YYYY/MM/DD/HH:mm-') + slugid.v4() 5 | return ref 6 | } 7 | -------------------------------------------------------------------------------- /babel-register.js: -------------------------------------------------------------------------------- 1 | require('babel-register')({ 2 | ignore: function (filename) { 3 | if (filename.match(/lambda-comments-utils\/src/)) { 4 | return false 5 | } 6 | return filename.match(/node_modules/) 7 | } 8 | }) 9 | -------------------------------------------------------------------------------- /bin/dump-config.js: -------------------------------------------------------------------------------- 1 | import { parse } from 'url' 2 | import dotenv from 'dotenv' 3 | 4 | dotenv.config() 5 | 6 | const { protocol, host } = parse(process.env.BLOG) 7 | process.env.ORIGIN = `${protocol}//${host}` 8 | 9 | console.log(process.env[process.argv[2]]) 10 | -------------------------------------------------------------------------------- /packages/frontend/.env.SAMPLE: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=8090 3 | PUBLIC_PATH=/ 4 | #SERVER_RENDERING=on 5 | SERVER_RENDERING=off 6 | 7 | ######################### 8 | # Development only config 9 | DEV_PORT=8081 10 | DEV_HOST=localhost 11 | AUTO_RELOAD=refresh 12 | #AUTO_RELOAD=hot 13 | #AUTO_RELOAD=none 14 | 15 | -------------------------------------------------------------------------------- /deploy/apex/webpack.config.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill') 2 | require('babel-register')({ 3 | ignore: function (filename) { 4 | if (filename.match(/lambda-comments-utils\/src/)) { 5 | return false 6 | } 7 | return filename.match(/node_modules/) 8 | } 9 | }) 10 | module.exports = require('./webpack.config.es6') 11 | -------------------------------------------------------------------------------- /bin/run-babel.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill') 2 | require('babel-register')({ 3 | ignore: function (filename) { 4 | if (filename.match(/lambda-comments-utils\/src/)) { 5 | return false 6 | } 7 | return filename.match(/node_modules/) 8 | } 9 | }) 10 | 11 | // console.log(process.argv[2]) 12 | require(process.argv[2]) 13 | -------------------------------------------------------------------------------- /packages/frontend/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { routerReducer } from 'react-router-redux' 3 | import { reducer as formReducer } from 'redux-form' 4 | import comments from './comments' 5 | 6 | export default combineReducers({ 7 | routing: routerReducer, 8 | form: formReducer, 9 | comments, 10 | }) 11 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/app.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | 3 | export default class App extends Component { 4 | 5 | static propTypes = { 6 | children: PropTypes.element.isRequired, 7 | } 8 | 9 | render () { 10 | return ( 11 |
12 | {this.props.children} 13 |
14 | ) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /packages/frontend/src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route } from 'react-router' 3 | import App from './ui/app' 4 | import CommentsContainer from './ui/commentsContainer' 5 | 6 | export default () => ( 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | -------------------------------------------------------------------------------- /bin/gen-api-key.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import slugid from 'slugid' 4 | import mkdirp from 'mkdirp' 5 | 6 | const json = { apiKey: slugid.v4() } 7 | const stateDir = path.normalize(`${__dirname}/../deploy/state`) 8 | mkdirp.sync(stateDir) 9 | fs.writeFileSync(`${stateDir}/apiKey.json`, JSON.stringify(json)) 10 | console.log('deploy/state/apiKey.json created') 11 | -------------------------------------------------------------------------------- /packages/frontend/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "globals": { 4 | "__DEV__": true, 5 | "__CONFIG__": true 6 | }, 7 | "parser": "babel-eslint", 8 | "rules": { 9 | "semi": [ 2, "never" ], 10 | "max-len": [ 2, 80, 2 ], 11 | "space-before-function-paren": [ 2, "always" ], 12 | "react/jsx-uses-react": 1, 13 | "react/jsx-no-undef": 2, 14 | "react/wrap-multilines": 2, 15 | "react/prefer-stateless-function": 0 16 | }, 17 | "plugins": [ 18 | "react" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/utils/src/slack.js: -------------------------------------------------------------------------------- 1 | import Slack from 'node-slack' 2 | import WError from 'verror' 3 | 4 | export function postToSlack ({ message, quiet }) { 5 | const { SLACK: slackWebhook } = process.env 6 | const slack = slackWebhook ? new Slack(slackWebhook) : null 7 | 8 | if (!slack) { 9 | return Promise.resolve() 10 | } 11 | if (!quiet) { 12 | console.log('Posting to Slack') 13 | } 14 | const promise = slack.send(message) 15 | .catch(error => { 16 | throw new WError(err, 'Slack') 17 | }) 18 | return promise 19 | } 20 | -------------------------------------------------------------------------------- /packages/lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-comments-lambda", 3 | "version": "0.0.3", 4 | "description": "AWS Lambda functions for lambda-comments", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "repository": "jimpick/lambda-comments", 9 | "author": "Jim Pick ", 10 | "license": "ISC", 11 | "bugs": { 12 | "url": "https://github.com/jimpick/lambda-comments/issues" 13 | }, 14 | "homepage": "https://github.com/jimpick/lambda-comments#readme", 15 | "dependencies": { 16 | "lambda-comments-utils": "0.0.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/frontend/react-project.js: -------------------------------------------------------------------------------- 1 | // This is an alternative top-level for react-project 2 | // that knows to run certain external files through 3 | // babel 4 | 5 | require('babel-polyfill') 6 | require('babel-register')({ 7 | ignore: function (filename) { 8 | if (filename.match(/lambda-comments-utils\/src/)) { 9 | return false 10 | } 11 | return filename.match(/node_modules/) 12 | } 13 | }) 14 | 15 | var dotenv = require('dotenv') 16 | var path = require('path') 17 | 18 | dotenv.load({ 19 | path: process.cwd() + '/.env', 20 | silent: true 21 | }) 22 | 23 | require('react-project/lib/cli') 24 | 25 | -------------------------------------------------------------------------------- /bin/upload-script.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import dotenv from 'dotenv' 4 | import { uploadWebsite } from 'lambda-comments-utils/src/s3' 5 | 6 | dotenv.config() 7 | 8 | const filename = 'lambda-comments.js' 9 | const buildDir = path.normalize(`${__dirname}/../packages/frontend/.build`) 10 | const jsFile = fs.readFileSync(path.join(buildDir, filename), 'utf8') 11 | 12 | async function run () { 13 | await uploadWebsite({ 14 | key: filename, 15 | data: jsFile, 16 | contentType: 'application/javascript' 17 | }) 18 | console.log(`${filename} uploaded to S3 bucket.`) 19 | } 20 | run() 21 | -------------------------------------------------------------------------------- /packages/frontend/src/root.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import { Provider } from 'react-redux' 3 | import { Router } from 'react-router' 4 | 5 | export default class Root extends React.Component { 6 | static propTypes = { 7 | history: PropTypes.object.isRequired, 8 | routes: PropTypes.element.isRequired, 9 | store: PropTypes.object.isRequired, 10 | }; 11 | 12 | render () { 13 | const { history, routes, store } = this.props 14 | return ( 15 | 16 | 17 | {routes} 18 | 19 | 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-comments-utils", 3 | "version": "0.0.3", 4 | "description": "Utility functions for lambda-comments", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "repository": "jimpick/lambda-comments", 9 | "author": "Jim Pick ", 10 | "license": "ISC", 11 | "bugs": { 12 | "url": "https://github.com/jimpick/lambda-comments/issues" 13 | }, 14 | "homepage": "https://github.com/jimpick/lambda-comments#readme", 15 | "devDependencies": { 16 | "babel-core": "^6.8.0", 17 | "babel-preset-es2015": "^6.6.0", 18 | "babel-preset-stage-0": "^6.5.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.env.SAMPLE: -------------------------------------------------------------------------------- 1 | # The URL of your blog/website that will be hosting the comments 2 | # Used to generate CORS headers and also for Akismet 3 | BLOG=https://example.com/blog/ 4 | 5 | # A name for your CloudFormation stack 6 | # Also prefixed to the API Gateway REST API name 7 | CLOUDFORMATION=myBlogComments 8 | 9 | # The AWS region to provision the resources in 10 | REGION=us-west-2 11 | 12 | # The name for the API Gateway stage 13 | STAGE=prod 14 | 15 | # The Akismet.com API key (optional, but recommended) 16 | # Akismet is a service for combatting blog spam from Automattic (WordPress) 17 | #AKISMET=0123456789ab 18 | 19 | # A Slack webhook to send notifications to (optional) 20 | #SLACK=https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The ISC License 2 | 3 | Copyright (c) Jim Pick and Contributors 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 15 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill' 2 | import dotenv from 'dotenv' 3 | 4 | dotenv.config() 5 | 6 | const lambdaFunctions = [ 7 | 'queueComment', 8 | 'worker' 9 | ] 10 | const localTests = lambdaFunctions.reduce((prev, test) => ({ 11 | [test]: require(`../packages/lambda/src/${test}/test`).local, 12 | ...prev 13 | }), {}) 14 | const remoteTests = lambdaFunctions.reduce((prev, test) => ({ 15 | [test]: require(`../packages/lambda/src/${test}/test`).remote, 16 | ...prev 17 | }), {}) 18 | 19 | function local () { 20 | describe('local', function () { 21 | Object.values(localTests).forEach(test => { 22 | test && test() 23 | }) 24 | }) 25 | } 26 | 27 | function remote () { 28 | describe('remote', function () { 29 | Object.values(remoteTests).forEach(test => { 30 | test && test() 31 | }) 32 | }) 33 | } 34 | 35 | local() 36 | remote() 37 | -------------------------------------------------------------------------------- /bin/setup-apex.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import fs from 'fs' 3 | import rimraf from 'rimraf' 4 | import mkdirp from 'mkdirp' 5 | import dotenv from 'dotenv' 6 | import { outputs } from 'lambda-comments-utils/src/cloudFormation' 7 | 8 | dotenv.config() 9 | 10 | const apexProjectTemplate = { 11 | name: process.env.CLOUDFORMATION, 12 | description: 'lambda-comments Lambda Functions', 13 | memory: 128, 14 | timeout: 30, 15 | runtime: 'nodejs4.3', 16 | shim: false, 17 | role: outputs.LambdaRoleArn, 18 | nameTemplate: '{{.Project.Name}}-{{.Function.Name}}', 19 | handler: 'index.handler' 20 | } 21 | const json = JSON.stringify(apexProjectTemplate, null, 2) 22 | const buildDir = path.normalize(`${__dirname}/../build/apex`) 23 | rimraf.sync(buildDir) 24 | mkdirp.sync(buildDir) 25 | fs.writeFileSync(`${buildDir}/project.json`, json) 26 | console.log('build/apex/project.json created') 27 | -------------------------------------------------------------------------------- /packages/lambda/src/worker/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import supertest from 'supertest' 3 | import { apiUrl } from 'lambda-comments-utils/src/cloudFormation' 4 | 5 | import { expect } from 'chai' 6 | import { handler } from './index' 7 | 8 | export function local () { 9 | 10 | describe('Worker', function () { 11 | 12 | this.timeout(5000) 13 | 14 | it('should process the action', function (done) { 15 | const event = { 16 | Records: [ 17 | { 18 | dynamodb: { 19 | NewImage: { 20 | actionRef: { 21 | S: '2016/04/23/17:34-xmZXF7R1RPCekSqC1b4FXA', 22 | }, 23 | dirName: { 24 | S: 'comments/post/good-to-great' 25 | } 26 | } 27 | } 28 | } 29 | ] 30 | } 31 | event.quiet = true 32 | event.dryRun = true // FIXME: We should mock the HTTP and AWS calls 33 | handler(event, null, error => { 34 | expect(error).to.be.null 35 | done() 36 | }) 37 | }) 38 | 39 | }) 40 | 41 | } 42 | -------------------------------------------------------------------------------- /packages/frontend/src/store.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux' 2 | import thunk from 'redux-thunk' 3 | import rootReducer from './reducers' 4 | import { routerMiddleware } from 'react-router-redux' 5 | import createLogger from 'redux-logger' 6 | 7 | export default function configureStore (initialState = {}, history) { 8 | const middlewareList = [ 9 | thunk, 10 | routerMiddleware(history), 11 | ] 12 | 13 | if (process.env.NODE_ENV === 'development') { 14 | const logger = createLogger({ collapsed: true }) 15 | middlewareList.push(logger) 16 | } 17 | 18 | // Compose final middleware and use devtools in debug environment 19 | const middleware = applyMiddleware.apply(null, middlewareList) 20 | 21 | // Create final store and subscribe router in debug env ie. for devtools 22 | const store = middleware(createStore)(rootReducer, initialState) 23 | 24 | if (module.hot) { 25 | module.hot.accept('./reducers', () => { 26 | const nextRootReducer = require('./reducers').default 27 | 28 | store.replaceReducer(nextRootReducer) 29 | }) 30 | } 31 | return store 32 | } 33 | -------------------------------------------------------------------------------- /packages/utils/src/akismet.js: -------------------------------------------------------------------------------- 1 | import akismet from 'akismet-api' 2 | 3 | export default class Akismet { 4 | constructor () { 5 | const { AKISMET: apiKey, BLOG: blog } = process.env 6 | 7 | this.client = akismet.client({ blog, key: apiKey }) 8 | this.apiKey = apiKey 9 | this.blog = blog 10 | } 11 | 12 | configured () { 13 | return !!this.apiKey 14 | } 15 | 16 | verifyKey () { 17 | if (!this.apiKey) { 18 | throw new Error('Missing Akismet API Key') 19 | } 20 | return this.client.verifyKey() 21 | } 22 | 23 | checkSpam (options) { 24 | if (!this.apiKey) { 25 | throw new Error('Missing Akismet API Key') 26 | } 27 | return this.client.checkSpam({ blog: this.blog, ...options }) 28 | } 29 | 30 | submitSpam (options) { 31 | if (!this.apiKey) { 32 | throw new Error('Missing Akismet API Key') 33 | } 34 | return this.client.submitSpam({ blog: this.blog, ...options }) 35 | } 36 | 37 | submitHam (options) { 38 | if (!this.apiKey) { 39 | throw new Error('Missing Akismet API Key') 40 | } 41 | return this.client.submitHam({ blog: this.blog, ...options }) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /bin/flip-lambdas-to-4.3.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk' 2 | import { resources } from 'lambda-comments-utils/src/cloudFormation' 3 | import dotenv from 'dotenv' 4 | 5 | dotenv.config() 6 | 7 | const lambda = new AWS.Lambda({ 8 | region: process.env.REGION 9 | }) 10 | 11 | function update (functionName) { 12 | return new Promise((resolve, reject) => { 13 | const params = { 14 | FunctionName: functionName, 15 | Runtime: 'nodejs4.3' 16 | } 17 | lambda.updateFunctionConfiguration(params, (error, data) => { 18 | if (error) { 19 | reject(error) 20 | return 21 | } 22 | resolve() 23 | }) 24 | }) 25 | } 26 | 27 | async function run () { 28 | try { 29 | const functions = [ 30 | 'QueueCommentLambdaFunction', 31 | 'WorkerLambdaFunction' 32 | ] 33 | for (let func of functions) { 34 | console.log(`Updating function ${func} to Node 4.3`) 35 | const funcName = resources[func].PhysicalResourceId 36 | await update(funcName) 37 | } 38 | console.log('Done.') 39 | } catch (error) { 40 | console.error(error) 41 | console.error(error.stack) 42 | process.exit(1) 43 | } 44 | } 45 | 46 | run() 47 | -------------------------------------------------------------------------------- /packages/frontend/src/client.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { browserHistory } from 'react-router' 4 | import { syncHistoryWithStore } from 'react-router-redux' 5 | import makeRoutes from './routes' 6 | import Root from './root' 7 | import configureStore from './store' 8 | 9 | // Create redux store and sync with react-router-redux. We have installed the 10 | // react-router-redux reducer under the key "router" in src/routes/index.js, 11 | // so we need to provide a custom `selectLocationState` to inform 12 | // react-router-redux of its location. 13 | const initialState = window.__INITIAL_STATE__ 14 | const store = configureStore(initialState, browserHistory) 15 | const history = syncHistoryWithStore(browserHistory, store) 16 | 17 | // Now that we have the Redux store, we can create our routes. We provide 18 | // the store to the route definitions so that routes have access to it for 19 | // hooks such as `onEnter`. 20 | const routes = makeRoutes(store) 21 | 22 | // Now that redux and react-router have been configured, we can render the 23 | // React application to the DOM! 24 | render( 25 | , 26 | document.getElementById('lambda-comments') 27 | ) 28 | -------------------------------------------------------------------------------- /deploy/cloudformation/cloudformation.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | 5 | if [ "$1" = "create" ]; then 6 | ACTION=create-stack 7 | elif [ "$1" = "update" ]; then 8 | ACTION=update-stack 9 | elif [ "$1" = "delete" ]; then 10 | ACTION=delete-stack 11 | else 12 | echo "Usage: $0 [create|update]" 13 | exit 1 14 | fi 15 | 16 | TAG='lambda-comments' 17 | DIR=`cd $(dirname $0); pwd` 18 | BABEL_NODE=$DIR/../../node_modules/babel-cli/bin/babel-node.js 19 | BIN_DIR=$DIR/../../bin 20 | STACK_NAME=$($BABEL_NODE $BIN_DIR/dump-config.js CLOUDFORMATION) 21 | ORIGIN=$($BABEL_NODE $BIN_DIR/dump-config.js ORIGIN) 22 | REGION=$($BABEL_NODE $BIN_DIR/dump-config.js REGION) 23 | 24 | if [ "$ACTION" = "delete-stack" ]; then 25 | aws cloudformation delete-stack \ 26 | --region $REGION \ 27 | --stack-name $STACK_NAME 28 | exit 0 29 | fi 30 | 31 | aws cloudformation $ACTION \ 32 | --region $REGION \ 33 | --stack-name $STACK_NAME \ 34 | --template-body file://$DIR/lambda-comments.json \ 35 | --capabilities CAPABILITY_IAM \ 36 | --parameters \ 37 | ParameterKey=TagName,ParameterValue=$TAG,UsePreviousValue=false \ 38 | ParameterKey=Origin,ParameterValue=$ORIGIN,UsePreviousValue=false \ 39 | || true 40 | 41 | # $BABEL_NODE $BIN_DIR/save-cloudformation-config.js 42 | -------------------------------------------------------------------------------- /packages/utils/src/dynamoDb.js: -------------------------------------------------------------------------------- 1 | import https from 'https' 2 | import AWS from 'aws-sdk' 3 | import WError from 'verror' 4 | import { resources } from './cloudFormation' 5 | 6 | const dynamoDbTable = resources.JobStreamDynamoDBTable.PhysicalResourceId 7 | 8 | export function updateRecord (object) { 9 | const { REGION: region } = process.env 10 | // Include workaround from: https://github.com/aws/aws-sdk-js/issues/862 11 | const awsDynamoDb = new AWS.DynamoDB({ 12 | region, 13 | httpOptions: { 14 | agent: new https.Agent({ 15 | rejectUnauthorized: true, 16 | // keepAlive: true, // workaround part i. 17 | // shouldn't be used in AWS Lambda functions 18 | secureProtocol: "TLSv1_method", // workaround part ii. 19 | ciphers: "ALL" // workaround part ii. 20 | }) 21 | } 22 | }) 23 | return new Promise((resolve, reject) => { 24 | const params = { 25 | TableName: dynamoDbTable, 26 | Key: { id: { S: 'jobs' } }, 27 | AttributeUpdates: Object.keys(object).reduce((prev, key) => ({ 28 | [key]: { 29 | Action: 'PUT', 30 | Value: { 31 | S: object[key] 32 | } 33 | }, 34 | ...prev 35 | }), {}) 36 | } 37 | awsDynamoDb.updateItem(params, (err, result) => { 38 | if (err) { 39 | return reject(new WError(err, 'DynamoDB')) 40 | } 41 | resolve(result) 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /packages/utils/src/cloudFormation.js: -------------------------------------------------------------------------------- 1 | import cloudFormation from '../../../deploy/state/cloudFormation.json' 2 | 3 | const { 4 | stack: { 5 | Outputs: cloudFormationOutputs 6 | }, 7 | resources: cloudFormationResources 8 | } = cloudFormation 9 | 10 | export const outputs = cloudFormationOutputs.reduce((prev, output) => { 11 | const { OutputKey, OutputValue } = output 12 | return { 13 | [OutputKey]: OutputValue, 14 | ...prev 15 | } 16 | }, {}) 17 | 18 | export const resources = cloudFormationResources.reduce((prev, resource) => { 19 | const { 20 | LogicalResourceId, 21 | PhysicalResourceId, 22 | StackId 23 | } = resource 24 | return { 25 | [LogicalResourceId]: { PhysicalResourceId, StackId }, 26 | ...prev 27 | } 28 | }, {}) 29 | 30 | export function getApiUrl () { 31 | const { REGION: region, STAGE: stage } = process.env 32 | return ( 33 | 'https://' + 34 | resources.RestApi.PhysicalResourceId + 35 | '.execute-api.' + 36 | region + 37 | '.amazonaws.com/' + 38 | stage 39 | ) 40 | } 41 | 42 | export function getWebsiteUrl () { 43 | const { REGION: region } = process.env 44 | return ( 45 | `https://s3-${region}.amazonaws.com/` + 46 | resources.WebsiteS3.PhysicalResourceId 47 | ) 48 | } 49 | 50 | export function getClientJsUrl () { 51 | const { REGION: region } = process.env 52 | return ( 53 | `//s3-${region}.amazonaws.com/` + 54 | resources.WebsiteS3.PhysicalResourceId + 55 | '/lambda-comments.js' 56 | ) 57 | } 58 | 59 | export default cloudFormation 60 | -------------------------------------------------------------------------------- /packages/frontend/src/server.js: -------------------------------------------------------------------------------- 1 | /* eslint react/no-multi-comp: 0 */ 2 | import React, { Component, PropTypes } from 'react' 3 | import { createServer } from 'react-project/server' 4 | import { Route, RouterContext } from 'react-router' 5 | 6 | class Document extends Component { 7 | 8 | static propTypes = { 9 | content: PropTypes.string, 10 | } 11 | 12 | render () { 13 | const { content } = this.props 14 | 15 | return ( 16 | 17 | 18 | 19 | Server 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | } 27 | 28 | class App extends Component { 29 | render () { 30 | return ( 31 |
32 | No server app here. 33 |
34 | ) 35 | } 36 | } 37 | 38 | const routes = ( 39 | 40 | ) 41 | 42 | function getApp (req, res, requestCallback) { 43 | // here is your chance to do things like get an auth token and generate 44 | // your route config w/ request aware `onEnter` hooks, etc. 45 | requestCallback(null, { 46 | routes, 47 | render (routerProps, renderCallback) { 48 | // here is your chance to load up data before rendering and pass it to 49 | // your top-level components 50 | renderCallback(null, { 51 | renderDocument: (props) => , 52 | renderApp: (props) => , 53 | }) 54 | }, 55 | }) 56 | } 57 | 58 | createServer(getApp).start() 59 | -------------------------------------------------------------------------------- /packages/utils/src/s3.js: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk' 2 | import WError from 'verror' 3 | import { resources } from './cloudFormation' 4 | 5 | const websiteBucket = resources.WebsiteS3.PhysicalResourceId 6 | const privateBucket = resources.PrivateS3.PhysicalResourceId 7 | 8 | function upload ({ 9 | s3Bucket, 10 | key, 11 | data, 12 | contentType = 'application/octet-stream' 13 | }) { 14 | const { REGION: region } = process.env 15 | const awsS3 = new AWS.S3({ region }) 16 | return new Promise((resolve, reject) => { 17 | const params = { 18 | Bucket: s3Bucket 19 | , Key: key 20 | , Body: data 21 | , ContentType: contentType 22 | } 23 | awsS3.putObject(params, (err, result) => { 24 | if (err) { 25 | return reject(new WError(err, 'S3')) 26 | } 27 | resolve() 28 | }) 29 | }) 30 | } 31 | 32 | export function uploadPrivate (params) { 33 | const s3Bucket = privateBucket 34 | return upload({ ...params, s3Bucket }) 35 | } 36 | 37 | export function uploadWebsite (params) { 38 | const s3Bucket = websiteBucket 39 | return upload({ ...params, s3Bucket }) 40 | } 41 | 42 | export function download ({ s3Bucket, key }) { 43 | const { REGION: region } = process.env 44 | const awsS3 = new AWS.S3({ region }) 45 | return new Promise((resolve, reject) => { 46 | const params = { 47 | Bucket: s3Bucket 48 | , Key: key 49 | } 50 | awsS3.getObject(params, (err, result) => { 51 | if (err) { 52 | return reject(new WError(err, 'S3')) 53 | } 54 | resolve(result) 55 | }) 56 | }) 57 | } 58 | 59 | export function downloadPrivate (params) { 60 | const s3Bucket = privateBucket 61 | return download({ ...params, s3Bucket }) 62 | } 63 | 64 | export function downloadWebsite (params) { 65 | const s3Bucket = websiteBucket 66 | return download({ ...params, s3Bucket }) 67 | } 68 | -------------------------------------------------------------------------------- /bin/save-cloudformation-config.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import mkdirp from 'mkdirp' 4 | import AWS from 'aws-sdk' 5 | import dotenv from 'dotenv' 6 | 7 | dotenv.config() 8 | 9 | function describeStackResources ({ cloudFormation, stackName }) { 10 | return new Promise((resolve, reject) => { 11 | const params = { 12 | StackName: stackName 13 | } 14 | cloudFormation.describeStackResources(params, (error, data) => { 15 | if (error) { 16 | return reject(error) 17 | } 18 | const { StackResources: resources } = data 19 | resolve(resources) 20 | }) 21 | }) 22 | } 23 | 24 | function describeStack ({ cloudFormation, stackName }) { 25 | return new Promise((resolve, reject) => { 26 | const params = { 27 | StackName: stackName 28 | } 29 | cloudFormation.describeStacks(params, (error, data) => { 30 | if (error) { 31 | return reject(error) 32 | } 33 | if (!data) { 34 | return reject(new Error('describeStacks returned no data')) 35 | } 36 | const { Stacks: stacks } = data 37 | if (!stacks || stacks.length !== 1) { 38 | return reject(new Error('describeStacks unexpected number of stacks')) 39 | } 40 | const stack = stacks[0] 41 | resolve(stack) 42 | }) 43 | }) 44 | } 45 | 46 | async function run () { 47 | try { 48 | const { CLOUDFORMATION: stackName, REGION: region } = process.env 49 | const cloudFormation = new AWS.CloudFormation({ region }) 50 | const resources = await describeStackResources({ cloudFormation, stackName }) 51 | const stack = await describeStack({ cloudFormation, stackName }) 52 | const result = { stack, resources } 53 | const json = JSON.stringify(result, null, 2) 54 | const dir = path.normalize(`${__dirname}/../deploy/state`) 55 | mkdirp.sync(dir) 56 | fs.writeFileSync(`${dir}/cloudFormation.json`, json) 57 | console.log('cloudFormation.json written') 58 | } catch (error) { 59 | console.error(error, error.stack) 60 | } 61 | } 62 | 63 | run() 64 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/comment.js: -------------------------------------------------------------------------------- 1 | /* eslint prefer-template: 0 */ 2 | import React, { Component, PropTypes } from 'react' 3 | import MarkdownIt from 'markdown-it' 4 | import hljs from 'highlight.js' 5 | import moment from 'moment' 6 | import { 7 | commentContainer, 8 | commentHeader, 9 | authorNameStyle, 10 | authorLinkStyle, 11 | spacer, 12 | timeFrom, 13 | commentContentStyle, 14 | } from './comments.css' 15 | 16 | const md = new MarkdownIt({ 17 | linkify: true, 18 | highlight: (str, lang) => { 19 | if (lang && hljs.getLanguage(lang)) { 20 | try { 21 | return ( 22 | '
' +
23 |           hljs.highlight(lang, str, true).value +
24 |           '
' 25 | ) 26 | } catch (__) { 27 | // Don't fail 28 | } 29 | } 30 | return `
${md.utils.escapeHtml(str)}
` 31 | }, 32 | }) 33 | 34 | export default class Comment extends Component { 35 | 36 | static propTypes = { 37 | comment: PropTypes.object.isRequired, 38 | } 39 | 40 | render () { 41 | const { comment } = this.props 42 | const { id, authorName, authorUrl, date, commentContent, pending } = comment 43 | const html = md.render(commentContent || 'No content.') 44 | let authorElement = authorName || 'Anonymous' 45 | if (authorUrl) { 46 | authorElement = {authorElement} 47 | } 48 | let authorLink = null 49 | if (authorUrl) { 50 | authorLink = {authorUrl} 51 | } 52 | return ( 53 |
54 |
55 | 56 | {authorElement} 57 | 58 | 59 | {authorLink && 60 | 61 | {authorLink} 62 | 63 | } 64 | {authorLink && } 65 | 66 | {moment(date).fromNow()} 67 | {pending && ' (Submitted)'} 68 | 69 |
70 |
74 |
75 | ) 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/comments.js: -------------------------------------------------------------------------------- 1 | /* eslint no-throw-literal: 0 */ 2 | import React, { Component, PropTypes } from 'react' 3 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group' 4 | import { autobind } from 'core-decorators' 5 | import Comment from './comment' 6 | import PostCommentForm from './postCommentForm' 7 | import { header, commentsContainer } from './comments.css' 8 | 9 | export default class Comments extends Component { 10 | 11 | static propTypes = { 12 | location: PropTypes.object.isRequired, 13 | comments: PropTypes.array.isRequired, 14 | postComment: PropTypes.func.isRequired, 15 | resetCommentForm: PropTypes.func.isRequired, 16 | } 17 | 18 | @autobind 19 | async submit (data) { 20 | const { 21 | location: { 22 | pathname, 23 | }, 24 | postComment, 25 | resetCommentForm, 26 | } = this.props 27 | try { 28 | const result = await postComment({ 29 | url: `${window.document.location.origin}${pathname}`, 30 | pathname, 31 | ...data, 32 | }) 33 | resetCommentForm({ pathname, clearContent: true }) 34 | return result 35 | } catch (error) { 36 | if ( 37 | error.name === 'ValidationError' || 38 | error.name === 'SpamError' || 39 | error.name === 'VerificationError' 40 | ) { 41 | throw error.data 42 | } 43 | throw { _error: 'An error occurred while posting the comment.' } 44 | } 45 | } 46 | 47 | render () { 48 | const { 49 | location: { 50 | pathname, 51 | }, 52 | comments, 53 | resetCommentForm, 54 | } = this.props 55 | return ( 56 |
57 |
58 | {comments.length} {comments.length !== 1 ? 'comments' : 'comment'} 59 |
60 |
61 | 66 | {comments.map(comment => { 67 | const { id } = comment 68 | return ( 69 | 70 | ) 71 | })} 72 | 73 |
74 | 79 |
80 | ) 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /packages/frontend/src/reducers/comments.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "unused" }] */ 2 | import { keyBy } from 'lodash' 3 | import { 4 | GET_COMMENTS, 5 | GET_COMMENTS_COMPLETE, 6 | GET_COMMENTS_ERROR, 7 | POST_COMMENT_COMPLETE, 8 | } from '../actions/comments' 9 | 10 | const initialState = { 11 | loading: false, 12 | comments: [], 13 | pendingComments: {}, 14 | error: null, 15 | } 16 | 17 | export default function commentsReducer (state = initialState, action) { 18 | const { comments, error, updateOnly } = action 19 | switch (action.type) { 20 | case GET_COMMENTS: 21 | return { 22 | ...state, 23 | loading: !updateOnly, 24 | } 25 | case GET_COMMENTS_COMPLETE: 26 | { 27 | if (!updateOnly) { 28 | return { 29 | ...state, 30 | loading: false, 31 | comments, 32 | } 33 | } 34 | // updateOnly 35 | const commentIds = keyBy(comments, 'id') 36 | const { 37 | comments: oldComments, 38 | pendingComments: oldPendingComments, 39 | } = state 40 | const oldCommentIds = keyBy(oldComments, 'id') 41 | const newComments = [...oldComments] 42 | const newPendingComments = { ...oldPendingComments } 43 | let modified = false 44 | Object.keys(newPendingComments).forEach(id => { 45 | if (!oldCommentIds[id] && commentIds[id]) { 46 | const newComment = commentIds[id] 47 | newComments.push(newComment) 48 | delete newPendingComments[id] 49 | modified = true 50 | } 51 | }) 52 | if (modified) { 53 | return { 54 | ...state, 55 | comments: newComments, 56 | pendingComments: newPendingComments, 57 | } 58 | } 59 | return state 60 | } 61 | case GET_COMMENTS_ERROR: 62 | return { 63 | ...state, 64 | loading: false, 65 | error: error.message, 66 | } 67 | case POST_COMMENT_COMPLETE: 68 | { 69 | const { pendingComments } = state 70 | const { 71 | responseData: { 72 | id, 73 | }, 74 | payload: { 75 | url: unused, 76 | ...fields, 77 | }, 78 | } = action 79 | return { 80 | ...state, 81 | pendingComments: { 82 | ...pendingComments, 83 | [id]: fields, 84 | }, 85 | } 86 | } 87 | default: 88 | return state 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /packages/frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import { ClientConfig, ServerConfig } from 'react-project/webpack' 3 | import postcssNested from 'postcss-nested' 4 | import { DefinePlugin } from 'webpack' 5 | import 'babel-register' 6 | import { apiKey } from '../../../deploy/state/apiKey.json' 7 | import { getApiUrl, getWebsiteUrl } from 8 | 'lambda-comments-utils/src/cloudFormation' 9 | 10 | dotenv.config() 11 | dotenv.config({ path: '../../.env' }) 12 | 13 | const apiUrl = getApiUrl() 14 | const websiteUrl = getWebsiteUrl() 15 | 16 | function modify (webpackConfig) { 17 | webpackConfig.postcss = () => { 18 | return [ postcssNested ] 19 | } 20 | if (webpackConfig.entry.app) { 21 | webpackConfig.entry.app = [ 22 | 'babel-polyfill', 23 | webpackConfig.entry.app 24 | ] 25 | } 26 | } 27 | 28 | function modifyClient (webpackConfig) { 29 | let { 30 | entry, 31 | devtool, 32 | output: { 33 | filename 34 | }, 35 | plugins, 36 | module: { 37 | loaders 38 | } 39 | } = webpackConfig 40 | modify(webpackConfig) 41 | if (process.env.NODE_ENV === 'production') { 42 | delete webpackConfig.devtool 43 | console.log('Devtool cleared') 44 | } else { 45 | console.log('Devtool before', devtool) 46 | devtool = devtool.replace( 47 | 'cheap-module-eval-source-map', 48 | 'cheap-source-map' 49 | ) 50 | webpackConfig.devtool = devtool 51 | console.log('Devtool after', devtool) 52 | } 53 | console.log('Entry before', entry) 54 | entry['lambda-comments'] = entry.app 55 | delete entry.app 56 | delete entry._vendor 57 | console.log('Entry after', entry) 58 | console.log('Output filename before', filename) 59 | filename = filename.replace('[chunkHash].js', '[name].js') 60 | webpackConfig.output.filename = filename 61 | console.log('Output filename after', filename) 62 | console.log('Plugins before', plugins) 63 | plugins = plugins.filter(plugin => { 64 | const name = plugin.constructor.name 65 | return name !== 'ExtractTextPlugin' && name !== 'CommonsChunkPlugin' 66 | }) 67 | 68 | plugins.push(new DefinePlugin({ 69 | '__CONFIG__': JSON.stringify({ 70 | apiUrl, 71 | websiteUrl, 72 | apiKey 73 | }) 74 | })) 75 | webpackConfig.plugins = plugins 76 | console.log('Plugins after', plugins) 77 | console.log('Loaders before', loaders) 78 | loaders.forEach(loader => { 79 | if (loader.test.source === '\\.css$') { 80 | loader.loader = 'style-loader!css-loader?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]!postcss-loader' 81 | } 82 | }) 83 | webpackConfig.module.loaders = loaders 84 | console.log('Loaders after', loaders) 85 | // Fix for: https://github.com/isagalaev/highlight.js/issues/895 86 | webpackConfig.module.noParse = [/autoit.js/] 87 | } 88 | 89 | modifyClient(ClientConfig) 90 | modify(ServerConfig) 91 | 92 | /* 93 | // Uncomment when adapting webpack config 94 | console.log('\n\n\n') 95 | console.log(JSON.stringify(ClientConfig, null, 2)) 96 | process.exit(1) 97 | */ 98 | 99 | export { ClientConfig, ServerConfig } 100 | -------------------------------------------------------------------------------- /deploy/apex/webpack.config.es6.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import dotenv from 'dotenv' 3 | import StringReplacePlugin from 'string-replace-webpack-plugin' 4 | import { resources } from 'lambda-comments-utils/src/cloudFormation' 5 | import { DefinePlugin, NormalModuleReplacementPlugin } from 'webpack' 6 | 7 | dotenv.config() 8 | 9 | const nodeModulesDir = path.normalize(`${__dirname}/../../node_modules`) 10 | 11 | const lambdas = [ 12 | 'QueueComment', 13 | 'Worker' 14 | ] 15 | 16 | const lambdaDirNames = lambdas.reduce((lookupTable, logicalResourceId) => { 17 | const resource = resources[`${logicalResourceId}LambdaFunction`] 18 | const lambdaDirName = resource.PhysicalResourceId.replace(/^[^-]+-/, '') 19 | return { 20 | [logicalResourceId]: lambdaDirName, 21 | ...lookupTable 22 | } 23 | }, {}) 24 | 25 | const defines = { 26 | 'global.GENTLY': false, 27 | 'process.env.BLOG': `'${process.env.BLOG}'`, 28 | 'process.env.REGION': `'${process.env.REGION}'`, 29 | 'process.env.STAGE': `'${process.env.STAGE}'`, 30 | } 31 | 32 | if (process.env.AKISMET) { 33 | defines['process.env.AKISMET'] = `'${process.env.AKISMET}'` 34 | } 35 | 36 | if (process.env.SLACK) { 37 | defines['process.env.SLACK'] = `'${process.env.SLACK}'` 38 | } 39 | 40 | export default { 41 | entry: { 42 | [lambdaDirNames['QueueComment']]: [ 43 | 'babel-polyfill', 44 | './packages/lambda/src/queueComment/index.js' 45 | ], 46 | [lambdaDirNames['Worker']]: [ 47 | 'babel-polyfill', 48 | './packages/lambda/src/worker/index.js' 49 | ] 50 | }, 51 | output: { 52 | path: "./build/apex/functions", 53 | library: "[name]", 54 | libraryTarget: "commonjs2", 55 | filename: "[name]/index.js" 56 | }, 57 | target: "node", 58 | externals: { 'aws-sdk': 'commonjs aws-sdk' }, 59 | module: { 60 | loaders: [ 61 | { 62 | test: /\.js$/, 63 | exclude: /node_modules/, 64 | loader: 'babel', 65 | query: { 66 | presets: [ 'es2015', 'stage-0' ] 67 | } 68 | }, 69 | { 70 | test: /\.json$/, 71 | loader: 'json' 72 | }, 73 | { 74 | test: /validate.js$/, 75 | include: /node_modules\/json-schema/, 76 | loader: StringReplacePlugin.replace({ // from the 'string-replace-webpack-plugin' 77 | replacements: [{ 78 | pattern: /\(\{define:typeof define!="undefined"\?define:function\(deps, factory\)\{module\.exports = factory\(\);\}\}\)\./ig, 79 | replacement: function(match, p1, offset, string) { 80 | return false; 81 | } 82 | }] 83 | }) 84 | } 85 | ] 86 | }, 87 | plugins: [ 88 | // https://github.com/andris9/encoding/issues/16 89 | new NormalModuleReplacementPlugin(/\/iconv-loader$/, 'node-noop'), 90 | new StringReplacePlugin(), 91 | // https://github.com/visionmedia/superagent/wiki/Superagent-for-Webpack 92 | new DefinePlugin(defines), 93 | ], 94 | // From: https://github.com/webpack/webpack/issues/784 95 | // for modules 96 | resolve: { 97 | fallback: [ nodeModulesDir ] 98 | }, 99 | // same issue, for loaders like babel 100 | resolveLoader: { 101 | fallback: [ nodeModulesDir ] 102 | }, 103 | /* node: { 104 | // https://github.com/visionmedia/superagent/wiki/Superagent-for-Webpack 105 | __dirname: true, 106 | } */ 107 | } 108 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/commentsContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { connect } from 'react-redux' 3 | import { createSelector } from 'reselect' 4 | import { sortBy, keyBy } from 'lodash' 5 | import * as commentsActions from '../actions/comments' 6 | import Comments from './comments' 7 | 8 | const getCommentsSelector = state => state.comments.comments 9 | const getPendingCommentsSelector = state => state.comments.pendingComments 10 | const getMergedCommentsSelector = createSelector( 11 | [getCommentsSelector, getPendingCommentsSelector], 12 | (comments, pendingComments) => { 13 | const commentIds = keyBy(comments, 'id') 14 | const mergedComments = [...comments] 15 | Object.keys(pendingComments).forEach(id => { 16 | if (!commentIds[id]) { 17 | mergedComments.push({ 18 | id, 19 | ...pendingComments[id], 20 | pending: true, 21 | }) 22 | } 23 | }) 24 | const sortedComments = sortBy(mergedComments, 'date') 25 | return sortedComments 26 | } 27 | ) 28 | 29 | @connect( 30 | state => { 31 | const { loading, error } = state.comments 32 | const comments = getMergedCommentsSelector(state) 33 | return { 34 | loading, 35 | error, 36 | comments, 37 | } 38 | }, 39 | { 40 | getComments: commentsActions.getComments, 41 | postComment: commentsActions.postComment, 42 | resetCommentForm: commentsActions.resetCommentForm, 43 | } 44 | ) 45 | export default class CommentsContainer extends Component { 46 | 47 | static propTypes = { 48 | location: PropTypes.object.isRequired, 49 | params: PropTypes.object.isRequired, 50 | getComments: PropTypes.func.isRequired, 51 | postComment: PropTypes.func.isRequired, 52 | resetCommentForm: PropTypes.func.isRequired, 53 | comments: PropTypes.array.isRequired, 54 | loading: PropTypes.bool.isRequired, 55 | error: PropTypes.string, 56 | } 57 | 58 | componentDidMount () { 59 | const { getComments, location: { pathname } } = this.props 60 | getComments({ url: pathname }) 61 | } 62 | 63 | componentDidUpdate (prevProps) { 64 | const { loading: oldLoading } = prevProps 65 | const { location: { hash }, loading: newLoading } = this.props 66 | if (!newLoading && oldLoading) { 67 | // Loading is finished 68 | const match = hash.match(/^#(comment-.*)$/) 69 | if (match && match[1]) { 70 | const id = match[1] 71 | const element = document.getElementById(id) 72 | if (element) { 73 | const rect = element.getBoundingClientRect() 74 | const top = rect.top + document.body.scrollTop 75 | document.body.scrollTop = top - 30 76 | } 77 | } 78 | } 79 | } 80 | 81 | render () { 82 | const { 83 | params, 84 | location, 85 | comments, 86 | loading, 87 | error, 88 | postComment, 89 | resetCommentForm, 90 | } = this.props 91 | if (loading) { 92 | return ( 93 |
94 | Loading comments... 95 |
96 | ) 97 | } 98 | if (error) { 99 | return ( 100 |
101 | Error loading comments. 102 |
103 | ) 104 | } 105 | if (!comments) { 106 | return null 107 | } 108 | return ( 109 | 116 | ) 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-comments", 3 | "version": "0.0.2", 4 | "description": "Blog commenting system built with AWS Lambda", 5 | "repository": "jimpick/lambda-comments", 6 | "main": "config.js", 7 | "scripts": { 8 | "create-cloudformation": "deploy/cloudformation/cloudformation.sh create", 9 | "update-cloudformation": "deploy/cloudformation/cloudformation.sh update", 10 | "delete-cloudformation": "deploy/cloudformation/cloudformation.sh delete", 11 | "save-cloudformation": "babel-node bin/save-cloudformation-config.js", 12 | "flip-lambdas-to-4.3": "node bin/run-babel.js ./flip-lambdas-to-4.3", 13 | "gen-api-key": "babel-node bin/gen-api-key.js", 14 | "setup-apex": "node bin/run-babel.js ./setup-apex", 15 | "compile-lambda": "rimraf build/apex/functions && webpack --config deploy/apex/webpack.config.js --display-exclude --display-modules --display-chunks --display-error-details --display-origins --display-cached --display-cached-assets --bail", 16 | "deploy-lambda": "export AWS_REGION=$(babel-node bin/dump-config.js REGION) && cd build/apex && apex deploy", 17 | "deploy-backend": "npm run compile-lambda && npm run deploy-lambda", 18 | "build-frontend": "cd packages/frontend && npm run build-prod", 19 | "upload-script": "node bin/run-babel.js ./upload-script", 20 | "deploy-frontend": "npm run build-frontend && npm run upload-script", 21 | "deploy": "npm run deploy-backend && npm run deploy-frontend && npm run get-client-js-url", 22 | "start": "cd packages/frontend && npm start", 23 | "test-local": "mocha --compilers js:./babel-register -g local", 24 | "test-remote": "mocha --compilers js:./babel-register -g remote", 25 | "test": "mocha --compilers js:./babel-register", 26 | "post-url": "babel-node bin/post-url.js http://jimpick.com/", 27 | "logs": "export AWS_REGION=$(babel-node bin/dump-config.js REGION) && cd build/apex && apex logs -f", 28 | "get-client-js-url": "node bin/run-babel.js ./get-client-js-url", 29 | "clean": "rimraf build && rimraf deploy/state && lerna run clean", 30 | "dist-clean": "lerna run dist-clean && rimraf node_modules", 31 | "install-all": "npm install && lerna bootstrap && linklocal" 32 | }, 33 | "author": "Jim Pick ", 34 | "bugs": { 35 | "url": "https://github.com/jimpick/lambda-comments/issues" 36 | }, 37 | "homepage": "https://github.com/jimpick/lambda-comments#readme", 38 | "license": "ISC", 39 | "devDependencies": { 40 | "babel-cli": "^6.5.1", 41 | "babel-core": "^6.8.0", 42 | "babel-loader": "^6.2.3", 43 | "babel-polyfill": "^6.5.0", 44 | "babel-preset-es2015": "^6.6.0", 45 | "babel-preset-stage-0": "^6.5.0", 46 | "babel-register": "^6.5.0", 47 | "chai": "^3.5.0", 48 | "json-loader": "^0.5.4", 49 | "lerna": "^2.0.0-beta.10", 50 | "linklocal": "^2.6.0", 51 | "mkdirp": "^0.5.1", 52 | "mocha": "^2.4.5", 53 | "rimraf": "^2.5.2", 54 | "string-replace-webpack-plugin": "0.0.3", 55 | "supertest": "^1.2.0", 56 | "webpack": "^1.12.14" 57 | }, 58 | "dependencies": { 59 | "lambda-comments-lambda": "file:./packages/lambda", 60 | "lambda-comments-utils": "file:./packages/utils", 61 | "akismet-api": "^2.1.0", 62 | "aws-sdk": "^2.3.5", 63 | "dotenv": "^2.0.0", 64 | "encoding": "^0.1.12", 65 | "jwa": "^1.1.3", 66 | "lerna": "2.0.0-beta.9", 67 | "moment": "^2.11.2", 68 | "node-fetch": "^1.3.3", 69 | "node-noop": "^1.0.0", 70 | "node-slack": "0.0.7", 71 | "redux": "^3.4.0", 72 | "redux-logger": "^2.6.1", 73 | "slugid": "^1.1.0", 74 | "superagent": "^1.8.0-beta.2", 75 | "superagent-promise-plugin": "^3.2.0", 76 | "validator": "^5.2.0", 77 | "verror": "^1.6.1" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lambda-comments-frontend", 3 | "version": "0.0.3", 4 | "description": "React.js front end UI for lambda-comments", 5 | "react-project": { 6 | "server": "src/server.js", 7 | "client": "src/client.js", 8 | "webpack": "webpack.config.js" 9 | }, 10 | "scripts": { 11 | "start": "if-env NODE_ENV=production && npm run react-project:start:prod || npm run react-project:start:dev", 12 | "test": "karma start", 13 | "react-project:start:dev": "eslint src && node ./react-project.js start", 14 | "react-project:start:prod": "rm -rf .build && node ./react-project.js build && cd .build && serve -p 9876", 15 | "build-prod": "npm run clean && NODE_ENV=production node ./react-project.js build", 16 | "clean": "rimraf .build", 17 | "dist-clean": "npm run clean && rimraf node_modules" 18 | }, 19 | "repository": "jimpick/lambda-comments", 20 | "author": "Jim Pick ", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/jimpick/lambda-comments/issues" 24 | }, 25 | "homepage": "https://github.com/jimpick/lambda-comments#readme", 26 | "dependencies": { 27 | "babel-cli": "6.8.0", 28 | "babel-core": "6.8.0", 29 | "babel-eslint": "6.0.4", 30 | "babel-loader": "6.2.4", 31 | "babel-plugin-transform-decorators-legacy": "1.3.4", 32 | "babel-polyfill": "6.8.0", 33 | "babel-preset-es2015": "6.6.0", 34 | "babel-preset-react": "6.5.0", 35 | "babel-preset-react-hmre": "1.1.1", 36 | "babel-preset-stage-1": "6.5.0", 37 | "babel-register": "6.8.0", 38 | "body-parser": "1.15.0", 39 | "buffer": "4.6.0", 40 | "bundle-loader": "0.5.4", 41 | "compression": "1.6.1", 42 | "core-decorators": "0.12.0", 43 | "css-loader": "0.23.1", 44 | "dotenv": "2.0.0", 45 | "eslint": "2.7.0", 46 | "eslint-config-airbnb": "6.2.0", 47 | "eslint-config-rackt": "1.1.1", 48 | "eslint-plugin-react": "4.3.0", 49 | "expect": "1.14.0", 50 | "express": "4.13.4", 51 | "extract-text-webpack-plugin": "1.0.1", 52 | "file-loader": "0.8.5", 53 | "helmet": "1.1.0", 54 | "highlight.js": "9.3.0", 55 | "hpp": "0.2.0", 56 | "if-env": "1.0.0", 57 | "isomorphic-fetch": "2.2.1", 58 | "jwa": "1.1.3", 59 | "karma": "0.13.21", 60 | "karma-chrome-launcher": "0.2.2", 61 | "karma-mocha": "0.2.2", 62 | "karma-mocha-reporter": "1.2.0", 63 | "karma-sourcemap-loader": "0.3.7", 64 | "karma-webpack": "1.7.0", 65 | "lambda-comments-utils": "0.0.3", 66 | "lodash": "4.11.1", 67 | "markdown-it": "6.0.1", 68 | "mocha": "2.4.5", 69 | "morgan": "1.7.0", 70 | "node-fetch": "1.3.3", 71 | "null-loader": "0.1.1", 72 | "octicons": "3.5.0", 73 | "postcss-loader": "0.8.1", 74 | "postcss-nested": "1.0.0", 75 | "react": "0.14.7", 76 | "react-addons-css-transition-group": "0.14.7", 77 | "react-dom": "0.14.7", 78 | "react-measure": "0.3.5", 79 | "react-motion": "0.4.2", 80 | "react-project": "0.0.30", 81 | "react-router": "2.0.0", 82 | "react-router-redux": "4.0.2", 83 | "react-spinner": "0.2.6", 84 | "react-textarea-autosize": "4.0.0", 85 | "react-title-component": "1.0.1", 86 | "redux": "3.4.0", 87 | "redux-devtools": "3.2.0", 88 | "redux-form": "5.0.1", 89 | "redux-logger": "2.6.1", 90 | "redux-thunk": "2.0.1", 91 | "reselect": "2.4.0", 92 | "rimraf": "2.5.2", 93 | "serve": "1.4.0", 94 | "source-map-support": "0.4.0", 95 | "store": "1.3.20", 96 | "style-loader": "0.13.0", 97 | "url-loader": "0.5.7", 98 | "validator": "5.2.0", 99 | "webpack": "1.13.0", 100 | "webpack-dev-server": "1.14.1" 101 | }, 102 | "devDependencies": { 103 | "postcss-nested": "1.0.0", 104 | "rimraf": "2.5.2", 105 | "serve": "1.4.0" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/comments.css: -------------------------------------------------------------------------------- 1 | .header { 2 | border-bottom: 1px solid lightgray; 3 | padding-bottom: 0.5em; 4 | } 5 | 6 | .commentsContainer { 7 | } 8 | 9 | .commentContainer { 10 | margin-top: 1.5em; 11 | margin-bottom: 1.5em; 12 | } 13 | 14 | .commentHeader { 15 | font-size: 13px; 16 | } 17 | 18 | .authorNameStyle { 19 | font-weight: 700; 20 | } 21 | 22 | .authorNameStyle, .authorLinkStyle { 23 | display: inline-block; 24 | max-width: 33%; 25 | overflow-x: hidden; 26 | text-overflow: ellipsis; 27 | white-space: nowrap; 28 | 29 | a, a:focus, a:hover { 30 | text-decoration: none; 31 | } 32 | } 33 | 34 | .spacer { 35 | color: gray !important; 36 | font-weight: normal; 37 | text-shadow: none !important; 38 | padding: 0 0.3em; 39 | vertical-align: top; 40 | } 41 | 42 | .timeFrom { 43 | color: gray !important; 44 | font-weight: normal; 45 | text-shadow: none !important; 46 | vertical-align: top; 47 | 48 | span { 49 | vertical-align: top; 50 | } 51 | } 52 | 53 | .commentContentStyle { 54 | border-bottom: 1px solid lightgray; 55 | padding: 0.5em; 56 | margin: 0.3em 0; 57 | } 58 | 59 | .postCommentForm { 60 | textarea, input { 61 | display: block; 62 | width: 100%; 63 | border: 1px solid lightgray; 64 | margin-top: 0.4em; 65 | outline: 0; 66 | padding: 3px; 67 | 68 | &:focus { 69 | border-color: black; 70 | } 71 | } 72 | textarea { 73 | resize: none; 74 | min-height: 5em; 75 | } 76 | } 77 | 78 | .errorMessage { 79 | color: red; 80 | } 81 | 82 | .hasError { 83 | border-color: red !important; 84 | } 85 | 86 | .postCommentFormHeader { 87 | margin: 1em 0; 88 | font-size: 13px; 89 | } 90 | 91 | .markdownNote { 92 | a, a:focus, a:hover { 93 | font-size: 10px; 94 | color: gray; 95 | text-decoration: none; 96 | float: right; 97 | } 98 | } 99 | 100 | .previewWrapper { 101 | overflow-y: hidden; 102 | } 103 | 104 | .preview { 105 | border: 1px solid lightgray; 106 | padding: 0 1em; 107 | background: #f8f8f8; 108 | margin-bottom: 1em; 109 | 110 | .commentContentStyle { 111 | border-bottom: none; 112 | padding-bottom: 0; 113 | } 114 | } 115 | 116 | .btn { 117 | // Styling "borrowed" from bootstrap 118 | margin: 0; 119 | font: inherit; 120 | overflow: visible; 121 | text-transform: none; 122 | -webkit-appearance: button; 123 | font-family: inherit; 124 | background-image: none; 125 | border: 1px solid transparent; 126 | border-radius: 4px; 127 | touch-action: manipulation; 128 | display: inline-block; 129 | padding: 6px 12px; 130 | margin-bottom: 0; 131 | font-size: 14px; 132 | font-weight: 400; 133 | line-height: 1.42857143; 134 | text-align: center; 135 | white-space: nowrap; 136 | vertical-align: middle; 137 | 138 | // btn-default 139 | color: #333; 140 | background-color: #fff; 141 | border-color: #ccc; 142 | 143 | &:disabled { 144 | box-shadow: none; 145 | opacity: .65; 146 | cursor: not-allowed; 147 | } 148 | } 149 | 150 | .spinnerButton { 151 | composes: btn; 152 | margin-top: 1em; 153 | 154 | .buttonContent { 155 | display: flex; 156 | align-items: center; 157 | 158 | .buttonText { 159 | height: 32px; 160 | padding: 0 10px; 161 | display: flex; 162 | align-items: center; 163 | } 164 | 165 | .spinnerWrapper { 166 | width: 32px; 167 | height: 32px; 168 | overflow: hidden; 169 | display: flex; 170 | } 171 | } 172 | } 173 | 174 | :global { 175 | 176 | .react-spinner_bar { 177 | background-color: black !important; 178 | } 179 | 180 | .comments-enter { 181 | opacity: 0.01; 182 | } 183 | 184 | .comments-enter.comments-enter-active { 185 | opacity: 1; 186 | transition: opacity 500ms ease-in; 187 | } 188 | 189 | .comments-leave { 190 | opacity: 1; 191 | } 192 | 193 | .comments-leave.comments-leave-active { 194 | opacity: 0.01; 195 | transition: opacity 300ms ease-in; 196 | } 197 | } 198 | 199 | .footer { 200 | } 201 | 202 | .marketing { 203 | float: right; 204 | margin-top: 1em; 205 | margin-left: 2em; 206 | font-size: 10px; 207 | 208 | span, img { 209 | vertical-align: middle; 210 | } 211 | 212 | a, a:focus, a:hover { 213 | color: gray; 214 | text-decoration: none; 215 | 216 | img { 217 | height: 1em; 218 | display: inline; 219 | width: 1em; 220 | margin-left: 0.3em; 221 | margin-top: 0.1em; 222 | } 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /packages/frontend/static/fetch.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * https://github.com/github/fetch/blob/master/LICENSE 3 | */ 4 | !function(t){"use strict";function e(t){if("string"!=typeof t&&(t=String(t)),/[^a-z0-9\-#$%&'*+.\^_`|~]/i.test(t))throw new TypeError("Invalid character in header field name");return t.toLowerCase()}function r(t){return"string"!=typeof t&&(t=String(t)),t}function o(t){this.map={},t instanceof o?t.forEach(function(t,e){this.append(e,t)},this):t&&Object.getOwnPropertyNames(t).forEach(function(e){this.append(e,t[e])},this)}function n(t){return t.bodyUsed?Promise.reject(new TypeError("Already read")):void(t.bodyUsed=!0)}function s(t){return new Promise(function(e,r){t.onload=function(){e(t.result)},t.onerror=function(){r(t.error)}})}function i(t){var e=new FileReader;return e.readAsArrayBuffer(t),s(e)}function a(t){var e=new FileReader;return e.readAsText(t),s(e)}function h(){return this.bodyUsed=!1,this._initBody=function(t){if(this._bodyInit=t,"string"==typeof t)this._bodyText=t;else if(c.blob&&Blob.prototype.isPrototypeOf(t))this._bodyBlob=t;else if(c.formData&&FormData.prototype.isPrototypeOf(t))this._bodyFormData=t;else if(t){if(!c.arrayBuffer||!ArrayBuffer.prototype.isPrototypeOf(t))throw new Error("unsupported BodyInit type")}else this._bodyText="";this.headers.get("content-type")||("string"==typeof t?this.headers.set("content-type","text/plain;charset=UTF-8"):this._bodyBlob&&this._bodyBlob.type&&this.headers.set("content-type",this._bodyBlob.type))},c.blob?(this.blob=function(){var t=n(this);if(t)return t;if(this._bodyBlob)return Promise.resolve(this._bodyBlob);if(this._bodyFormData)throw new Error("could not read FormData body as blob");return Promise.resolve(new Blob([this._bodyText]))},this.arrayBuffer=function(){return this.blob().then(i)},this.text=function(){var t=n(this);if(t)return t;if(this._bodyBlob)return a(this._bodyBlob);if(this._bodyFormData)throw new Error("could not read FormData body as text");return Promise.resolve(this._bodyText)}):this.text=function(){var t=n(this);return t?t:Promise.resolve(this._bodyText)},c.formData&&(this.formData=function(){return this.text().then(f)}),this.json=function(){return this.text().then(JSON.parse)},this}function u(t){var e=t.toUpperCase();return y.indexOf(e)>-1?e:t}function d(t,e){e=e||{};var r=e.body;if(d.prototype.isPrototypeOf(t)){if(t.bodyUsed)throw new TypeError("Already read");this.url=t.url,this.credentials=t.credentials,e.headers||(this.headers=new o(t.headers)),this.method=t.method,this.mode=t.mode,r||(r=t._bodyInit,t.bodyUsed=!0)}else this.url=t;if(this.credentials=e.credentials||this.credentials||"omit",(e.headers||!this.headers)&&(this.headers=new o(e.headers)),this.method=u(e.method||this.method||"GET"),this.mode=e.mode||this.mode||null,this.referrer=null,("GET"===this.method||"HEAD"===this.method)&&r)throw new TypeError("Body not allowed for GET or HEAD requests");this._initBody(r)}function f(t){var e=new FormData;return t.trim().split("&").forEach(function(t){if(t){var r=t.split("="),o=r.shift().replace(/\+/g," "),n=r.join("=").replace(/\+/g," ");e.append(decodeURIComponent(o),decodeURIComponent(n))}}),e}function p(t){var e=new o,r=t.getAllResponseHeaders().trim().split("\n");return r.forEach(function(t){var r=t.trim().split(":"),o=r.shift().trim(),n=r.join(":").trim();e.append(o,n)}),e}function l(t,e){e||(e={}),this.type="default",this.status=e.status,this.ok=this.status>=200&&this.status<300,this.statusText=e.statusText,this.headers=e.headers instanceof o?e.headers:new o(e.headers),this.url=e.url||"",this._initBody(t)}if(!t.fetch){o.prototype.append=function(t,o){t=e(t),o=r(o);var n=this.map[t];n||(n=[],this.map[t]=n),n.push(o)},o.prototype["delete"]=function(t){delete this.map[e(t)]},o.prototype.get=function(t){var r=this.map[e(t)];return r?r[0]:null},o.prototype.getAll=function(t){return this.map[e(t)]||[]},o.prototype.has=function(t){return this.map.hasOwnProperty(e(t))},o.prototype.set=function(t,o){this.map[e(t)]=[r(o)]},o.prototype.forEach=function(t,e){Object.getOwnPropertyNames(this.map).forEach(function(r){this.map[r].forEach(function(o){t.call(e,o,r,this)},this)},this)};var c={blob:"FileReader"in t&&"Blob"in t&&function(){try{return new Blob,!0}catch(t){return!1}}(),formData:"FormData"in t,arrayBuffer:"ArrayBuffer"in t},y=["DELETE","GET","HEAD","OPTIONS","POST","PUT"];d.prototype.clone=function(){return new d(this)},h.call(d.prototype),h.call(l.prototype),l.prototype.clone=function(){return new l(this._bodyInit,{status:this.status,statusText:this.statusText,headers:new o(this.headers),url:this.url})},l.error=function(){var t=new l(null,{status:0,statusText:""});return t.type="error",t};var b=[301,302,303,307,308];l.redirect=function(t,e){if(-1===b.indexOf(e))throw new RangeError("Invalid status code");return new l(null,{status:e,headers:{location:t}})},t.Headers=o,t.Request=d,t.Response=l,t.fetch=function(t,e){return new Promise(function(r,o){function n(){return"responseURL"in i?i.responseURL:/^X-Request-URL:/m.test(i.getAllResponseHeaders())?i.getResponseHeader("X-Request-URL"):void 0}var s;s=d.prototype.isPrototypeOf(t)&&!e?t:new d(t,e);var i=new XMLHttpRequest;i.onload=function(){var t={status:i.status,statusText:i.statusText,headers:p(i),url:n()},e="response"in i?i.response:i.responseText;r(new l(e,t))},i.onerror=function(){o(new TypeError("Network request failed"))},i.open(s.method,s.url,!0),"include"===s.credentials&&(i.withCredentials=!0),"responseType"in i&&c.blob&&(i.responseType="blob"),s.headers.forEach(function(t,e){i.setRequestHeader(e,t)}),i.send("undefined"==typeof s._bodyInit?null:s._bodyInit)})},t.fetch.polyfill=!0}}("undefined"!=typeof self?self:this); 5 | -------------------------------------------------------------------------------- /packages/frontend/src/actions/comments.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch' 2 | import { initialize as initializeReduxForm } from 'redux-form' 3 | import jwa from 'jwa' 4 | import store from 'store' 5 | import { Buffer } from 'buffer' 6 | 7 | const hmac = jwa('HS256') 8 | 9 | export const GET_COMMENTS = 'GET_COMMENTS' 10 | export const GET_COMMENTS_COMPLETE = 'GET_COMMENTS_COMPLETE' 11 | export const GET_COMMENTS_ERROR = 'GET_COMMENTS_ERROR' 12 | 13 | export const POST_COMMENT = 'POST_COMMENT' 14 | export const POST_COMMENT_COMPLETE = 'POST_COMMENT_COMPLETE' 15 | export const POST_COMMENT_ERROR = 'POST_COMMENT_ERROR' 16 | 17 | export const FORM_NAME = 'postCommment' 18 | export const FORM_FIELDS = [ 19 | 'commentContent', 20 | 'authorName', 21 | 'authorEmail', 22 | 'authorUrl', 23 | ] 24 | 25 | const websiteUrl = __CONFIG__.websiteUrl 26 | const apiUrl = __CONFIG__.apiUrl 27 | const apiKey = __CONFIG__.apiKey 28 | 29 | class ValidationError extends Error { 30 | constructor (data) { 31 | super() 32 | this.name = 'ValidationError' 33 | this.data = data 34 | this.stack = (new Error()).stack 35 | } 36 | } 37 | 38 | class SpamError extends Error { 39 | constructor (data) { 40 | super() 41 | this.name = 'SpamError' 42 | this.data = data 43 | this.stack = (new Error()).stack 44 | } 45 | } 46 | 47 | class VerificationError extends Error { 48 | constructor (data) { 49 | super() 50 | this.name = 'VerificationError' 51 | this.data = data 52 | this.stack = (new Error()).stack 53 | } 54 | } 55 | 56 | export function getComments ({ url, updateOnly = false }) { 57 | return async dispatch => { 58 | const noTrailingSlashUrl = url.replace(/[\/*]$/, '') 59 | dispatch({ type: GET_COMMENTS, url: noTrailingSlashUrl, updateOnly }) 60 | try { 61 | const fetchUrl = 62 | `${websiteUrl}/comments${noTrailingSlashUrl}/comments.json` 63 | const response = await fetch(fetchUrl) 64 | const { status } = response 65 | if (status === 403 || status === 404) { 66 | dispatch({ type: GET_COMMENTS_COMPLETE, comments: [], updateOnly }) 67 | return 68 | } 69 | const comments = await response.json() 70 | dispatch({ type: GET_COMMENTS_COMPLETE, comments, updateOnly }) 71 | } catch (error) { 72 | dispatch({ type: GET_COMMENTS_ERROR, error, updateOnly }) 73 | } 74 | } 75 | } 76 | 77 | function refetchCommentsWhilePending ({ url }) { 78 | return (dispatch, getState) => { 79 | let retryCounter = 0 80 | const interval = setInterval(async () => { 81 | const { comments: { pendingComments } } = getState() 82 | if (retryCounter++ > 10 || Object.keys(pendingComments).length === 0) { 83 | clearInterval(interval) 84 | } else { 85 | dispatch(getComments({ url, updateOnly: true })) 86 | } 87 | }, 5000) 88 | } 89 | } 90 | 91 | export function postComment ({ 92 | url, 93 | pathname, 94 | commentContent, 95 | authorName, 96 | authorEmail, 97 | authorUrl, 98 | }) { 99 | return async dispatch => { 100 | const payload = { 101 | permalink: url, 102 | referrer: window.document.referrer, 103 | userAgent: window.navigator.userAgent, 104 | commentContent, 105 | authorName, 106 | authorEmail, 107 | authorUrl, 108 | } 109 | dispatch({ type: POST_COMMENT, payload }) 110 | const buffer = Buffer.from(JSON.stringify(payload)) 111 | const signature = hmac.sign(buffer, apiKey) 112 | const body = JSON.stringify({ 113 | signature, 114 | payload, 115 | }) 116 | try { 117 | const apiPostUrl = `${apiUrl}/comments` 118 | const response = await fetch( 119 | apiPostUrl, 120 | { 121 | method: 'POST', 122 | headers: { 123 | Accept: 'application/json', 124 | 'Content-Type': 'application/json', 125 | }, 126 | body, 127 | } 128 | ) 129 | const { status } = response 130 | const responseData = await response.json() 131 | if (status === 201) { 132 | dispatch({ type: POST_COMMENT_COMPLETE, responseData, payload }) 133 | dispatch(refetchCommentsWhilePending({ url: pathname })) 134 | } else if (status === 400) { 135 | const { errorMessage } = responseData 136 | if (!errorMessage) { 137 | throw new Error('Error occured while posting comment') 138 | } 139 | const parsedError = JSON.parse(errorMessage) 140 | const { error, data } = parsedError 141 | if (error === 'ValidationError') { 142 | throw new ValidationError(data) 143 | } 144 | if (error === 'SpamError') { 145 | throw new SpamError(data) 146 | } 147 | if (error === 'VerificationError') { 148 | throw new VerificationError(data) 149 | } 150 | throw new Error('Error occured while posting comment') 151 | } else { 152 | throw new Error('Unexpected status on response') 153 | } 154 | } catch (error) { 155 | dispatch({ type: POST_COMMENT_ERROR, error }) 156 | throw error 157 | } 158 | } 159 | } 160 | 161 | export function resetCommentForm ({ pathname, clearContent }) { 162 | return dispatch => { 163 | const data = { 164 | authorName: '', 165 | authorEmail: '', 166 | authorUrl: '', 167 | commentContent: '', 168 | } 169 | if (store.enabled) { 170 | const savedPathname = store.get('pathname') 171 | data.authorName = store.get('authorName') 172 | data.authorEmail = store.get('authorEmail') 173 | data.authorUrl = store.get('authorUrl') 174 | if (!clearContent && pathname === savedPathname) { 175 | data.commentContent = store.get('commentContent') 176 | } 177 | } 178 | dispatch(initializeReduxForm(FORM_NAME, data, FORM_FIELDS)) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /packages/frontend/static/Promise.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * @overview es6-promise - a tiny implementation of Promises/A+. 3 | * @copyright Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors (Conversion to ES6 API by Jake Archibald) 4 | * @license Licensed under MIT license 5 | * See https://raw.githubusercontent.com/jakearchibald/es6-promise/master/LICENSE 6 | * @version 3.1.2 7 | */ 8 | 9 | (function(){"use strict";function t(t){return"function"==typeof t||"object"==typeof t&&null!==t}function e(t){return"function"==typeof t}function n(t){W=t}function r(t){H=t}function o(){return function(){process.nextTick(a)}}function i(){return function(){U(a)}}function s(){var t=0,e=new Q(a),n=document.createTextNode("");return e.observe(n,{characterData:!0}),function(){n.data=t=++t%2}}function u(){var t=new MessageChannel;return t.port1.onmessage=a,function(){t.port2.postMessage(0)}}function c(){return function(){setTimeout(a,1)}}function a(){for(var t=0;G>t;t+=2){var e=X[t],n=X[t+1];e(n),X[t]=void 0,X[t+1]=void 0}G=0}function f(){try{var t=require,e=t("vertx");return U=e.runOnLoop||e.runOnContext,i()}catch(n){return c()}}function l(t,e){var n=this,r=n._state;if(r===et&&!t||r===nt&&!e)return this;var o=new this.constructor(p),i=n._result;if(r){var s=arguments[r-1];H(function(){C(r,o,s,i)})}else j(n,o,t,e);return o}function h(t){var e=this;if(t&&"object"==typeof t&&t.constructor===e)return t;var n=new e(p);return g(n,t),n}function p(){}function _(){return new TypeError("You cannot resolve a promise with itself")}function d(){return new TypeError("A promises callback cannot return that same promise.")}function v(t){try{return t.then}catch(e){return rt.error=e,rt}}function y(t,e,n,r){try{t.call(e,n,r)}catch(o){return o}}function m(t,e,n){H(function(t){var r=!1,o=y(n,e,function(n){r||(r=!0,e!==n?g(t,n):E(t,n))},function(e){r||(r=!0,S(t,e))},"Settle: "+(t._label||" unknown promise"));!r&&o&&(r=!0,S(t,o))},t)}function w(t,e){e._state===et?E(t,e._result):e._state===nt?S(t,e._result):j(e,void 0,function(e){g(t,e)},function(e){S(t,e)})}function b(t,n,r){n.constructor===t.constructor&&r===Z&&constructor.resolve===$?w(t,n):r===rt?S(t,rt.error):void 0===r?E(t,n):e(r)?m(t,n,r):E(t,n)}function g(e,n){e===n?S(e,_()):t(n)?b(e,n,v(n)):E(e,n)}function A(t){t._onerror&&t._onerror(t._result),T(t)}function E(t,e){t._state===tt&&(t._result=e,t._state=et,0!==t._subscribers.length&&H(T,t))}function S(t,e){t._state===tt&&(t._state=nt,t._result=e,H(A,t))}function j(t,e,n,r){var o=t._subscribers,i=o.length;t._onerror=null,o[i]=e,o[i+et]=n,o[i+nt]=r,0===i&&t._state&&H(T,t)}function T(t){var e=t._subscribers,n=t._state;if(0!==e.length){for(var r,o,i=t._result,s=0;ss;s++)j(r.resolve(t[s]),void 0,e,n);return o}function Y(t){var e=this,n=new e(p);return S(n,t),n}function q(){throw new TypeError("You must pass a resolver function as the first argument to the promise constructor")}function F(){throw new TypeError("Failed to construct 'Promise': Please use the 'new' operator, this object constructor cannot be called as a function.")}function D(t){this._id=ct++,this._state=void 0,this._result=void 0,this._subscribers=[],p!==t&&("function"!=typeof t&&q(),this instanceof D?M(this,t):F())}function K(t,e){this._instanceConstructor=t,this.promise=new t(p),Array.isArray(e)?(this._input=e,this.length=e.length,this._remaining=e.length,this._result=new Array(this.length),0===this.length?E(this.promise,this._result):(this.length=this.length||0,this._enumerate(),0===this._remaining&&E(this.promise,this._result))):S(this.promise,this._validationError())}function L(){var t;if("undefined"!=typeof global)t=global;else if("undefined"!=typeof self)t=self;else try{t=Function("return this")()}catch(e){throw new Error("polyfill failed because global object is unavailable in this environment")}var n=t.Promise;(!n||"[object Promise]"!==Object.prototype.toString.call(n.resolve())||n.cast)&&(t.Promise=at)}var N;N=Array.isArray?Array.isArray:function(t){return"[object Array]"===Object.prototype.toString.call(t)};var U,W,z,B=N,G=0,H=function(t,e){X[G]=t,X[G+1]=e,G+=2,2===G&&(W?W(a):z())},I="undefined"!=typeof window?window:void 0,J=I||{},Q=J.MutationObserver||J.WebKitMutationObserver,R="undefined"==typeof self&&"undefined"!=typeof process&&"[object process]"==={}.toString.call(process),V="undefined"!=typeof Uint8ClampedArray&&"undefined"!=typeof importScripts&&"undefined"!=typeof MessageChannel,X=new Array(1e3);z=R?o():Q?s():V?u():void 0===I&&"function"==typeof require?f():c();var Z=l,$=h,tt=void 0,et=1,nt=2,rt=new P,ot=new P,it=O,st=k,ut=Y,ct=0,at=D;D.all=it,D.race=st,D.resolve=$,D.reject=ut,D._setScheduler=n,D._setAsap=r,D._asap=H,D.prototype={constructor:D,then:Z,"catch":function(t){return this.then(null,t)}};var ft=K;K.prototype._validationError=function(){return new Error("Array Methods must be provided an Array")},K.prototype._enumerate=function(){for(var t=this.length,e=this._input,n=0;this._state===tt&&t>n;n++)this._eachEntry(e[n],n)},K.prototype._eachEntry=function(t,e){var n=this._instanceConstructor,r=n.resolve;if(r===$){var o=v(t);if(o===Z&&t._state!==tt)this._settledAt(t._state,e,t._result);else if("function"!=typeof o)this._remaining--,this._result[e]=t;else if(n===at){var i=new n(p);b(i,t,o),this._willSettleAt(i,e)}else this._willSettleAt(new n(function(e){e(t)}),e)}else this._willSettleAt(r(t),e)},K.prototype._settledAt=function(t,e,n){var r=this.promise;r._state===tt&&(this._remaining--,t===nt?S(r,n):this._result[e]=n),0===this._remaining&&E(r,this._result)},K.prototype._willSettleAt=function(t,e){var n=this;j(t,void 0,function(t){n._settledAt(et,e,t)},function(t){n._settledAt(nt,e,t)})};var lt=L,ht={Promise:at,polyfill:lt};"function"==typeof define&&define.amd?define(function(){return ht}):"undefined"!=typeof module&&module.exports?module.exports=ht:"undefined"!=typeof this&&(this.ES6Promise=ht),lt()}).call(this); 10 | -------------------------------------------------------------------------------- /packages/lambda/src/queueComment/index.js: -------------------------------------------------------------------------------- 1 | import { parse as urlParse } from 'url' 2 | import { normalize as pathNormalize, join as pathJoin } from 'path' 3 | import slugid from 'slugid' 4 | import moment from 'moment' 5 | import { isEmail, isURL } from 'validator' 6 | import jwa from 'jwa' 7 | import dotenv from 'dotenv' 8 | import { generateReference } from 'lambda-comments-utils/src/references' 9 | import { uploadPrivate } from 'lambda-comments-utils/src/s3' 10 | import Akismet from 'lambda-comments-utils/src/akismet' 11 | import { updateRecord } from 'lambda-comments-utils/src/dynamoDb' 12 | import { apiKey } from '../../../../deploy/state/apiKey.json' 13 | 14 | dotenv.config({ silent: true }) 15 | 16 | const hmac = jwa('HS256') 17 | 18 | let akismet = null 19 | 20 | class ValidationError extends Error { 21 | constructor (data) { 22 | super() 23 | this.name = 'ValidationError' 24 | this.data = data 25 | this.stack = (new Error()).stack 26 | } 27 | } 28 | 29 | function uploadJson ({ dirName, actionRef, action }) { 30 | return uploadPrivate({ 31 | key: `${dirName}/.actions/${actionRef}/action.json`, 32 | data: JSON.stringify(action), 33 | contentType: 'application/json' 34 | }) 35 | } 36 | 37 | function validate (payload) { 38 | const { 39 | permalink, 40 | userAgent, 41 | commentContent, 42 | authorName, 43 | authorEmail, 44 | authorUrl 45 | } = payload 46 | const errors = {} 47 | if (!permalink) { 48 | errors._error = 'Missing permalink' 49 | } 50 | if (!userAgent) { 51 | errors._error = 'Missing user agent' 52 | } 53 | if (!commentContent) { 54 | errors.commentContent = 'Required' 55 | } 56 | if (commentContent && commentContent.length < 3) { 57 | errors.commentContent = 'Must be at least 3 characters' 58 | } 59 | if (authorEmail && !isEmail(authorEmail)) { 60 | errors.authorEmail = 'Email format not valid' 61 | } 62 | if (authorUrl && !isURL(authorUrl)) { 63 | errors.authorUrl = 'URL format not valid' 64 | } 65 | if (Object.keys(errors).length > 0) { 66 | throw new ValidationError(errors) 67 | } 68 | } 69 | 70 | async function checkSpam ({ payload, quiet, isTest }) { 71 | if (!akismet) { 72 | akismet = new Akismet() 73 | if (akismet.configured()) { 74 | const verified = await akismet.verifyKey() 75 | if (verified) { 76 | if (!quiet) { 77 | console.log('Akismet key/blog verified') 78 | } 79 | } else { 80 | throw new Error('Akismet key/blog failed verification') 81 | } 82 | } 83 | } 84 | if (akismet.configured()) { 85 | const { 86 | permalink, 87 | referrer, 88 | userAgent, 89 | commentContent, 90 | authorName, 91 | authorEmail, 92 | authorUrl, 93 | sourceIp 94 | } = payload 95 | const options = { 96 | user_ip: sourceIp, 97 | user_agent: userAgent, 98 | referrer, 99 | comment_type: 'comment', 100 | comment_author: authorName, 101 | comment_author_email: authorEmail, 102 | comment_author_url: authorUrl, 103 | comment_content: commentContent, 104 | is_test: isTest 105 | } 106 | const spam = await akismet.checkSpam(options) 107 | if (!quiet) { 108 | console.log(spam ? 'Akismet detected spam' : 'Akismet check passed') 109 | } 110 | if (spam) { 111 | throw new Error('Spam') 112 | } 113 | } 114 | } 115 | 116 | export async function handler (event, context, callback) { 117 | if (!callback) { 118 | const errorMessage = 'Requires Node 4.3 or greater on Lambda' 119 | console.log(errorMessage) 120 | context.error(context.fail(errorMessage)) 121 | return 122 | } 123 | const quiet = event ? !!event.quiet : false 124 | try { 125 | const { 126 | sourceIp, 127 | fields, 128 | dryRun, 129 | quiet, 130 | skipSpamCheck, 131 | isTest 132 | } = event 133 | const { 134 | signature, 135 | payload: incomingPayload 136 | } = fields 137 | const buffer = new Buffer(JSON.stringify(incomingPayload)) 138 | const verification = hmac.verify( 139 | buffer, 140 | signature, 141 | apiKey 142 | ) 143 | if (!verification) { 144 | throw new Error('VerificationError') 145 | } 146 | validate(incomingPayload) 147 | const { 148 | permalink, 149 | referrer, 150 | userAgent, 151 | commentContent, 152 | authorName, 153 | authorEmail, 154 | authorUrl 155 | } = incomingPayload 156 | const { pathname } = urlParse(permalink) 157 | const normalizedPath = pathNormalize(pathname).replace(/\/+$/, '') 158 | const dirName = pathJoin('comments', normalizedPath) 159 | if (!commentContent) { 160 | throw new Error('Missing commentContent') 161 | } 162 | const now = moment.utc() 163 | const actionRef = generateReference(now) 164 | const id = slugid.v4() 165 | const payload = { 166 | id, 167 | permalink, 168 | referrer, 169 | userAgent, 170 | commentContent, 171 | authorName, 172 | authorEmail, 173 | authorUrl, 174 | sourceIp 175 | } 176 | const action = { 177 | type: 'NEW_COMMENT', 178 | actionRef, 179 | payload, 180 | submittedDate: now.toISOString() 181 | } 182 | if (!quiet) { 183 | console.log('actionRef:', actionRef) 184 | console.log('permalink:', permalink) 185 | } 186 | if (!dryRun) { 187 | await uploadJson({ dirName, actionRef, action }) 188 | if (!skipSpamCheck) { 189 | await checkSpam({ payload, quiet, isTest }) 190 | } 191 | await updateRecord({ dirName, actionRef }) 192 | } 193 | callback( null, { id } ) 194 | } catch (error) { 195 | if (error.name === 'ValidationError') { 196 | if (!quiet) { 197 | console.log('ValidationError', error.data) 198 | } 199 | callback(JSON.stringify({ 200 | error: 'ValidationError', 201 | data: error.data 202 | })) 203 | return 204 | } 205 | if (error.message === 'Spam') { 206 | if (!quiet) { 207 | console.log('Spam detected') 208 | } 209 | callback(JSON.stringify({ 210 | error: 'SpamError', 211 | data: { 212 | _error: 'Our automated filter thinks this comment is spam.' 213 | } 214 | })) 215 | return 216 | } 217 | if (error.message === 'VerificationError') { 218 | if (!quiet) { 219 | console.log('Checksum verification failed') 220 | } 221 | callback(JSON.stringify({ 222 | error: 'VerificationError', 223 | data: { 224 | _error: 'Checksum verification failed.' 225 | } 226 | })) 227 | return 228 | } 229 | if (!quiet) { 230 | console.log('Queue Comment error', error) 231 | console.log(error.stack) 232 | } 233 | callback(JSON.stringify({ 234 | error: 'Exception occurred' 235 | })) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /packages/lambda/src/worker/index.js: -------------------------------------------------------------------------------- 1 | import { parse as urlParse } from 'url' 2 | import { normalize as pathNormalize, join as pathJoin } from 'path' 3 | import fetch from 'node-fetch' 4 | import { createStore, applyMiddleware } from 'redux' 5 | import createLogger from 'redux-logger' 6 | import moment from 'moment' 7 | import dotenv from 'dotenv' 8 | import { postToSlack } from 'lambda-comments-utils/src/slack' 9 | import { 10 | downloadPrivate, 11 | downloadWebsite, 12 | uploadPrivate, 13 | uploadWebsite 14 | } from 'lambda-comments-utils/src/s3' 15 | import { generateReference } from 'lambda-comments-utils/src/references' 16 | 17 | dotenv.config({ silent: true }) 18 | 19 | let invocationCounter = 0 20 | 21 | const initialState = {} 22 | 23 | const logger = createLogger({ 24 | colors: { 25 | title: false, 26 | prevState: false, 27 | action: false, 28 | nextState: false, 29 | error: false 30 | } 31 | }) 32 | 33 | function reducer (state = initialState, action) { 34 | switch (action.type) { 35 | case 'FETCH_OLD_COMMENTS': 36 | { 37 | const { dirName, comments, timestamp } = action 38 | return { 39 | ...state, 40 | [dirName]: { 41 | timestamp, 42 | comments, 43 | } 44 | } 45 | } 46 | case 'NEW_COMMENT': 47 | { 48 | const { 49 | dirName, 50 | submittedDate: date, 51 | payload: { 52 | id, 53 | commentContent, 54 | authorName, 55 | authorUrl 56 | } 57 | } = action 58 | const comments = state[dirName] ? [...state[dirName].comments] : [] 59 | const timestamp = moment.utc() 60 | comments.push({ 61 | id, 62 | date, 63 | authorName, 64 | authorUrl, 65 | commentContent 66 | }) 67 | return { 68 | ...state, 69 | [dirName]: { 70 | timestamp, 71 | comments, 72 | } 73 | } 74 | } 75 | default: 76 | return state 77 | } 78 | } 79 | 80 | // const store = createStore(reducer, applyMiddleware(logger)) 81 | const store = createStore(reducer) 82 | 83 | async function fetchOldComments({ dirName, quiet }) { 84 | const state = store.getState() 85 | if (state[dirName] && 86 | moment().subtract(20, 'seconds').isBefore(state[dirName].timestamp)) { 87 | // Use cache if it's under 20 seconds old 88 | if (!quiet) { 89 | console.log('Using cached old comments') 90 | } 91 | return 92 | } 93 | const key = `${dirName}/comments.json` 94 | try { 95 | if (!quiet) { 96 | console.log('Loading old comments from S3') 97 | } 98 | const fileData = await downloadWebsite({ key }) 99 | const { LastModified: lastModified } = fileData 100 | const timestamp = moment.utc(new Date(lastModified)) 101 | const comments = JSON.parse(fileData.Body.toString()) 102 | await store.dispatch({ 103 | type: 'FETCH_OLD_COMMENTS', 104 | dirName, 105 | comments, 106 | timestamp 107 | }) 108 | } catch (error) { 109 | // For some reason, when there is no file in S3, we get an 'NoSuchKey' 110 | // error when running from the developer account, but an 'AccessDenied' 111 | // when running on Lambda 112 | if ( 113 | error.cause && 114 | (error.cause().code === 'NoSuchKey' || 115 | error.cause().code === 'AccessDenied') 116 | ) { 117 | // It's okay if the file doesn't exist. That is normal 118 | // for the first post 119 | if (!quiet) { 120 | console.log('No old comments found') 121 | } 122 | const timestamp = moment.utc() 123 | const comments = [] 124 | await store.dispatch({ 125 | type: 'FETCH_OLD_COMMENTS', 126 | dirName, 127 | comments, 128 | timestamp 129 | }) 130 | return 131 | } 132 | throw error 133 | } 134 | } 135 | 136 | async function postMessageToSlack({ action, quiet }) { 137 | const { 138 | type, 139 | payload: { 140 | id, 141 | permalink, 142 | authorName, 143 | authorEmail, 144 | authorUrl, 145 | commentContent 146 | } 147 | } = action 148 | if (type !== 'NEW_COMMENT') { 149 | return 150 | } 151 | await postToSlack({ 152 | message: { 153 | text: `Comment posted <${permalink}#comment-${id}>`, 154 | attachments: [ 155 | { 156 | fields: [ 157 | { 158 | title: "Author Name", 159 | value: authorName, 160 | short: true, 161 | }, 162 | { 163 | title: "Author Email", 164 | value: authorEmail, 165 | short: true, 166 | }, 167 | { 168 | title: "Author Url", 169 | value: authorUrl, 170 | short: true, 171 | }, 172 | ] 173 | }, 174 | { 175 | title: "Comment", 176 | text: commentContent 177 | } 178 | ] 179 | }, 180 | quiet 181 | }) 182 | } 183 | 184 | async function downloadActionAndDispatch({ dirName, actionRef, quiet }) { 185 | await fetchOldComments({ dirName, quiet }) 186 | const key = `${dirName}/.actions/${actionRef}/action.json` 187 | const fileData = await downloadPrivate({ key }) 188 | const action = JSON.parse(fileData.Body.toString()) 189 | await store.dispatch({ ...action, dirName }) 190 | await postMessageToSlack({ action, quiet }) 191 | } 192 | 193 | async function saveAllComments ({ quiet }) { 194 | const allComments = store.getState() 195 | for (const key in allComments) { 196 | if (!quiet) { 197 | console.log('Saving', key) 198 | } 199 | const now = moment.utc() 200 | const backupRef = generateReference(now) 201 | await uploadPrivate({ 202 | key: `${key}/.backup/${backupRef}/comments.json`, 203 | data: JSON.stringify(allComments[key].comments, null, 2), 204 | contentType: 'application/json' 205 | }) 206 | await uploadWebsite({ 207 | key: `${key}/comments.json`, 208 | data: JSON.stringify(allComments[key].comments, null, 2), 209 | contentType: 'application/json' 210 | }) 211 | } 212 | } 213 | 214 | export async function handler (event, context, callback) { 215 | if (!callback) { 216 | const errorMessage = 'Requires Node 4.3 or greater on Lambda' 217 | console.log(errorMessage) 218 | context.error(context.fail(errorMessage)) 219 | return 220 | } 221 | try { 222 | // console.log('Event', JSON.stringify(event, null, 2)) 223 | const { 224 | Records: records, 225 | quiet, 226 | dryRun 227 | } = event 228 | if (!quiet) { 229 | console.log('Invocation count:', ++invocationCounter) 230 | } 231 | let count = 0 232 | for (let record of records) { 233 | const { 234 | dynamodb: { 235 | NewImage: { 236 | actionRef: { 237 | S: actionRef 238 | }, 239 | dirName: { 240 | S: dirName 241 | } 242 | } 243 | } 244 | } = record 245 | count++ 246 | if (!quiet) { 247 | console.log(`Record ${count} of ${records.length}`) 248 | console.log(' dirName:', dirName) 249 | console.log(' actionRef:', actionRef) 250 | } 251 | if (!dryRun) { 252 | await downloadActionAndDispatch({ dirName, actionRef, quiet }) 253 | } 254 | } 255 | await saveAllComments({ quiet }) 256 | callback( null, { success: true } ) 257 | } catch (error) { 258 | // console.log('Worker error', error) 259 | // console.log(error.stack) 260 | callback(error) 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /packages/frontend/src/ui/postCommentForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react' 2 | import { reduxForm } from 'redux-form' 3 | import Textarea from 'react-textarea-autosize' 4 | import { Motion, spring, presets } from 'react-motion' 5 | import Measure from 'react-measure' 6 | import Spinner from 'react-spinner' 7 | import '!style!css!react-spinner/react-spinner.css' 8 | import { autobind } from 'core-decorators' 9 | import { isEmail, isURL } from 'validator' 10 | import store from 'store' 11 | import gitHubSvg from 'octicons/svg/mark-github.svg' 12 | import Comment from './comment' 13 | import { 14 | postCommentForm, 15 | hasError, 16 | errorMessage, 17 | postCommentFormHeader, 18 | markdownNote, 19 | previewWrapper, 20 | preview, 21 | spinnerButton, 22 | buttonContent, 23 | buttonText, 24 | spinnerWrapper, 25 | footer, 26 | marketing, 27 | } from './comments.css' 28 | import { FORM_NAME, FORM_FIELDS } from '../actions/comments' 29 | 30 | function validate (values) { 31 | const errors = {} 32 | const { commentContent, authorEmail, authorUrl } = values 33 | if (!commentContent) { 34 | errors.commentContent = 'Required' 35 | } 36 | if (commentContent && commentContent.length < 3) { 37 | errors.commentContent = 'Must be at least 3 characters' 38 | } 39 | if (authorEmail && !isEmail(authorEmail)) { 40 | errors.authorEmail = 'Email format not valid' 41 | } 42 | if (authorUrl && !isURL(authorUrl)) { 43 | errors.authorUrl = 'URL format not valid' 44 | } 45 | return errors 46 | } 47 | 48 | @reduxForm({ 49 | form: FORM_NAME, 50 | fields: FORM_FIELDS, 51 | validate, 52 | }) 53 | export default class PostCommentForm extends Component { 54 | 55 | static propTypes = { 56 | pathname: PropTypes.string.isRequired, 57 | fields: PropTypes.object.isRequired, 58 | error: PropTypes.string, 59 | handleSubmit: PropTypes.func.isRequired, 60 | resetCommentForm: PropTypes.func.isRequired, 61 | submitting: PropTypes.bool.isRequired, 62 | } 63 | 64 | constructor (props) { 65 | super(props) 66 | this.state = { height: 0 } 67 | } 68 | 69 | componentWillMount () { 70 | const { resetCommentForm, pathname } = this.props 71 | resetCommentForm({ pathname }) 72 | } 73 | 74 | componentWillReceiveProps (nextProps) { 75 | const { 76 | fields: { 77 | commentContent: { 78 | value: commentContent, 79 | }, 80 | authorName: { 81 | value: authorName, 82 | }, 83 | authorEmail: { 84 | value: authorEmail, 85 | }, 86 | authorUrl: { 87 | value: authorUrl, 88 | }, 89 | }, 90 | pathname, 91 | } = nextProps 92 | if (store.enabled) { 93 | if (pathname !== null) { 94 | store.set('pathname', pathname) 95 | } 96 | if (commentContent !== null) { 97 | store.set('commentContent', commentContent) 98 | } 99 | if (authorName !== null) { 100 | store.set('authorName', authorName) 101 | } 102 | if (authorEmail !== null) { 103 | store.set('authorEmail', authorEmail) 104 | } 105 | if (authorUrl !== null) { 106 | store.set('authorUrl', authorUrl) 107 | } 108 | } 109 | } 110 | 111 | @autobind 112 | getStyle () { 113 | const { fields: { commentContent } } = this.props 114 | const { height } = this.state 115 | if (!commentContent.value || !height) { 116 | return { height: spring(5, presets.gentle) } 117 | } 118 | return { height: spring(height + 20, presets.gentle) } 119 | } 120 | 121 | render () { 122 | const { 123 | fields: { 124 | commentContent, 125 | authorName, 126 | authorEmail, 127 | authorUrl, 128 | }, 129 | error, 130 | handleSubmit, 131 | submitting, 132 | } = this.props 133 | const gitHubUrl = 134 | 'https://help.github.com/articles/basic-writing-and-formatting-syntax/' 135 | const previewComment = { 136 | authorName: authorName.value, 137 | authorUrl: authorUrl.value, 138 | date: new Date(), 139 | commentContent: commentContent.value, 140 | } 141 | return ( 142 |
146 |
147 | Add your comment 148 | 149 | 154 | GitHub-style markdown is supported 155 | 156 | 157 |
158 | {error && 159 |
160 | {error} 161 |
162 | } 163 |