├── .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 |
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 |
270 | )
271 | }
272 | }
273 |
--------------------------------------------------------------------------------
/packages/lambda/src/queueComment/test.js:
--------------------------------------------------------------------------------
1 | import assert from 'assert'
2 | import supertest from 'supertest'
3 | import { getApiUrl } from 'lambda-comments-utils/src/cloudFormation'
4 | import { apiKey } from '../../../../deploy/state/apiKey.json'
5 | import { expect } from 'chai'
6 | import { handler } from './index'
7 | import jwa from 'jwa'
8 |
9 | const hmac = jwa('HS256')
10 |
11 | function checkBody (body) {
12 | expect(body).to.be.a('object')
13 | const { id } = body
14 | expect(id).to.be.a('string')
15 | }
16 |
17 | export function local () {
18 |
19 | describe('Post new comment to the queue', function () {
20 |
21 | this.timeout(5000)
22 |
23 | it('should return an id', function (done) {
24 | const payload = {
25 | permalink: 'http://example.com/blog/1/',
26 | userAgent: 'testhost/1.0 | node-akismet/0.0.1',
27 | referrer: 'http://jimpick.com/',
28 | commentContent: 'My comment',
29 | authorName: 'Bob Bob',
30 | authorEmail: 'bob@example.com',
31 | authorUrl: 'http://bob.example.com/',
32 | }
33 | const buffer = Buffer.from(JSON.stringify(payload))
34 | const signature = hmac.sign(buffer, apiKey)
35 | const event = {
36 | fields: {
37 | payload,
38 | signature
39 | },
40 | sourceIp: '64.46.22.7',
41 | dryRun: true,
42 | quiet: true,
43 | skipSpamCheck: true,
44 | isTest: true,
45 | }
46 | handler(event, null, (error, result) => {
47 | expect(error).to.be.null
48 | checkBody(result)
49 | done()
50 | })
51 | })
52 |
53 | it('should fail if there is no data', function (done) {
54 | const payload = {}
55 | const buffer = Buffer.from(JSON.stringify(payload))
56 | const signature = hmac.sign(buffer, apiKey)
57 | const event = {
58 | fields: {
59 | payload,
60 | signature
61 | },
62 | quiet: true,
63 | skipSpamCheck: true,
64 | isTest: true,
65 | }
66 | handler(event, null, error => {
67 | expect(error).to.be.a('string')
68 | expect(error).to.equal(JSON.stringify({
69 | error: 'ValidationError',
70 | data: {
71 | _error: 'Missing user agent',
72 | commentContent: 'Required'
73 | }
74 | }))
75 | done()
76 | })
77 | })
78 |
79 | it('should catch spam', function (done) {
80 | // FIXME: Use nock to mock HTTP API for akismet
81 | const payload = {
82 | permalink: 'http://example.com/blog/1/',
83 | userAgent: 'testhost/1.0 | node-akismet/0.0.1',
84 | referrer: 'http://jimpick.com/',
85 | commentContent: 'My comment',
86 | authorName: 'viagra-test-123',
87 | authorEmail: 'bob@example.com',
88 | authorUrl: 'http://bob.example.com/',
89 | }
90 | const buffer = Buffer.from(JSON.stringify(payload))
91 | const signature = hmac.sign(buffer, apiKey)
92 | const event = {
93 | fields: {
94 | payload,
95 | signature
96 | },
97 | sourceIp: '64.46.22.7',
98 | // dryRun: true,
99 | quiet: true,
100 | isTest: true,
101 | }
102 | handler(event, null, (error, result) => {
103 | expect(error).to.be.a('string')
104 | expect(error).to.equal(JSON.stringify({
105 | error: 'SpamError',
106 | data: {
107 | _error: 'Our automated filter thinks this comment is spam.'
108 | }
109 | }))
110 | done()
111 | })
112 | })
113 |
114 | it('should fail with a bad signature', function (done) {
115 | const payload = {
116 | permalink: 'http://example.com/blog/1/',
117 | userAgent: 'testhost/1.0 | node-akismet/0.0.1',
118 | referrer: 'http://jimpick.com/',
119 | commentContent: 'My comment',
120 | authorName: 'Bob Bob',
121 | authorEmail: 'bob@example.com',
122 | authorUrl: 'http://bob.example.com/',
123 | }
124 | const buffer = Buffer.from(JSON.stringify(payload))
125 | const signature = hmac.sign(buffer, 'bad api key')
126 | const event = {
127 | fields: {
128 | payload,
129 | signature
130 | },
131 | sourceIp: '64.46.22.7',
132 | dryRun: true,
133 | quiet: true,
134 | skipSpamCheck: true,
135 | isTest: true,
136 | }
137 | handler(event, null, (error, result) => {
138 | expect(error).to.be.a('string')
139 | expect(error).to.equal(JSON.stringify({
140 | error: 'VerificationError',
141 | data: {
142 | _error: 'Checksum verification failed.'
143 | }
144 | }))
145 | done()
146 | })
147 | })
148 |
149 | it('should allow posting hangul characters', function (done) {
150 | const payload = {
151 | permalink: 'http://example.com/blog/1/',
152 | userAgent: 'testhost/1.0 | node-akismet/0.0.1',
153 | referrer: 'http://jimpick.com/',
154 | commentContent: '비빔밥(乒乓飯)은 대표적인 한국 요리의 하나로, 사발 그릇에 밥과 여러 가지 나물, 고기, 계란, 고추장 등을 넣고 섞어서 먹는 음식이다.',
155 | authorName: 'Bob Bob',
156 | authorEmail: 'bob@example.com',
157 | authorUrl: 'http://bob.example.com/',
158 | }
159 | const buffer = Buffer.from(JSON.stringify(payload))
160 | const signature = hmac.sign(buffer, apiKey)
161 | const event = {
162 | fields: {
163 | payload,
164 | signature
165 | },
166 | sourceIp: '64.46.22.7',
167 | dryRun: true,
168 | quiet: true,
169 | skipSpamCheck: true,
170 | isTest: true,
171 | }
172 | handler(event, null, (error, result) => {
173 | expect(error).to.be.null
174 | checkBody(result)
175 | done()
176 | })
177 | })
178 |
179 |
180 | // it('should write a json file to S3')
181 |
182 | // it('should write to DynamoDB')
183 |
184 | })
185 |
186 | }
187 |
188 | export function remote () {
189 |
190 | describe('Post new comment to the queue', function () {
191 |
192 | this.timeout(5000)
193 |
194 | function testResponse(request, done) {
195 | request
196 | .expect(201)
197 | .expect('Content-Type', /json/)
198 | .expect(({ body }) => {
199 | checkBody(body)
200 | })
201 | .end(done)
202 | }
203 |
204 | it('should return an actionRef', function (done) {
205 | const payload = {
206 | permalink: 'http://example.com/blog/1',
207 | userAgent: 'Test Suite',
208 | referrer: 'http://jimpick.com/',
209 | commentContent: 'My comment',
210 | authorName: 'Bob Bob',
211 | authorEmail: 'bob@example.com',
212 | authorUrl: 'http://bob.example.com/'
213 | }
214 | const buffer = Buffer.from(JSON.stringify(payload))
215 | const signature = hmac.sign(buffer, apiKey)
216 | const request = supertest(getApiUrl())
217 | .post('/comments')
218 | .send({ payload, signature })
219 | testResponse(request, done)
220 | })
221 |
222 | it('should fail if there is no data', function (done) {
223 | const payload = {}
224 | const buffer = Buffer.from(JSON.stringify(payload))
225 | const signature = hmac.sign(buffer, apiKey)
226 | const request = supertest(getApiUrl())
227 | .post('/comments')
228 | .send({ payload, signature })
229 | .expect(400)
230 | .expect({
231 | errorMessage: JSON.stringify({
232 | error: 'ValidationError',
233 | data: {
234 | _error: 'Missing user agent',
235 | commentContent: 'Required'
236 | }
237 | })
238 | })
239 | .end(done)
240 | })
241 |
242 | it('should allow posting hangul characters', function (done) {
243 | const payload = {
244 | permalink: 'http://example.com/blog/1/',
245 | userAgent: 'testhost/1.0 | node-akismet/0.0.1',
246 | referrer: 'http://jimpick.com/',
247 | commentContent: '비빔밥(乒乓飯)은 대표적인 한국 요리의 하나로, 사발 그릇에 밥과 여러 가지 나물, 고기, 계란, 고추장 등을 넣고 섞어서 먹는 음식이다.',
248 | authorName: 'Bob Bob',
249 | authorEmail: 'bob@example.com',
250 | authorUrl: 'http://bob.example.com/',
251 | }
252 | const buffer = Buffer.from(JSON.stringify(payload))
253 | const signature = hmac.sign(buffer, apiKey)
254 | const request = supertest(getApiUrl())
255 | .post('/comments')
256 | .send({ payload, signature })
257 | testResponse(request, done)
258 | })
259 |
260 |
261 |
262 | })
263 |
264 | }
265 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lambda Comments
2 |
3 | This project which implements a minimal blog commenting service.
4 |
5 | Blog posts:
6 |
7 | * [Introducing lambda-comments](https://jimpick.com/2016/05/05/introducing-lambda-comments/) (you can try leaving a comment there)
8 | * [A day on the Hacker News home page: lambda-comments](https://jimpick.com/2016/05/10/after-hacker-news-lambda-comments/)
9 |
10 | Hacker News thread: https://news.ycombinator.com/item?id=11644042
11 |
12 | It is completely "serverless", designed to use the following Amazon services:
13 |
14 | * [API Gateway](http://docs.aws.amazon.com/apigateway/latest/developerguide/welcome.html)
15 | * [Lambda](http://docs.aws.amazon.com/lambda/latest/dg/welcome.html)
16 | * [S3](http://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html)
17 | * [DynamoDB streams](http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)
18 | * [IAM](http://docs.aws.amazon.com/IAM/latest/UserGuide/introduction.html)
19 | * [CloudWatch Logs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/WhatIsCloudWatch.html)
20 |
21 | The Lambda functions are written in [ES6](http://exploringjs.com/es6/), with [async/await](http://pouchdb.com/2015/03/05/taming-the-async-beast-with-es7.html), transpiled using [Babel](https://babeljs.io/), and bundled using [Webpack](https://webpack.github.io/).
22 |
23 | The AWS resources are provisioned using the [CloudFormation](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/Welcome.html) service.
24 |
25 | Additionally, we use Apex to simplify the uploading of the Lambda functions
26 | (without the shim).
27 |
28 | * http://apex.run/
29 |
30 | # Costs
31 |
32 | It should cost very little to run. The following resources are provisioned:
33 |
34 | * [DynamoDB](https://aws.amazon.com/dynamodb/pricing/) - only provisioned for 1 read capacity unit, 1 write capacity unit (which limits it to 1 job per second). This is the most expensive resource, approximately $0.65 a month.
35 | * [S3](https://aws.amazon.com/s3/pricing/) - storage for comments and private data, plus requests and data transfer
36 | * [CloudWatch Logs](https://aws.amazon.com/cloudwatch/pricing/)
37 | * [Lambda functions](https://aws.amazon.com/lambda/pricing/) - only pay for invocations, first million requests per month are free (hopefully your blog isn't that popular)
38 | * [API gateway](https://aws.amazon.com/api-gateway/pricing/) - only pay for API calls
39 |
40 | # Deployment Instructions
41 |
42 | ## Prerequisites
43 |
44 | * You will need an [AWS Account](https://aws.amazon.com/)
45 | * You will need OS X, Linux, \*BSD or another Unix-based OS (scripts will need some modifications for Windows)
46 | * Install the [AWS CLI](https://aws.amazon.com/cli/) and ensure credentials are setup under ~/.aws/credentials ([Instructions](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html#cli-config-files))
47 | * Install [Node.js](https://nodejs.org/) (tested with v4.2.6 and v5.7.0)
48 | * `git clone https://github.com/jimpick/lambda-comments.git` (https)
49 | or
50 | `git clone git@github.com:jimpick/lambda-comments.git` (git)
51 | * `cd lambda-comments`
52 | * `npm run install-all` (runs `npm install` in top directory, and then sets up sub-packages under `packages`)
53 | * Install [Apex](http://apex.run/)
54 |
55 | ## Configuration
56 |
57 | Copy `.env.SAMPLE` to `.env` and customize it.
58 |
59 | ```
60 | cp .env.SAMPLE .env
61 | ```
62 |
63 | The default .env.SAMPLE contains:
64 |
65 | ```
66 | # The URL of your blog/website that will be hosting the comments
67 | # Used to generate CORS headers and also for Akismet
68 | BLOG=https://example.com/blog/
69 |
70 | # A name for your CloudFormation stack
71 | # Also prefixed to the API Gateway REST API name
72 | CLOUDFORMATION=myBlogComments
73 |
74 | # The AWS region to provision the resources in
75 | REGION=us-west-2
76 |
77 | # The name for the API Gateway stage
78 | STAGE=prod
79 |
80 | # The Akismet.com API key (optional, but recommended)
81 | # Akismet is a service for combatting blog spam from Automattic (WordPress)
82 | #AKISMET=0123456789ab
83 |
84 | # A Slack webhook to send notifications to (optional)
85 | #SLACK=https://hooks.slack.com/services/XXXXXXXXX/YYYYYYYYY/ZZZZZZZZZZZZZZZZZZZZZZZZ
86 | ```
87 |
88 | We use [dotenv](https://github.com/motdotla/dotenv) so it is also possible to
89 | configure the project by setting environment variables.
90 |
91 | ### Parameters
92 |
93 | **BLOG**: The full base url of the blog/website
94 |
95 | **CLOUDFORMATION**: The name of the CloudFormation stack
96 |
97 | **REGION**: The AWS region
98 |
99 | **STAGE**: The API Gateway stage to create
100 |
101 | **AKISMET**: (Optional, but recommended) API key from [akismet.com](https://akismet.com/) for spam filtering
102 |
103 | **SLACK**: (Optional) Slack webhook - configure this if you want a notification
104 | in a Slack channel each time a comment is posted
105 |
106 | ## Installation
107 |
108 | For now, follow the step-by-step instructions below. In the future, we will
109 | develop a streamlined installation procedure.
110 |
111 | ## Use CloudFormation to create the AWS resources
112 |
113 | ```
114 | npm run create-cloudformation
115 | ```
116 |
117 | The command returns immediately, but it will take a while to complete
118 | (typically 3-4 minutes). It's a good idea
119 | to watch the CloudFormation task in the AWS Web Console to
120 | ensure that it completes without errors.
121 |
122 | **Note:** When working with the CloudFormation recipe, you can also use
123 | `npm run update-cloudformation` and `npm run delete-cloudformation`
124 |
125 | ## Save the references to the provisioned CloudFormation resources
126 |
127 | ```
128 | npm run save-cloudformation
129 | ```
130 |
131 | This will create a file in `deploy/state/cloudFormation.json`
132 |
133 | ## Temporary fix - update Lambda functions to use Node.js 4.3
134 |
135 | CloudFormation has issues setting the runtime to 'nodejs4.3', see:
136 |
137 | https://forums.aws.amazon.com/thread.jspa?threadID=229072
138 |
139 | Until CloudFormation is updated, here's an extra step to update
140 | the Lambda functions to use Node.js 4.3 using a custom script.
141 |
142 | ```
143 | npm run flip-lambdas-to-4.3
144 | ```
145 |
146 | ## Generate an API key
147 |
148 | ```
149 | npm run gen-api-key
150 | ```
151 |
152 | This will create a file in `deploy/state/apiKey.json` containing an
153 | apiKey variable that will be baked into the client to sign requests.
154 |
155 | The purpose of the API key is to try to minimize spam to the API, but
156 | as the API key is distributed publicly as part of the javascript, it's
157 | only there to stop non-sophisticated spammers who don't care enough to
158 | extract the API key and sign their requests.
159 |
160 | ## Setup the Apex build directory
161 |
162 | ```
163 | npm run setup-apex
164 | ```
165 |
166 | This generates `build/apex/project.json`
167 |
168 | ## Compile the Lambda scripts using babel
169 |
170 | ```
171 | npm run compile-lambda
172 | ```
173 |
174 | This will use webpack and babel to compile the source code in `src/server/lambdaFunctions` into `build/apex/functions`
175 |
176 | The webpack configuration is in `deploy/apex/webpack.config.es6.js`
177 |
178 | ## Deploy the lambda functions
179 |
180 | ```
181 | npm run deploy-lambda
182 | ```
183 |
184 | This will run `apex deploy` in the `build/apex` directory to upload the
185 | compiled lambda functions.
186 |
187 | Alternatively, if you want to execute the compile and deploy steps in one
188 | command, you can run: `npm run deploy-backend`
189 |
190 | ## Build the frontend javascript
191 |
192 | ```
193 | npm run build-frontend
194 | ```
195 |
196 | This builds the code in the `packages/frontend` directory.
197 |
198 | ## Upload the frontend javascript script to S3
199 |
200 | ```
201 | npm run upload-script
202 | ```
203 |
204 | This will copy `lambda-comments.js` to the S3 bucket.
205 |
206 | Alternatively, if you want to execute the compile and deploy steps in one
207 | command, you can run: `npm run deploy-frontend`.
208 |
209 | If you want to deploy the backend and frontend all in one step, you can
210 | use: `npm run deploy`
211 |
212 | ## Run the test suite
213 |
214 | ```
215 | npm run test
216 | ```
217 |
218 | This will run both the local tests, and remote test which test the
219 | deployed API and lambda functions.
220 |
221 | The local tests can be run as `npm run test-local`, and the remote tests can
222 | be run as `npm run test-remote`.
223 |
224 | Currently the test suite expects some data to pre-exist in the S3 bucket. Until
225 | the tests are properly mocked, they will fail unless the data is created.
226 |
227 | ## View logs
228 |
229 | You can tail the CloudWatch logs:
230 |
231 | ```
232 | npm run logs
233 | ```
234 |
235 | This just executes `apex logs -f` in `build/apex`
236 |
237 | ## Embed the front-end JavaScript client
238 |
239 | First, get the URL for the script:
240 |
241 | ```
242 | npm run get-client-js-url
243 | ```
244 |
245 | This will return a URL you will use below. eg.
246 |
247 | ```
248 | //s3-us-west-2.amazonaws.com/myblogcomments-websites3-1ttpk69ph7gr7/lambda-comments.js
249 | ```
250 |
251 | In the target web page (perhaps a blog generated by a static site generator
252 | such as Jekyll or Hugo), add the following HTML to insert the comments from
253 | the development server into the page:
254 |
255 | ```
256 |
257 |
258 | ```
259 |
260 | Providing that the webpage is located at the web address matching
261 | the 'BLOG' setting in the .env configuration file, the comments form
262 | should appear on the page. If not, check the developer tools console
263 | in the web browser to see if there are any errors (typically due to
264 | CORS).
265 |
266 | ## Front-end development
267 |
268 | The code for the "front-end" javascript that displays the comments and the
269 | comment form embedded in a web page lives in the `packages/frontend`
270 | directory.
271 |
272 | To run a development server, change into the `packages/frontend` directory,
273 | copy .env.SAMPLE to .env, and run the development server.
274 |
275 | ```
276 | cd packages/frontend
277 | cp .env.SAMPLE .env
278 | npm start
279 | ```
280 |
281 | The development server is based on [react-project](https://github.com/ryanflorence/react-project)
282 | with a heavily modified webpack configuration in `webpack.config.js`.
283 |
284 | In the target web page (perhaps a blog generated by a static site generator
285 | such as Jekyll or Hugo), add the following HTML to insert the comments from
286 | the development server into the page:
287 |
288 | ```
289 |
290 |
291 | ```
292 |
293 | # To Do List
294 |
295 | * Limit length of comments and metadata
296 | * Simplified installation
297 | * Check that permalink and blog match
298 | * Override for path location
299 | * Fetch source page to confirm script is installed on first post
300 | * Test on various browsers, polyfills
301 | * CORS override
302 | * Rearrange code: put lambda scripts under packages directory
303 | * Admin: auth
304 | * Admin: moderation
305 | * Admin: submit ham/spam to akismet
306 | * Admin: Turn comments on/off
307 | * Support for editing blog posts for a limited time
308 | * Detect DDoS style attacks and automatically throttle
309 | API Gateway to prevent unlimited charges
310 | * Mocks for AWS/API calls
311 | * Integration test
312 | * Selenium tests
313 | * Coverage
314 | * Emoji support
315 | * Handle DynamoDB ProvisionedThroughputExceededException
316 | * Investigate Swagger
317 | * Generate API docs
318 | * Webpack 2 tree-shaking support
319 | * Optimize download size
320 | * Plugins for server-side rendering on common static site generators
321 | * Optimized bundle for ES6-capable platforms
322 | * Library for bundling with existing client-side javascript builds
323 | * Investigate deep integration with React.js static site generators
324 | * [Gatsby](https://github.com/gatsbyjs/gatsby)
325 | * [Phenomic](https://github.com/MoOx/phenomic)
326 | * Instructions for static-ish hosting platforms
327 | * [GitHub Pages](https://pages.github.com/)
328 | * [Surge](http://surge.sh/)
329 | * [Netlify](https://www.netlify.com/)
330 | * [Aerobatic](https://www.aerobatic.com/)
331 | * [Firebase](https://www.firebase.com/)
332 | * [Zeit](https://zeit.co/)
333 |
334 | # Interesting links
335 |
336 | Lots of related projects, many of which I haven't investigated yet.
337 |
338 | ## Lambda / Serverless Frameworks
339 | * http://apex.run/ (Go, Terraform - we use Apex, but just for convenience to upload the functions)
340 | * https://github.com/serverless/serverless (CloudFormation)
341 | * https://github.com/motdotla/node-lambda
342 | * [Azure Cloud Functions vs. AWS Lambda](https://serifandsemaphore.io/azure-cloud-functions-vs-aws-lambda-caf8a90605dd#.qtdnojr54)
343 |
344 | ## Lambda Libraries
345 |
346 | * https://github.com/smallwins/lambda
347 | * https://github.com/vandium-io/vandium-node
348 |
349 | ## Awesome Lists
350 |
351 | * https://github.com/donnemartin/awesome-aws#lambda
352 | * https://github.com/donnemartin/awesome-aws#api-gateway
353 |
354 | ## Serverless Comment Systems
355 |
356 | * http://kevinold.com/2016/02/01/serverless-graphql.html
357 | * https://github.com/serverless/serverless-graphql-blog
358 | * https://github.com/ummels/jekyll-aws-comments
359 |
360 | ## Open-source Comment Systems
361 |
362 | * https://posativ.org/isso/
363 |
364 | ## Hosted Comment Platforms
365 |
366 | * https://disqus.com/
367 | * http://web.livefyre.com/comments/
368 |
--------------------------------------------------------------------------------
/deploy/cloudformation/lambda-comments.json:
--------------------------------------------------------------------------------
1 | {
2 | "AWSTemplateFormatVersion": "2010-09-09",
3 | "Description": "API Gateway + Lambda + S3 Blog Commenting System",
4 | "Parameters": {
5 | "TagName": {
6 | "Description": "Tag for resources",
7 | "Type": "String"
8 | },
9 | "Origin": {
10 | "Description": "Origin URL for CORS",
11 | "Type": "String"
12 | },
13 | "NewResources": {
14 | "Description": "Include new resources (still being developed)",
15 | "Default": "false",
16 | "Type": "String",
17 | "AllowedValues" : ["true", "false"]
18 | }
19 | },
20 | "Conditions" : {
21 | "CreateNewResources" : {"Fn::Equals" : [{"Ref" : "NewResources"}, "true"]}
22 | },
23 | "Resources": {
24 | "LambdaRole": {
25 | "Type": "AWS::IAM::Role",
26 | "Properties": {
27 | "AssumeRolePolicyDocument": {
28 | "Statement": [
29 | {
30 | "Effect": "Allow",
31 | "Principal": {
32 | "Service": [
33 | "lambda.amazonaws.com"
34 | ]
35 | },
36 | "Action": [
37 | "sts:AssumeRole"
38 | ]
39 | }
40 | ]
41 | },
42 | "Path": "/"
43 | }
44 | },
45 | "LambdaRoleInstanceProfile": {
46 | "Type": "AWS::IAM::InstanceProfile",
47 | "Properties": {
48 | "Path": "/",
49 | "Roles": [
50 | {
51 | "Ref": "LambdaRole"
52 | }
53 | ]
54 | }
55 | },
56 | "LambdaCommentsCombinedPolicy": {
57 | "Type": "AWS::IAM::Policy",
58 | "Properties": {
59 | "PolicyName": "LambdaCommentsCombined",
60 | "PolicyDocument": {
61 | "Statement": [
62 | {
63 | "Action": [
64 | "logs:CreateLogGroup",
65 | "logs:CreateLogStream",
66 | "logs:PutLogEvents"
67 | ],
68 | "Effect": "Allow",
69 | "Resource": "arn:aws:logs:*:*:*"
70 | },
71 | {
72 | "Effect": "Allow",
73 | "Action": "s3:*",
74 | "Resource": {
75 | "Fn::Join": [
76 | "",
77 | [
78 | "arn:aws:s3:::",
79 | { "Ref": "WebsiteS3" },
80 | "/*"
81 | ]
82 | ]
83 | }
84 | },
85 | {
86 | "Effect": "Allow",
87 | "Action": "s3:*",
88 | "Resource": {
89 | "Fn::Join": [
90 | "",
91 | [
92 | "arn:aws:s3:::",
93 | { "Ref": "PrivateS3" },
94 | "/*"
95 | ]
96 | ]
97 | }
98 | },
99 | {
100 | "Effect": "Allow",
101 | "Action": "dynamodb:*",
102 | "Resource": {
103 | "Fn::Join": [
104 | "",
105 | [
106 | "arn:aws:dynamodb:",
107 | { "Ref": "AWS::Region" },
108 | ":",
109 | { "Ref": "AWS::AccountId" },
110 | ":table/",
111 | { "Ref" : "JobStreamDynamoDBTable" }
112 | ]
113 | ]
114 | }
115 | },
116 | {
117 | "Effect": "Allow",
118 | "Action": [
119 | "dynamodb:GetRecords",
120 | "dynamodb:GetShardIterator",
121 | "dynamodb:DescribeStream",
122 | "dynamodb:ListStreams"
123 | ],
124 | "Resource": { "Fn::GetAtt": [ "JobStreamDynamoDBTable", "StreamArn" ] }
125 | }
126 | ]
127 | },
128 | "Roles": [
129 | {
130 | "Ref": "LambdaRole"
131 | }
132 | ]
133 | }
134 | },
135 | "QueueCommentLambdaFunction": {
136 | "Type": "AWS::Lambda::Function",
137 | "Properties": {
138 | "Code": {
139 | "ZipFile": "exports.handler = function(event, context) { context.fail('Not Implemented'); };"
140 | },
141 | "Description": "QueueComment",
142 | "Handler": "lib/index.handler",
143 | "MemorySize": 128,
144 | "Role": { "Fn::GetAtt": [ "LambdaRole", "Arn" ] },
145 | "Runtime": "nodejs",
146 | "Timeout": 30
147 | }
148 | },
149 | "QueueCommentLambdaInvokePermission": {
150 | "Type": "AWS::Lambda::Permission",
151 | "Properties": {
152 | "FunctionName" : { "Fn::GetAtt" : [ "QueueCommentLambdaFunction", "Arn" ] },
153 | "Action": "lambda:InvokeFunction",
154 | "Principal": "apigateway.amazonaws.com",
155 | "SourceArn": {
156 | "Fn::Join": [
157 | "",
158 | [
159 | "arn:aws:execute-api:",
160 | { "Ref" : "AWS::Region" },
161 | ":",
162 | { "Ref" : "AWS::AccountId" },
163 | ":",
164 | { "Ref" : "RestApi" },
165 | "/*/POST/comments"
166 | ]
167 | ]
168 | }
169 | }
170 | },
171 | "WorkerLambdaFunction": {
172 | "Type": "AWS::Lambda::Function",
173 | "Properties": {
174 | "Code": {
175 | "ZipFile": "exports.handler = function(event, context) { context.fail('Not Implemented'); };"
176 | },
177 | "Description": "Worker",
178 | "Handler": "lib/index.handler",
179 | "MemorySize": 128,
180 | "Role": { "Fn::GetAtt": [ "LambdaRole", "Arn" ] },
181 | "Runtime": "nodejs",
182 | "Timeout": 30
183 | }
184 | },
185 | "WorkerLambdaEventSourceMapping": {
186 | "Type": "AWS::Lambda::EventSourceMapping",
187 | "Properties": {
188 | "BatchSize": 20,
189 | "Enabled": true,
190 | "EventSourceArn": { "Fn::GetAtt": [ "JobStreamDynamoDBTable", "StreamArn" ] },
191 | "FunctionName": { "Ref": "WorkerLambdaFunction" },
192 | "StartingPosition": "LATEST"
193 | },
194 | "DependsOn": [
195 | "JobStreamDynamoDBTable",
196 | "LambdaCommentsCombinedPolicy",
197 | "LambdaRoleInstanceProfile",
198 | "LambdaRole"
199 | ]
200 | },
201 | "ApiDeployment": {
202 | "Type": "AWS::ApiGateway::Deployment",
203 | "Properties": {
204 | "RestApiId": { "Ref": "RestApi" },
205 | "Description": "Production deployment",
206 | "StageName": "prod"
207 | },
208 | "DependsOn": [
209 | "RestApi",
210 | "CommentsApiResource",
211 | "PostCommentApiMethod",
212 | "OptionsCommentApiMethod"
213 | ]
214 | },
215 | "RestApi": {
216 | "Type": "AWS::ApiGateway::RestApi",
217 | "Properties": {
218 | "Description": "API for blog comments",
219 | "Name": {
220 | "Fn::Join": [
221 | "",
222 | [
223 | "Comments-",
224 | { "Ref": "AWS::StackName" }
225 | ]
226 | ]
227 | }
228 | }
229 | },
230 | "CommentsApiResource": {
231 | "Type" : "AWS::ApiGateway::Resource",
232 | "Properties": {
233 | "RestApiId": { "Ref": "RestApi" },
234 | "ParentId": { "Fn::GetAtt": ["RestApi", "RootResourceId"] },
235 | "PathPart": "comments"
236 | }
237 | },
238 | "PostCommentApiMethod": {
239 | "Type": "AWS::ApiGateway::Method",
240 | "Properties": {
241 | "RestApiId": { "Ref": "RestApi" },
242 | "ResourceId": { "Ref": "CommentsApiResource" },
243 | "HttpMethod": "POST",
244 | "AuthorizationType": "NONE",
245 | "Integration": {
246 | "Type": "AWS",
247 | "IntegrationHttpMethod": "POST",
248 | "Uri": {
249 | "Fn::Join": [
250 | "",
251 | [
252 | "arn:aws:apigateway:",
253 | { "Ref" : "AWS::Region" },
254 | ":lambda:path/2015-03-31/functions/",
255 | { "Fn::GetAtt" : [ "QueueCommentLambdaFunction", "Arn" ] },
256 | "/invocations"
257 | ]
258 | ]
259 | },
260 | "RequestTemplates": {
261 | "application/json": "{ \"sourceIp\": \"$context.identity.sourceIp\", \"fields\": $input.json('$') }"
262 | },
263 | "IntegrationResponses": [
264 | {
265 | "StatusCode": "201",
266 | "ResponseParameters" : {
267 | "method.response.header.Access-Control-Allow-Origin": {
268 | "Fn::Join": [
269 | "",
270 | [
271 | "'",
272 | { "Ref" : "Origin" },
273 | "'"
274 | ]
275 | ]
276 | }
277 | }
278 | },
279 | {
280 | "StatusCode": "400",
281 | "ResponseParameters" : {
282 | "method.response.header.Access-Control-Allow-Origin": {
283 | "Fn::Join": [
284 | "",
285 | [
286 | "'",
287 | { "Ref" : "Origin" },
288 | "'"
289 | ]
290 | ]
291 | }
292 | },
293 | "SelectionPattern": ".*Error.*"
294 | }
295 | ]
296 | },
297 | "MethodResponses": [
298 | {
299 | "StatusCode": "201",
300 | "ResponseParameters" : {
301 | "method.response.header.Access-Control-Allow-Origin": true
302 | }
303 | },
304 | {
305 | "StatusCode": "400",
306 | "ResponseParameters" : {
307 | "method.response.header.Access-Control-Allow-Origin": true
308 | }
309 | }
310 | ]
311 | }
312 | },
313 | "OptionsCommentApiMethod": {
314 | "Type": "AWS::ApiGateway::Method",
315 | "Properties": {
316 | "RestApiId": { "Ref": "RestApi" },
317 | "ResourceId": { "Ref": "CommentsApiResource" },
318 | "HttpMethod": "OPTIONS",
319 | "AuthorizationType": "NONE",
320 | "Integration": {
321 | "Type": "MOCK",
322 | "RequestTemplates": {
323 | "application/json": "{ \"statusCode\": 200 }"
324 | },
325 | "IntegrationResponses": [
326 | {
327 | "StatusCode": "200",
328 | "ResponseParameters" : {
329 | "method.response.header.Access-Control-Allow-Headers": "'Content-Type,X-Amz-Date,Authorization,X-Api-Key'",
330 | "method.response.header.Access-Control-Allow-Methods": "'POST,OPTIONS'",
331 | "method.response.header.Access-Control-Allow-Origin": {
332 | "Fn::Join": [
333 | "",
334 | [
335 | "'",
336 | { "Ref" : "Origin" },
337 | "'"
338 | ]
339 | ]
340 | }
341 | }
342 | }
343 | ]
344 | },
345 | "MethodResponses": [
346 | {
347 | "StatusCode": "200",
348 | "ResponseParameters" : {
349 | "method.response.header.Access-Control-Allow-Headers": true,
350 | "method.response.header.Access-Control-Allow-Methods": true,
351 | "method.response.header.Access-Control-Allow-Origin": true
352 | }
353 | }
354 | ]
355 | }
356 | },
357 | "WebsiteS3": {
358 | "Type": "AWS::S3::Bucket",
359 | "Properties": {
360 | "CorsConfiguration": {
361 | "CorsRules": [
362 | {
363 | "AllowedOrigins": [ { "Ref" : "Origin" } ],
364 | "AllowedMethods": [ "GET" ],
365 | "MaxAge": 3000,
366 | "AllowedHeaders": [ "Authorization" ]
367 | }
368 | ]
369 | },
370 | "WebsiteConfiguration": {
371 | "IndexDocument": "index.html"
372 | }
373 | }
374 | },
375 | "WebsiteS3BucketPolicy": {
376 | "Type": "AWS::S3::BucketPolicy",
377 | "Properties": {
378 | "Bucket": { "Ref": "WebsiteS3" },
379 | "PolicyDocument": {
380 | "Statement": [
381 | {
382 | "Effect": "Allow",
383 | "Action": [ "s3:*" ],
384 | "Principal": {
385 | "AWS": { "Fn::GetAtt" : [ "LambdaRole" , "Arn" ] }
386 | },
387 | "Resource": [
388 | {
389 | "Fn::Join": [
390 | "",
391 | [
392 | "arn:aws:s3:::",
393 | { "Ref": "WebsiteS3" },
394 | "/*"
395 | ]
396 | ]
397 | }
398 | ]
399 | },
400 | {
401 | "Effect": "Allow",
402 | "Action": [ "s3:GetObject" ],
403 | "Principal": "*",
404 | "Resource": [
405 | {
406 | "Fn::Join": [
407 | "",
408 | [
409 | "arn:aws:s3:::",
410 | { "Ref": "WebsiteS3" },
411 | "/*"
412 | ]
413 | ]
414 | }
415 | ]
416 | }
417 | ]
418 | }
419 | }
420 | },
421 | "PrivateS3": {
422 | "Type": "AWS::S3::Bucket"
423 | },
424 | "PrivateS3BucketPolicy": {
425 | "Type": "AWS::S3::BucketPolicy",
426 | "Properties": {
427 | "Bucket": {
428 | "Ref": "PrivateS3"
429 | },
430 | "PolicyDocument": {
431 | "Statement": [
432 | {
433 | "Effect": "Allow",
434 | "Action": [ "s3:*" ],
435 | "Principal": {
436 | "AWS": { "Fn::GetAtt" : [ "LambdaRole" , "Arn" ] }
437 | },
438 | "Resource": [
439 | {
440 | "Fn::Join": [
441 | "",
442 | [
443 | "arn:aws:s3:::",
444 | { "Ref": "PrivateS3" },
445 | "/*"
446 | ]
447 | ]
448 | }
449 | ]
450 | }
451 | ]
452 | }
453 | }
454 | },
455 | "JobStreamDynamoDBTable" : {
456 | "Type" : "AWS::DynamoDB::Table",
457 | "Properties" : {
458 | "AttributeDefinitions" : [
459 | {
460 | "AttributeName" : "id",
461 | "AttributeType" : "S"
462 | }
463 | ],
464 | "KeySchema" : [
465 | {
466 | "AttributeName" : "id",
467 | "KeyType" : "HASH"
468 | }
469 | ],
470 | "ProvisionedThroughput" : {
471 | "ReadCapacityUnits" : "1",
472 | "WriteCapacityUnits" : "1"
473 | },
474 | "StreamSpecification": {
475 | "StreamViewType": "NEW_IMAGE"
476 | }
477 | }
478 | }
479 | },
480 | "Outputs": {
481 | "LambdaRoleArn": {
482 | "Value": { "Fn::GetAtt": [ "LambdaRole", "Arn" ] },
483 | "Description": "ARN for LambdaRole"
484 | }
485 | }
486 | }
487 |
--------------------------------------------------------------------------------
/packages/frontend/static/es5-shim.min.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * https://github.com/es-shims/es5-shim
3 | * @license es5-shim Copyright 2009-2015 by contributors, MIT License
4 | * see https://github.com/es-shims/es5-shim/blob/v4.5.5/LICENSE
5 | */
6 | (function(t,r){"use strict";if(typeof define==="function"&&define.amd){define(r)}else if(typeof exports==="object"){module.exports=r()}else{t.returnExports=r()}})(this,function(){var t=Array;var r=t.prototype;var e=Object;var n=e.prototype;var i=Function;var a=i.prototype;var o=String;var u=o.prototype;var f=Number;var s=f.prototype;var l=r.slice;var c=r.splice;var v=r.push;var h=r.unshift;var p=r.concat;var g=r.join;var y=a.call;var d=a.apply;var w=Math.max;var b=Math.min;var m=n.toString;var T=typeof Symbol==="function"&&typeof Symbol.toStringTag==="symbol";var D;var x=Function.prototype.toString,S=/\s*class /,O=function isES6ClassFn(t){try{var r=x.call(t);var e=r.replace(/\/\/.*\n/g,"");var n=e.replace(/\/\*[.\s\S]*\*\//g,"");var i=n.replace(/\n/gm," ").replace(/ {2}/g," ");return S.test(i)}catch(a){return false}},E=function tryFunctionObject(t){try{if(O(t)){return false}x.call(t);return true}catch(r){return false}},j="[object Function]",I="[object GeneratorFunction]",D=function isCallable(t){if(!t){return false}if(typeof t!=="function"&&typeof t!=="object"){return false}if(T){return E(t)}if(O(t)){return false}var r=m.call(t);return r===j||r===I};var M;var U=RegExp.prototype.exec,F=function tryRegexExec(t){try{U.call(t);return true}catch(r){return false}},N="[object RegExp]";M=function isRegex(t){if(typeof t!=="object"){return false}return T?F(t):m.call(t)===N};var C;var k=String.prototype.valueOf,R=function tryStringObject(t){try{k.call(t);return true}catch(r){return false}},A="[object String]";C=function isString(t){if(typeof t==="string"){return true}if(typeof t!=="object"){return false}return T?R(t):m.call(t)===A};var P=e.defineProperty&&function(){try{var t={};e.defineProperty(t,"x",{enumerable:false,value:t});for(var r in t){return false}return t.x===t}catch(n){return false}}();var $=function(t){var r;if(P){r=function(t,r,n,i){if(!i&&r in t){return}e.defineProperty(t,r,{configurable:true,enumerable:false,writable:true,value:n})}}else{r=function(t,r,e,n){if(!n&&r in t){return}t[r]=e}}return function defineProperties(e,n,i){for(var a in n){if(t.call(n,a)){r(e,a,n[a],i)}}}}(n.hasOwnProperty);var J=function isPrimitive(t){var r=typeof t;return t===null||r!=="object"&&r!=="function"};var Y=f.isNaN||function(t){return t!==t};var Z={ToInteger:function ToInteger(t){var r=+t;if(Y(r)){r=0}else if(r!==0&&r!==1/0&&r!==-(1/0)){r=(r>0||-1)*Math.floor(Math.abs(r))}return r},ToPrimitive:function ToPrimitive(t){var r,e,n;if(J(t)){return t}e=t.valueOf;if(D(e)){r=e.call(t);if(J(r)){return r}}n=t.toString;if(D(n)){r=n.call(t);if(J(r)){return r}}throw new TypeError},ToObject:function(t){if(t==null){throw new TypeError("can't convert "+t+" to object")}return e(t)},ToUint32:function ToUint32(t){return t>>>0}};var z=function Empty(){};$(a,{bind:function bind(t){var r=this;if(!D(r)){throw new TypeError("Function.prototype.bind called on incompatible "+r)}var n=l.call(arguments,1);var a;var o=function(){if(this instanceof a){var i=d.call(r,this,p.call(n,l.call(arguments)));if(e(i)===i){return i}return this}else{return d.call(r,t,p.call(n,l.call(arguments)))}};var u=w(0,r.length-n.length);var f=[];for(var s=0;s1){a=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.forEach callback must be a function")}while(++n1){o=arguments[1]}if(!D(r)){throw new TypeError("Array.prototype.map callback must be a function")}for(var u=0;u1){o=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.filter callback must be a function")}for(var u=0;u1){i=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.every callback must be a function")}for(var a=0;a1){i=arguments[1]}if(!D(t)){throw new TypeError("Array.prototype.some callback must be a function")}for(var a=0;a=2){a=arguments[1]}else{do{if(i in e){a=e[i++];break}if(++i>=n){throw new TypeError("reduce of empty array with no initial value")}}while(true)}for(;i=2){i=arguments[1]}else{do{if(a in e){i=e[a--];break}if(--a<0){throw new TypeError("reduceRight of empty array with no initial value")}}while(true)}if(a<0){return i}do{if(a in e){i=t(i,e[a],a,r)}}while(a--);return i}},!at);var ot=r.indexOf&&[0,1].indexOf(1,2)!==-1;$(r,{indexOf:function indexOf(t){var r=et&&C(this)?X(this,""):Z.ToObject(this);var e=Z.ToUint32(r.length);if(e===0){return-1}var n=0;if(arguments.length>1){n=Z.ToInteger(arguments[1])}n=n>=0?n:w(0,e+n);for(;n1){n=b(n,Z.ToInteger(arguments[1]))}n=n>=0?n:e-Math.abs(n);for(;n>=0;n--){if(n in r&&t===r[n]){return n}}return-1}},ut);var ft=function(){var t=[1,2];var r=t.splice();return t.length===2&&_(r)&&r.length===0}();$(r,{splice:function splice(t,r){if(arguments.length===0){return[]}else{return c.apply(this,arguments)}}},!ft);var st=function(){var t={};r.splice.call(t,0,0,1);return t.length===1}();$(r,{splice:function splice(t,r){if(arguments.length===0){return[]}var e=arguments;this.length=w(Z.ToInteger(this.length),0);if(arguments.length>0&&typeof r!=="number"){e=H(arguments);if(e.length<2){K(e,this.length-t)}else{e[1]=Z.ToInteger(r)}}return c.apply(this,e)}},!st);var lt=function(){var r=new t(1e5);r[8]="x";r.splice(1,1);return r.indexOf("x")===7}();var ct=function(){var t=256;var r=[];r[t]="a";r.splice(t+1,0,"b");return r[t]==="a"}();$(r,{splice:function splice(t,r){var e=Z.ToObject(this);var n=[];var i=Z.ToUint32(e.length);var a=Z.ToInteger(t);var u=a<0?w(i+a,0):b(a,i);var f=b(w(Z.ToInteger(r),0),i-u);var s=0;var l;while(sg){delete e[s-1];s-=1}}else if(v>f){s=i-f;while(s>u){l=o(s+f-1);h=o(s+v-1);if(G(e,l)){e[h]=e[l]}else{delete e[h]}s-=1}}s=u;for(var y=0;y=0&&!_(t)&&D(t.callee)};var Ct=Ft(arguments)?Ft:Nt;$(e,{keys:function keys(t){var r=D(t);var e=Ct(t);var n=t!==null&&typeof t==="object";var i=n&&C(t);if(!n&&!r&&!e){throw new TypeError("Object.keys called on a non-object")}var a=[];var u=xt&&r;if(i&&St||e){for(var f=0;f11){return t+1}return t},getMonth:function getMonth(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Bt(this);var r=Ht(this);if(t<0&&r>11){return 0}return r},getDate:function getDate(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Bt(this);var r=Ht(this);var e=Wt(this);if(t<0&&r>11){if(r===12){return e}var n=nr(0,t+1);return n-e+1}return e},getUTCFullYear:function getUTCFullYear(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);if(t<0&&Xt(this)>11){return t+1}return t},getUTCMonth:function getUTCMonth(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);var r=Xt(this);if(t<0&&r>11){return 0}return r},getUTCDate:function getUTCDate(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Lt(this);var r=Xt(this);var e=qt(this);if(t<0&&r>11){if(r===12){return e}var n=nr(0,t+1);return n-e+1}return e}},Pt);$(Date.prototype,{toUTCString:function toUTCString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=Kt(this);var r=qt(this);var e=Xt(this);var n=Lt(this);var i=Qt(this);var a=Vt(this);var o=_t(this);return rr[t]+", "+(r<10?"0"+r:r)+" "+er[e]+" "+n+" "+(i<10?"0"+i:i)+":"+(a<10?"0"+a:a)+":"+(o<10?"0"+o:o)+" GMT"}},Pt||Yt);$(Date.prototype,{toDateString:function toDateString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=this.getDay();var r=this.getDate();var e=this.getMonth();var n=this.getFullYear();return rr[t]+" "+er[e]+" "+(r<10?"0"+r:r)+" "+n}},Pt||Zt);if(Pt||zt){Date.prototype.toString=function toString(){if(!this||!(this instanceof Date)){throw new TypeError("this is not a Date object.")}var t=this.getDay();var r=this.getDate();var e=this.getMonth();var n=this.getFullYear();var i=this.getHours();var a=this.getMinutes();var o=this.getSeconds();var u=this.getTimezoneOffset();var f=Math.floor(Math.abs(u)/60);var s=Math.floor(Math.abs(u)%60);return rr[t]+" "+er[e]+" "+(r<10?"0"+r:r)+" "+n+" "+(i<10?"0"+i:i)+":"+(a<10?"0"+a:a)+":"+(o<10?"0"+o:o)+" GMT"+(u>0?"-":"+")+(f<10?"0"+f:f)+(s<10?"0"+s:s)};if(P){e.defineProperty(Date.prototype,"toString",{configurable:true,enumerable:false,writable:true})}}var ir=-621987552e5;var ar="-000001";var or=Date.prototype.toISOString&&new Date(ir).toISOString().indexOf(ar)===-1;var ur=Date.prototype.toISOString&&new Date(-1).toISOString()!=="1969-12-31T23:59:59.999Z";var fr=y.bind(Date.prototype.getTime);$(Date.prototype,{toISOString:function toISOString(){if(!isFinite(this)||!isFinite(fr(this))){throw new RangeError("Date.prototype.toISOString called on non-finite value.")}var t=Lt(this);var r=Xt(this);t+=Math.floor(r/12);r=(r%12+12)%12;var e=[r+1,qt(this),Qt(this),Vt(this),_t(this)];t=(t<0?"-":t>9999?"+":"")+L("00000"+Math.abs(t),0<=t&&t<=9999?-4:-6);for(var n=0;n=7&&s>hr){var p=Math.floor(s/hr)*hr;var g=Math.floor(p/1e3);v+=g;h-=g*1e3}c=l===1&&o(e)===e?new t(r.parse(e)):l>=7?new t(e,n,i,a,u,v,h):l>=6?new t(e,n,i,a,u,v):l>=5?new t(e,n,i,a,u):l>=4?new t(e,n,i,a):l>=3?new t(e,n,i):l>=2?new t(e,n):l>=1?new t(e):new t}else{c=t.apply(this,arguments)}if(!J(c)){$(c,{constructor:r},true)}return c};var e=new RegExp("^"+"(\\d{4}|[+-]\\d{6})"+"(?:-(\\d{2})"+"(?:-(\\d{2})"+"(?:"+"T(\\d{2})"+":(\\d{2})"+"(?:"+":(\\d{2})"+"(?:(\\.\\d{1,}))?"+")?"+"("+"Z|"+"(?:"+"([-+])"+"(\\d{2})"+":(\\d{2})"+")"+")?)?)?)?"+"$");var n=[0,31,59,90,120,151,181,212,243,273,304,334,365];var i=function dayFromMonth(t,r){var e=r>1?1:0;return n[r]+Math.floor((t-1969+e)/4)-Math.floor((t-1901+e)/100)+Math.floor((t-1601+e)/400)+365*(t-1970)};var a=function toUTC(r){var e=0;var n=r;if(pr&&n>hr){var i=Math.floor(n/hr)*hr;var a=Math.floor(i/1e3);e+=a;n-=a*1e3}return f(new t(1970,0,1,0,0,e,n))};for(var u in t){if(G(t,u)){r[u]=t[u]}}$(r,{now:t.now,UTC:t.UTC},true);r.prototype=t.prototype;$(r.prototype,{constructor:r},true);var s=function parse(r){var n=e.exec(r);if(n){var o=f(n[1]),u=f(n[2]||1)-1,s=f(n[3]||1)-1,l=f(n[4]||0),c=f(n[5]||0),v=f(n[6]||0),h=Math.floor(f(n[7]||0)*1e3),p=Boolean(n[4]&&!n[8]),g=n[9]==="-"?1:-1,y=f(n[10]||0),d=f(n[11]||0),w;var b=c>0||v>0||h>0;if(l<(b?24:25)&&c<60&&v<60&&h<1e3&&u>-1&&u<12&&y<24&&d<60&&s>-1&&s=0){e+=yr.data[r];yr.data[r]=Math.floor(e/t);e=e%t*yr.base}},numToString:function numToString(){var t=yr.size;var r="";while(--t>=0){if(r!==""||t===0||yr.data[t]!==0){var e=o(yr.data[t]);if(r===""){r=e}else{r+=L("0000000",0,7-e.length)+e}}}return r},pow:function pow(t,r,e){return r===0?e:r%2===1?pow(t,r-1,e*t):pow(t*t,r/2,e)},log:function log(t){var r=0;var e=t;while(e>=4096){r+=12;e/=4096}while(e>=2){r+=1;e/=2}return r}};var dr=function toFixed(t){var r,e,n,i,a,u,s,l;r=f(t);r=Y(r)?0:Math.floor(r);if(r<0||r>20){throw new RangeError("Number.toFixed called with invalid number of decimals")}e=f(this);if(Y(e)){return"NaN"}if(e<=-1e21||e>=1e21){return o(e)}n="";if(e<0){n="-";e=-e}i="0";if(e>1e-21){a=yr.log(e*yr.pow(2,69,1))-69;u=a<0?e*yr.pow(2,-a,1):e/yr.pow(2,a,1);u*=4503599627370496;a=52-a;if(a>0){yr.multiply(0,u);s=r;while(s>=7){yr.multiply(1e7,0);s-=7}yr.multiply(yr.pow(10,s,1),0);s=a-1;while(s>=23){yr.divide(1<<23);s-=23}yr.divide(1<0){l=i.length;if(l<=r){i=n+L("0.0000000000000000000",0,r-l+2)+i}else{i=n+L(i,0,l-r)+"."+L(i,l-r)}}else{i=n+i}return i};$(s,{toFixed:dr},gr);var wr=function(){try{return 1..toPrecision(undefined)==="1"}catch(t){return true}}();var br=s.toPrecision;$(s,{toPrecision:function toPrecision(t){return typeof t==="undefined"?br.call(this):br.call(this,t)}},wr);if("ab".split(/(?:ab)*/).length!==2||".".split(/(.?)(.?)/).length!==4||"tesst".split(/(s)*/)[1]==="t"||"test".split(/(?:)/,-1).length!==4||"".split(/.?/).length||".".split(/()()/).length>1){(function(){var t=typeof/()??/.exec("")[1]==="undefined";var r=Math.pow(2,32)-1;u.split=function(e,n){var i=String(this);if(typeof e==="undefined"&&n===0){return[]}if(!M(e)){return X(this,e,n)}var a=[];var o=(e.ignoreCase?"i":"")+(e.multiline?"m":"")+(e.unicode?"u":"")+(e.sticky?"y":""),u=0,f,s,l,c;var h=new RegExp(e.source,o+"g");if(!t){f=new RegExp("^"+h.source+"$(?!\\s)",o)}var p=typeof n==="undefined"?r:Z.ToUint32(n);s=h.exec(i);while(s){l=s.index+s[0].length;if(l>u){K(a,L(i,u,s.index));if(!t&&s.length>1){s[0].replace(f,function(){for(var t=1;t1&&s.index=p){break}}if(h.lastIndex===s.index){h.lastIndex++}s=h.exec(i)}if(u===i.length){if(c||!h.test("")){K(a,"")}}else{K(a,L(i,u))}return a.length>p?H(a,0,p):a}})()}else if("0".split(void 0,0).length){u.split=function split(t,r){if(typeof t==="undefined"&&r===0){return[]}return X(this,t,r)}}var mr=u.replace;var Tr=function(){var t=[];"x".replace(/x(.)?/g,function(r,e){K(t,e)});return t.length===1&&typeof t[0]==="undefined"}();if(!Tr){u.replace=function replace(t,r){var e=D(r);var n=M(t)&&/\)[*?]/.test(t.source);if(!e||!n){return mr.call(this,t,r)}else{var i=function(e){var n=arguments.length;var i=t.lastIndex;t.lastIndex=0;var a=t.exec(e)||[];t.lastIndex=i;K(a,arguments[n-2],arguments[n-1]);return r.apply(this,a)};return mr.call(this,t,i)}}}var Dr=u.substr;var xr="".substr&&"0b".substr(-1)!=="b";$(u,{substr:function substr(t,r){var e=t;if(t<0){e=w(this.length+t,0)}return Dr.call(this,e,r)}},xr);var Sr=" \n\x0B\f\r \xa0\u1680\u180e\u2000\u2001\u2002\u2003"+"\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028"+"\u2029\ufeff";var Or="\u200b";var Er="["+Sr+"]";var jr=new RegExp("^"+Er+Er+"*");var Ir=new RegExp(Er+Er+"*$");var Mr=u.trim&&(Sr.trim()||!Or.trim());$(u,{trim:function trim(){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}return o(this).replace(jr,"").replace(Ir,"")}},Mr);var Ur=y.bind(String.prototype.trim);var Fr=u.lastIndexOf&&"abc\u3042\u3044".lastIndexOf("\u3042\u3044",2)!==-1;$(u,{lastIndexOf:function lastIndexOf(t){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}var r=o(this);var e=o(t);var n=arguments.length>1?f(arguments[1]):NaN;var i=Y(n)?Infinity:Z.ToInteger(n);var a=b(w(i,0),r.length);var u=e.length;var s=a+u;while(s>0){s=w(0,s-u);var l=q(L(r,s,a+u),e);if(l!==-1){return s+l}}return-1}},Fr);var Nr=u.lastIndexOf;$(u,{lastIndexOf:function lastIndexOf(t){return Nr.apply(this,arguments)}},u.lastIndexOf.length!==1);if(parseInt(Sr+"08")!==8||parseInt(Sr+"0x16")!==22){parseInt=function(t){var r=/^[\-+]?0[xX]/;return function parseInt(e,n){var i=Ur(e);var a=f(n)||(r.test(i)?16:10);return t(i,a)}}(parseInt)}if(1/parseFloat("-0")!==-Infinity){parseFloat=function(t){return function parseFloat(r){var e=Ur(r);var n=t(e);return n===0&&L(e,0,1)==="-"?-0:n}}(parseFloat)}if(String(new RangeError("test"))!=="RangeError: test"){var Cr=function toString(){if(typeof this==="undefined"||this===null){throw new TypeError("can't convert "+this+" to object")}var t=this.name;if(typeof t==="undefined"){t="Error"}else if(typeof t!=="string"){t=o(t)}var r=this.message;if(typeof r==="undefined"){r=""}else if(typeof r!=="string"){r=o(r)}if(!t){return r}if(!r){return t}return t+": "+r};Error.prototype.toString=Cr}if(P){var kr=function(t,r){if(Q(t,r)){var e=Object.getOwnPropertyDescriptor(t,r);e.enumerable=false;Object.defineProperty(t,r,e)}};kr(Error.prototype,"message");if(Error.prototype.message!==""){Error.prototype.message=""}kr(Error.prototype,"name")}if(String(/a/gim)!=="/a/gim"){var Rr=function toString(){var t="/"+this.source+"/";if(this.global){t+="g"}if(this.ignoreCase){t+="i"}if(this.multiline){t+="m"}return t};RegExp.prototype.toString=Rr}});
7 |
--------------------------------------------------------------------------------