├── .babelrc ├── .gitignore ├── .sequelizerc ├── HEROKU.md ├── LICENSE ├── README.md ├── api ├── auth.js ├── controllers │ ├── comment.js │ ├── post.js │ ├── queries.js │ └── user.js ├── index.js ├── migrations │ ├── 20170412220603-create-user.js │ ├── 20170412220728-create-post.js │ └── 20170412235342-create-comment.js └── models │ ├── comment.js │ ├── index.js │ ├── post.js │ └── user.js ├── common ├── actions.js ├── enforce-ssl.js ├── http.js ├── store.js └── strings.js ├── components ├── AuthLoginForm.js ├── AuthSignupForm.js ├── Border.js ├── BorderedItem.js ├── BoxHeaderLayout.js ├── Button.js ├── ColumnLayout.js ├── CommentForm.js ├── CommentList.js ├── CommentPreview.js ├── CommentPreviewHeader.js ├── CommentPreviewReply.js ├── Document.js ├── Input.js ├── Label.js ├── LabelBold.js ├── Link.js ├── NavAuthenticated.js ├── NavLayout.js ├── NavPublic.js ├── PostForm.js ├── PostList.js ├── PostLockup.js ├── PostPreview.js ├── Text.js ├── Textarea.js ├── UserList.js └── UserPreview.js ├── config.js ├── higher-order └── withData.js ├── index.js ├── package.json ├── pages ├── _document.js ├── comments.js ├── index.js ├── post.js ├── posts.js ├── users.js └── write.js ├── server.js └── static └── favicon.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "next/babel", 5 | { 6 | "transform-runtime": { 7 | "useESModules": false 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | [ 14 | "module-resolver", 15 | { 16 | "alias": { 17 | "app": "." 18 | } 19 | } 20 | ] 21 | ] 22 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | app.yaml 2 | yarn.lock 3 | .next 4 | .vscode 5 | .package-lock.json 6 | package-lock.json 7 | .now-secrets.json 8 | node_modules 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # node-waf configuration 29 | .lock-wscript 30 | 31 | # Compiled binary addons (http://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Dependency directory 35 | node_modules 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Env variables 44 | .env 45 | 46 | # Finder stuff 47 | .DS_Store 48 | -------------------------------------------------------------------------------- /.sequelizerc: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | "config": path.resolve('./', 'config.js'), 5 | "models-path": path.resolve('./api/models'), 6 | "seeders-path": path.resolve('./api/seeders'), 7 | "migrations-path": path.resolve('./api/migrations') 8 | }; 9 | -------------------------------------------------------------------------------- /HEROKU.md: -------------------------------------------------------------------------------- 1 | ## Heroku Deploys: Changes 2 | 3 | #### No 1. 4 | Copy and paste these scripts over the existing `build`, `migrate`, and `heroku-postbuild` commands. 5 | 6 | * The most important thing is that Heroku needs to run `npm install -g babel-cli` on their end. 7 | * `"heroku-postbuild"` only works with Heroku deploys. 8 | 9 | ``` 10 | "scripts": { 11 | "build": "NODE_ENV=production npm install -g babel-cli && next build && npm run migrate", 12 | "migrate": "NODE_ENV=production npm install -g sequelize-cli && sequelize db:migrate", 13 | "heroku-postbuild": "NODE_ENV=production npm run build" 14 | } 15 | ``` 16 | 17 | #### No 2. 18 | No `"devDependencies"`, Heroku needs all dependencies to be in the object `"dependencies"`. 19 | 20 | 21 | ## Heroku Deploys: Setup 22 | 23 | Install Heroku. 24 | 25 | ```sh 26 | npm install -g heroku 27 | heroku login 28 | heroku create 29 | ``` 30 | 31 | Heroku will give you a unique address, like ours: `https://next-postgres.herokuapp.com/`. 32 | 33 | Already have a heroku app to deploy to? 34 | 35 | ``` 36 | heroku git:remote -a name-of-your-heroku-app 37 | ``` 38 | 39 | ## Heroku Deploys: Configure Postgres and environment variables on Heroku 40 | 41 | Go to https://data.heroku.com, add a datastore, pick Postgres. 42 | 43 | You will receive `database`, `host`, `password`, `port`, and `username` values. Here is how you set them: 44 | 45 | ```sh 46 | # Set variables 47 | heroku config:set PRODUCTION_DATABASE=xxxxxxxxxxxxxxx 48 | heroku config:set PRODUCTION_HOST=xxxxxxxxxxxxxxx 49 | heroku config:set PRODUCTION_PASSWORD=xxxxxxxxxxxxxxx 50 | heroku config:set PRODUCTION_PORT=xxxxxxxxxxxxxxx 51 | heroku config:set PRODUCTION_USERNAME=xxxxxxxxxxxxxxx 52 | 53 | # See all of your variables 54 | heroku config 55 | ``` 56 | 57 | Please preview your config values at this point in your terminal to make sure you've set them. 58 | 59 | Set a secret for [cookie-session](https://github.com/expressjs/cookie-session): 60 | 61 | ```sh 62 | heroku config:set PRODUCTION_SECRET=xxxxxxxxxxxxxxx 63 | ``` 64 | 65 | Make sure there is a `DATABASE_URL`. If not, you will have to find it and set it: 66 | 67 | ```sh 68 | heroku config:set DATABASE_URL=postgres://xxxxxxxxxxxxxxx.compute-1.amazonaws.com:xxxx/xxxxxxxxxxxxxxx 69 | ``` 70 | 71 | 72 | ## Heroku Deploys: Last step 73 | 74 | ```sh 75 | git push heroku master 76 | ``` 77 | 78 | append `--force` if necessary. 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 @wwwjim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## DEPRECATION NOTICE 2 | 3 | This template is no longer up to date. For an updated template, either as a team or individually, we encourage you to explore our [latest template](https://github.com/internet-development/nextjs-sass-starter) produced by [INTDEV](https://internet.dev). Thank you for your interest in our work! 4 | 5 | # next-postgres 6 | 7 | > **January 4th, 2022** ➝ _I recommend you use [www-react-postgres](https://github.com/jimmylee/www-react-postgres) instead because it does not have an `express` server or a need for `babel`, therefore the template has less dependencies. That means there will be less attention cost required._ 8 | 9 | An example app with... 10 | 11 | - Posts 12 | - Comments 13 | - Authentication 14 | 15 | With some nice qualities... 16 | 17 | - Full stack JavaScript 18 | - Server side rendering 19 | 20 | And you can deploy it to... 21 | 22 | - [Google App Engine](https://cloud.google.com/appengine/) 23 | - [Heroku](https://github.com/jimmylee/next-postgres/blob/master/HEROKU.md) 24 | 25 | Feel free to use without attribution! 26 | 27 | #### Production Examples: 28 | 29 | - [Maurice Kenji Clarke](https://twitter.com/mauricekenji) used the setup to 30 | create: [https://indvstry.io/](https://indvstry.io/) 31 | - [Parker Ruhstaller](https://github.com/pruhstal) used the setup to create: 32 | [https://leafist.com/](https://leafist.com/) 33 | - [Jay Graber](https://twitter.com/arcalinea) used the setup to create: [https://happening.net](https://happening.net) 34 | - [Rich C Smith](https://twitter.com/richcsmith) is working on something wicked cool. 35 | - Maybe you? 36 | 37 | #### Preview: 38 | 39 | - [https://next-postgres.herokuapp.com/](https://next-postgres.herokuapp.com/) 40 | - ~[https://next-postgres.appspot.com/](https://next-postgres.appspot.com/)~ (disabled because of flexible instance cost) 41 | 42 | ### Stack 43 | 44 | - [NextJS + Custom Express](https://github.com/zeit/next.js/) 45 | - [Emotion CSS-in-JS](https://github.com/emotion-js/emotion) 46 | - [Postgres](https://www.postgresql.org/) 47 | - [Sequelize: PostgresSQL ORM](http://docs.sequelizejs.com/) 48 | - [Passport for local authentication](http://passportjs.org/) 49 | - [Redux](http://redux.js.org/) 50 | - [Babel](https://babeljs.io/) 51 | 52 | ### Why is this useful? Why should I care? 53 | 54 | - Bad UX/UI so you are forced to make it your own! 55 | - Some "production ready" are concepts baked in for you. 56 | - You'll get server side rendering for free. 57 | - You can move a little faster at a competition or hackathon. 58 | 59 | ## Setup: Prerequisites 60 | 61 | I use [Homebrew](https://brew.sh/) to manage dependencies. 62 | 63 | - Install Postgres: `brew install postgres`. 64 | - Install [Node 10.7.0+](https://nodejs.org/en/): `brew install node`. (Or 65 | update your node) 66 | 67 | ## Setup: Quick newbies guide to Postgres 68 | 69 | - On OSX, to run Postgres in a tab on the default port. 70 | 71 | ```sh 72 | postgres -D /usr/local/var/postgres -p 5432 73 | ``` 74 | 75 | - Postgres config is stored in `./config.js`. 76 | - Local database: `sampledb`. 77 | - Username: `test`. 78 | - Password: `test`. 79 | - Please come up with something better in production. 80 | 81 | ### First time Postgres instructions. 82 | 83 | ```sh 84 | # Enter Postgres console 85 | psql postgres 86 | 87 | # Create a new user for yourself 88 | CREATE ROLE yourname WITH LOGIN PASSWORD 'yourname'; 89 | 90 | # Allow yourself to create databases 91 | ALTER ROLE yourname CREATEDB; 92 | 93 | # Exit Postgres console 94 | \q 95 | 96 | # Log in as your new user. 97 | psql postgres -U yourname 98 | 99 | # Create a database named: sampledb. 100 | # If you change this, update config.js 101 | CREATE DATABASE sampledb; 102 | 103 | # Give your self privileges 104 | GRANT ALL PRIVILEGES ON DATABASE sampledb TO yourname; 105 | 106 | # List all of your databases 107 | \list 108 | 109 | # Connect to your newly created DB as a test 110 | \connect sampledb 111 | 112 | # Exit Postgres console 113 | \q 114 | ``` 115 | 116 | Newbie tip: I use an app called [TablePlus](https://tableplus.io/) for postgres. 117 | 118 | ## Setup: Run locally 119 | 120 | In the root directory run these commands: 121 | 122 | ```sh 123 | npm install 124 | npm install -g babel-cli 125 | npm install -g sequelize-cli 126 | sequelize db:migrate 127 | npm run dev 128 | ``` 129 | 130 | - Visit `localhost:8000` in a browser to start development locally. 131 | - You will need postgres running. 132 | 133 | ## Deploy Heroku 134 | 135 | To deploy with Heroku, please follow the instructions 136 | [here](https://github.com/jimmylee/next-postgres/blob/master/HEROKU.md). 137 | 138 | ## Deploy Google App Engine 139 | 140 | Please set up [Google App Engine](https://cloud.google.com/appengine/) and 141 | download the `Google Cloud SDK` so you can use `gcloud` from the command line. 142 | 143 | You will need to add an `app.yaml`. It will look something like this: 144 | 145 | ```yaml 146 | runtime: nodejs 147 | env: flex 148 | 149 | manual_scaling: 150 | instances: 1 151 | 152 | resources: 153 | cpu: 1 154 | memory_gb: 0.5 155 | disk_size_gb: 10 156 | 157 | env_variables: 158 | NODE_ENV: production 159 | PRODUCTION_USERNAME: your-database-username 160 | PRODUCTION_PASSWORD: your-database-user-password 161 | PRODUCTION_DATABASE: your-database-name 162 | PRODUCTION_HOST: your-database-host 163 | PRODUCTION_PORT: your-database-port 164 | PRODUCTION_SECRET: your-secret 165 | ``` 166 | 167 | Be sure to read the 168 | [documentation](https://cloud.google.com/appengine/docs/flexible/custom-runtimes/configuring-your-app-with-app-yaml) 169 | 170 | Make sure you add `app.yaml` to the `.gitignore`. You don't want to commit this 171 | file into your Github repository. 172 | 173 | Then run `npm run deploy`. This configuration will cost you ~\$40 a month. 174 | 175 | ## What happened to Zeit's Now service? 176 | 177 | - It is a great service. 178 | - Now 2.0 is about serverless everything 179 | - This example doesn't work with Now 2.0 180 | 181 | ## Questions? 182 | 183 | Feel free to slang any feels to [@wwwjim](https://twitter.com/wwwjim). 184 | -------------------------------------------------------------------------------- /api/auth.js: -------------------------------------------------------------------------------- 1 | import expressSession from 'cookie-session'; 2 | import bcrypt from 'bcrypt'; 3 | import { Strategy } from 'passport-local'; 4 | import { session } from '../config.js'; 5 | import { User } from './models'; 6 | 7 | module.exports = (app, passport) => { 8 | const newExpressSession = expressSession({ 9 | secret: session.secret, 10 | resave: true, 11 | saveUninitialized: true, 12 | cookie: { 13 | secure: false, 14 | httpOnly: false, 15 | maxAge: 600000, 16 | }, 17 | }); 18 | 19 | app.use(newExpressSession); 20 | app.use(passport.initialize()); 21 | app.use(passport.session()); 22 | 23 | const newLocalStrategyOptions = { 24 | usernameField: 'username', 25 | passwordField: 'password', 26 | session: true, 27 | }; 28 | 29 | const newLocalStrategy = new Strategy( 30 | newLocalStrategyOptions, 31 | async (username, password, done) => { 32 | try { 33 | const user = await User.findOne({ 34 | where: { 35 | username: username, 36 | }, 37 | }); 38 | 39 | if (!user) { 40 | return done(null, false, { 41 | message: 'Incorrect credentials.', 42 | }); 43 | } 44 | 45 | const hashed = bcrypt.hashSync(password, user.salt); 46 | if (user.password === hashed) { 47 | return done(null, user); 48 | } 49 | 50 | return done(null, false, { 51 | message: 'Incorrect credentials.', 52 | }); 53 | } catch (err) { 54 | done(null, false, { 55 | message: 'Failed', 56 | }); 57 | } 58 | } 59 | ); 60 | 61 | passport.use(newLocalStrategy); 62 | 63 | passport.serializeUser((user, done) => { 64 | return done(null, user.id); 65 | }); 66 | 67 | passport.deserializeUser(async (id, done) => { 68 | try { 69 | const user = await User.findOne({ 70 | where: { 71 | id: id, 72 | }, 73 | }); 74 | 75 | if (!user) { 76 | return done(null, false, { message: 'User does not exist' }); 77 | } 78 | 79 | return done(null, user); 80 | } catch (err) { 81 | return done(null, false, { message: 'Failed' }); 82 | } 83 | }); 84 | }; 85 | -------------------------------------------------------------------------------- /api/controllers/comment.js: -------------------------------------------------------------------------------- 1 | import queries from './queries'; 2 | import { Comment, User, Post } from '../models'; 3 | 4 | module.exports = { 5 | async create(req, res) { 6 | try { 7 | await Comment.create({ 8 | content: req.body.content, 9 | commentId: req.body.commentId, 10 | postId: req.body.postId, 11 | userId: req.user.id, 12 | }); 13 | 14 | return res.status(200).send({}); 15 | } catch (err) { 16 | return res.status(500).send(err); 17 | } 18 | }, 19 | 20 | async list(req, res) { 21 | try { 22 | const comments = await Comment.findAll( 23 | queries.comments.list({ User, Post, Comment }) 24 | ); 25 | 26 | return res.status(200).send(comments); 27 | } catch (err) { 28 | throw new Error(err); 29 | return res.status(500).send(err); 30 | } 31 | }, 32 | 33 | async get(req, res) { 34 | const comment = await Comment.findOne( 35 | queries.comments.get({ req, User, Post, Comment }) 36 | ); 37 | 38 | if (!comment) { 39 | return res.status(404).send({ 40 | message: '404 comment', 41 | }); 42 | } 43 | 44 | return res.status(200).send(comment); 45 | }, 46 | 47 | async getAll(req, res) { 48 | try { 49 | const comments = await Comment.findAll( 50 | queries.comments.listForUser({ req, User, Post, Comment }) 51 | ); 52 | 53 | if (!comments) { 54 | return res.status(404).send({ 55 | message: '404 comments', 56 | }); 57 | } 58 | 59 | return res.status(200).send(comments); 60 | } catch (err) { 61 | return res.status(500).send(err); 62 | } 63 | }, 64 | 65 | async update(req, res) { 66 | const comment = await Comment.findOne({ 67 | where: { 68 | id: req.params.commentId, 69 | postId: req.params.postId, 70 | userId: req.user.id, 71 | }, 72 | }); 73 | 74 | if (!comment) { 75 | return res.status(404).send({ 76 | message: '404 comment to update', 77 | }); 78 | } 79 | 80 | const updatedComment = await comment.update({ 81 | content: req.body.content || commment.content, 82 | }); 83 | 84 | return res.status(200).send(updatedComment); 85 | }, 86 | 87 | async delete(req, res) { 88 | const comment = await Comment.findOne({ 89 | where: { 90 | id: req.params.commentId, 91 | postId: req.params.postId, 92 | userId: req.user.id, 93 | }, 94 | }); 95 | 96 | console.log(comment); 97 | 98 | if (!comment) { 99 | return res.status(404).send({ 100 | message: '404 comment to delete', 101 | }); 102 | } 103 | 104 | await comment.destroy(); 105 | 106 | return res.status(200).send(); 107 | }, 108 | }; 109 | -------------------------------------------------------------------------------- /api/controllers/post.js: -------------------------------------------------------------------------------- 1 | import queries from './queries'; 2 | import { Post, User, Comment } from '../models'; 3 | 4 | module.exports = { 5 | async create(req, res) { 6 | try { 7 | const post = await Post.create({ 8 | title: req.body.title, 9 | content: req.body.content, 10 | userId: req.user.id, 11 | }); 12 | 13 | return res.status(200).send(post); 14 | } catch (err) { 15 | return res.status(500).send(err); 16 | } 17 | }, 18 | 19 | async list(req, res) { 20 | try { 21 | const posts = await Post.findAll( 22 | queries.posts.list({ User, Post, Comment }) 23 | ); 24 | return res.status(200).send(posts); 25 | } catch (err) { 26 | return res.status(500).send(err); 27 | } 28 | }, 29 | 30 | async get(req, res) { 31 | try { 32 | const post = await Post.findByPk( 33 | req.params.postId, 34 | queries.posts.get({ User, Post, Comment }) 35 | ); 36 | 37 | if (!post) { 38 | return res.status(404).send({ 39 | message: 'Post Not Found', 40 | }); 41 | } 42 | 43 | return res.status(200).send(post); 44 | } catch (err) { 45 | return res.status(500).send(err); 46 | } 47 | }, 48 | 49 | async update(req, res) { 50 | const post = await Post.findOne({ 51 | where: { 52 | id: req.params.postId, 53 | userId: req.user.id, 54 | }, 55 | }); 56 | 57 | if (!post) { 58 | return res.status(404).send({ 59 | message: '404 on post update', 60 | }); 61 | } 62 | 63 | const updatedPost = await post.update({ 64 | title: req.body.title || post.title, 65 | content: req.body.content || post.content, 66 | }); 67 | 68 | return res.status(200).send(updatedPost); 69 | }, 70 | 71 | async delete(req, res) { 72 | const post = await Post.findOne({ 73 | where: { 74 | id: req.params.postId, 75 | userId: req.user.id, 76 | }, 77 | }); 78 | 79 | if (!post) { 80 | return res.status(404).send({ 81 | message: 'Post Not Found', 82 | }); 83 | } 84 | 85 | await post.destroy(); 86 | 87 | return res.status(200).send({ 88 | message: null, 89 | }); 90 | }, 91 | }; 92 | -------------------------------------------------------------------------------- /api/controllers/queries.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | users: { 3 | list: ({ req, User, Post, Comment }) => { 4 | return { 5 | attributes: { 6 | exclude: ['salt', 'password'], 7 | }, 8 | order: [['createdAt', 'DESC']], 9 | }; 10 | }, 11 | get: ({ req, User, Post, Comment }) => { 12 | return { 13 | attributes: { 14 | exclude: ['salt', 'password'], 15 | }, 16 | include: [ 17 | { 18 | model: Post, 19 | as: 'posts', 20 | }, 21 | { 22 | model: Comment, 23 | as: 'comments', 24 | }, 25 | ], 26 | order: [['createdAt', 'DESC']], 27 | }; 28 | }, 29 | }, 30 | posts: { 31 | list: ({ req, User, Post, Comment }) => { 32 | return { 33 | include: [ 34 | { 35 | model: Comment, 36 | as: 'comments', 37 | include: [ 38 | { 39 | model: User, 40 | as: 'user', 41 | attributes: { 42 | exclude: ['salt', 'password'], 43 | }, 44 | }, 45 | { 46 | model: Post, 47 | as: 'post', 48 | }, 49 | { 50 | model: Comment, 51 | as: 'replies', 52 | include: [ 53 | { 54 | model: User, 55 | as: 'user', 56 | attributes: { 57 | exclude: ['salt', 'password'], 58 | }, 59 | }, 60 | ], 61 | }, 62 | ], 63 | }, 64 | { 65 | model: User, 66 | as: 'user', 67 | attributes: { 68 | exclude: ['salt', 'password'], 69 | }, 70 | }, 71 | ], 72 | order: [['createdAt', 'DESC']], 73 | }; 74 | }, 75 | get: ({ req, User, Post, Comment }) => { 76 | return { 77 | include: [ 78 | { 79 | model: Comment, 80 | as: 'comments', 81 | where: { 82 | commentId: null, 83 | }, 84 | include: [ 85 | { 86 | model: User, 87 | as: 'user', 88 | attributes: { 89 | exclude: ['salt', 'password'], 90 | }, 91 | }, 92 | { 93 | model: Post, 94 | as: 'post', 95 | }, 96 | { 97 | model: Comment, 98 | as: 'replies', 99 | include: [ 100 | { 101 | model: User, 102 | as: 'user', 103 | attributes: { 104 | exclude: ['salt', 'password'], 105 | }, 106 | }, 107 | ], 108 | }, 109 | ], 110 | }, 111 | { 112 | model: User, 113 | as: 'user', 114 | attributes: { 115 | exclude: ['salt', 'password'], 116 | }, 117 | }, 118 | ], 119 | }; 120 | }, 121 | }, 122 | comments: { 123 | list: ({ req, User, Post, Comment }) => { 124 | return { 125 | order: [['createdAt', 'DESC']], 126 | include: [ 127 | { 128 | model: User, 129 | as: 'user', 130 | attributes: { 131 | exclude: ['salt', 'password'], 132 | }, 133 | }, 134 | { 135 | model: Post, 136 | as: 'post', 137 | }, 138 | { 139 | model: Comment, 140 | as: 'replies', 141 | order: [['createdAt']], 142 | include: [ 143 | { 144 | model: User, 145 | as: 'user', 146 | attributes: { 147 | exclude: ['salt', 'password'], 148 | }, 149 | }, 150 | ], 151 | }, 152 | { 153 | model: Comment, 154 | as: 'parent', 155 | include: [ 156 | { 157 | model: User, 158 | as: 'user', 159 | attributes: { 160 | exclude: ['salt', 'password'], 161 | }, 162 | }, 163 | ], 164 | }, 165 | ], 166 | }; 167 | }, 168 | listForUser: ({ req, User, Post, Comment }) => { 169 | return { 170 | where: { 171 | id: req.params.commentId, 172 | userId: req.user.id, 173 | }, 174 | include: [ 175 | { 176 | model: User, 177 | as: 'user', 178 | attributes: { 179 | exclude: ['salt', 'password'], 180 | }, 181 | }, 182 | { 183 | model: Post, 184 | as: 'post', 185 | }, 186 | { 187 | model: Comment, 188 | as: 'replies', 189 | order: [['createdAt']], 190 | }, 191 | ], 192 | order: [['createdAt', 'DESC']], 193 | }; 194 | }, 195 | get: ({ req, User, Post, Comment }) => { 196 | return { 197 | where: { 198 | id: req.params.commentId, 199 | postId: req.params.postId, 200 | userId: req.user.id, 201 | }, 202 | include: [ 203 | { 204 | model: User, 205 | as: 'user', 206 | attributes: { 207 | exclude: ['salt', 'password'], 208 | }, 209 | }, 210 | { 211 | model: Post, 212 | as: 'post', 213 | }, 214 | { 215 | model: Comment, 216 | as: 'replies', 217 | order: [['createdAt']], 218 | include: [ 219 | { 220 | model: User, 221 | as: 'user', 222 | attributes: { 223 | exclude: ['salt', 'password'], 224 | }, 225 | }, 226 | ], 227 | }, 228 | ], 229 | }; 230 | }, 231 | }, 232 | }; 233 | -------------------------------------------------------------------------------- /api/controllers/user.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import passport from 'passport'; 3 | import queries from './queries'; 4 | import { User, Comment, Post } from '../models'; 5 | 6 | const isEmptyOrNull = string => { 7 | return !string || !string.trim(); 8 | }; 9 | 10 | const getUserProps = user => { 11 | return { 12 | id: user.id, 13 | username: user.username, 14 | email: user.email, 15 | createdAt: user.createdAt, 16 | updatedAt: user.updatedAt, 17 | }; 18 | }; 19 | 20 | module.exports = { 21 | async create(req, res) { 22 | const username = req.body.username; 23 | const password = req.body.password; 24 | const verify = req.body.verify; 25 | 26 | if ( 27 | isEmptyOrNull(username) || 28 | isEmptyOrNull(password) || 29 | isEmptyOrNull(verify) 30 | ) { 31 | return res.status(500).send({ 32 | message: 'Please fill out all fields.', 33 | }); 34 | } 35 | 36 | if (password !== verify) { 37 | return res.status(500).send({ 38 | message: 'Your passwords do not match.', 39 | }); 40 | } 41 | 42 | const salt = bcrypt.genSaltSync(10); 43 | const hash = bcrypt.hashSync(password, salt); 44 | 45 | try { 46 | const user = await User.create({ 47 | username: username.toLowerCase(), 48 | salt: salt, 49 | password: hash, 50 | }); 51 | 52 | return req.login(user, err => { 53 | if (!err) { 54 | return res.status(200).send(getUserProps(user)); 55 | } 56 | 57 | return res.status(500).send({ 58 | message: 'Auth error', 59 | }); 60 | }); 61 | } catch (err) { 62 | return res.status(500).send(err); 63 | } 64 | }, 65 | 66 | auth(req, res) { 67 | return passport.authenticate('local', (err, user, info) => { 68 | if (err) { 69 | return res.status(500).send({ 70 | message: '500: Authentication failed, try again.', 71 | }); 72 | } 73 | 74 | if (!user) { 75 | return res.status(404).send({ 76 | message: '404: Authentication failed, try again.', 77 | }); 78 | } 79 | 80 | req.login(user, err => { 81 | if (!err) { 82 | res.status(200).send(user); 83 | } 84 | }); 85 | })(req, res); 86 | }, 87 | 88 | logout(req, res) { 89 | req.logout(); 90 | return res.status(200).send({ 91 | message: 'You are successfully logged out', 92 | }); 93 | }, 94 | 95 | async list(req, res) { 96 | const users = await User.findAll( 97 | queries.users.list({ req, User, Post, Comment }) 98 | ); 99 | 100 | return res.status(200).send(users); 101 | }, 102 | 103 | async get(req, res) { 104 | const user = await User.findByPk( 105 | req.params.userId, 106 | queries.users.get({ req, User, Post, Comment }) 107 | ); 108 | 109 | if (!user) { 110 | return res.status(404).send({ 111 | message: '404 on user get', 112 | }); 113 | } 114 | 115 | return res.status(200).send(getUserProps(user)); 116 | }, 117 | 118 | async update(req, res) { 119 | if (isEmptyOrNull(req.body.password)) { 120 | return res.status(500).send({ 121 | message: 'You must provide a password.', 122 | }); 123 | } 124 | 125 | const user = await User.findByPk(req.params.userId); 126 | 127 | if (!user) { 128 | return res.status(404).send({ 129 | message: '404 no user on update', 130 | }); 131 | } 132 | 133 | const updatedUser = await user.update({ 134 | email: req.body.email || user.email, 135 | username: req.body.username || user.username, 136 | password: req.body.password || user.password, 137 | }); 138 | 139 | return res.status(200).send(getUserProps(updatedUser)); 140 | }, 141 | 142 | async deleteViewer(req, res) { 143 | const user = await User.findByPk(req.user.id); 144 | 145 | if (!user) { 146 | return res.status(403).send({ 147 | message: 'Forbidden: User Not Found', 148 | }); 149 | } 150 | 151 | req.logout(); 152 | await user.destroy(); 153 | 154 | return res.status(200).send({ 155 | viewer: null, 156 | }); 157 | }, 158 | }; 159 | -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | import user from './controllers/user'; 2 | import post from './controllers/post'; 3 | import comment from './controllers/comment'; 4 | 5 | const authMiddleware = (req, res, next) => { 6 | if (req.isAuthenticated()) { 7 | return next(); 8 | } else { 9 | return res.status(403).send({ 10 | message: 'not authenticated', 11 | }); 12 | } 13 | }; 14 | 15 | module.exports = app => { 16 | app.get('/api', (req, res) => { 17 | return res.status(200).send({ 18 | message: 'Welcome', 19 | }); 20 | }); 21 | 22 | app.get('/api/authenticated', (req, res) => { 23 | if (req.isAuthenticated()) { 24 | return res.status(200).send({ 25 | isAuthenticated: true, 26 | }); 27 | } 28 | 29 | return res.status(403).send({ 30 | isAuthenticated: false, 31 | }); 32 | }); 33 | 34 | app.post('/api/signup', user.create); 35 | app.post('/api/login', user.auth); 36 | app.post('/api/logout', authMiddleware, user.logout); 37 | 38 | app.post('/api/users', user.create); 39 | app.get('/api/users', user.list); 40 | app.get('/api/users/:userId', user.get); 41 | app.put('/api/users/:userId', authMiddleware, user.update); 42 | app.delete('/api/viewer/delete', authMiddleware, user.deleteViewer); 43 | 44 | app.post('/api/posts', authMiddleware, post.create); 45 | app.get('/api/posts', post.list); 46 | app.get('/api/posts/:postId', post.get); 47 | app.put('/api/posts/:postId', authMiddleware, post.update); 48 | app.delete('/api/posts/:postId', authMiddleware, post.delete); 49 | 50 | app.get('/api/comments', comment.list); 51 | app.post('/api/comments', authMiddleware, comment.create); 52 | app.get('/api/posts/:postId/comments', comment.getAll); 53 | app.get('/api/posts/:postId/comments/:commentId', comment.get); 54 | app.put( 55 | '/api/posts/:postId/comments/:commentId', 56 | authMiddleware, 57 | comment.update 58 | ); 59 | app.delete( 60 | '/api/posts/:postId/comments/:commentId', 61 | authMiddleware, 62 | comment.delete 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /api/migrations/20170412220603-create-user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Users', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | username: { 12 | type: Sequelize.STRING, 13 | allowNull: false, 14 | unique: true, 15 | }, 16 | email: { 17 | type: Sequelize.STRING, 18 | allowNull: true, 19 | validate: { 20 | isEmail: true, 21 | }, 22 | }, 23 | password: { 24 | type: Sequelize.STRING, 25 | allowNull: false, 26 | }, 27 | salt: { 28 | type: Sequelize.STRING, 29 | allowNull: false, 30 | }, 31 | createdAt: { 32 | allowNull: false, 33 | type: Sequelize.DATE, 34 | }, 35 | updatedAt: { 36 | allowNull: false, 37 | type: Sequelize.DATE, 38 | }, 39 | }); 40 | }, 41 | down: function(queryInterface, Sequelize) { 42 | return queryInterface.dropTable('Users'); 43 | }, 44 | }; 45 | -------------------------------------------------------------------------------- /api/migrations/20170412220728-create-post.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Posts', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | title: { 12 | type: Sequelize.TEXT, 13 | }, 14 | content: { 15 | type: Sequelize.TEXT, 16 | }, 17 | userId: { 18 | allowNull: false, 19 | type: Sequelize.INTEGER, 20 | onDelete: 'CASCADE', 21 | references: { 22 | model: 'Users', 23 | key: 'id', 24 | as: 'userId', 25 | }, 26 | }, 27 | createdAt: { 28 | allowNull: false, 29 | type: Sequelize.DATE, 30 | }, 31 | updatedAt: { 32 | allowNull: false, 33 | type: Sequelize.DATE, 34 | }, 35 | }); 36 | }, 37 | down: function(queryInterface, Sequelize) { 38 | return queryInterface.dropTable('Posts'); 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /api/migrations/20170412235342-create-comment.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | module.exports = { 3 | up: function(queryInterface, Sequelize) { 4 | return queryInterface.createTable('Comments', { 5 | id: { 6 | allowNull: false, 7 | autoIncrement: true, 8 | primaryKey: true, 9 | type: Sequelize.INTEGER, 10 | }, 11 | postId: { 12 | allowNull: false, 13 | type: Sequelize.INTEGER, 14 | onDelete: 'CASCADE', 15 | references: { 16 | model: 'Posts', 17 | key: 'id', 18 | as: 'postId', 19 | }, 20 | }, 21 | userId: { 22 | allowNull: false, 23 | type: Sequelize.INTEGER, 24 | onDelete: 'CASCADE', 25 | references: { 26 | model: 'Users', 27 | key: 'id', 28 | as: 'userId', 29 | }, 30 | }, 31 | commentId: { 32 | allowNull: true, 33 | type: Sequelize.INTEGER, 34 | onDelete: 'CASCADE', 35 | referenes: { 36 | model: 'Comments', 37 | key: 'id', 38 | as: 'commentId', 39 | }, 40 | }, 41 | content: { 42 | type: Sequelize.TEXT, 43 | }, 44 | createdAt: { 45 | allowNull: false, 46 | type: Sequelize.DATE, 47 | }, 48 | updatedAt: { 49 | allowNull: false, 50 | type: Sequelize.DATE, 51 | }, 52 | }); 53 | }, 54 | down: function(queryInterface, Sequelize) { 55 | return queryInterface.dropTable('Comments'); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /api/models/comment.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const Comment = Sequelize.define('Comment', { 3 | content: { 4 | type: DataTypes.TEXT, 5 | allowNull: false, 6 | }, 7 | }); 8 | 9 | Comment.associate = models => { 10 | Comment.hasMany(models.Comment, { 11 | foreignKey: 'commentId', 12 | as: 'replies', 13 | onDelete: 'CASCADE', 14 | }); 15 | 16 | Comment.belongsTo(models.Comment, { 17 | foreignKey: 'commentId', 18 | as: 'parent', 19 | onDelete: 'CASCADE', 20 | }); 21 | 22 | Comment.belongsTo(models.Post, { 23 | foreignKey: 'postId', 24 | as: 'post', 25 | onDelete: 'CASCADE', 26 | }); 27 | 28 | Comment.belongsTo(models.User, { 29 | foreignKey: 'userId', 30 | as: 'user', 31 | onDelete: 'CASCADE', 32 | }); 33 | }; 34 | 35 | return Comment; 36 | }; 37 | -------------------------------------------------------------------------------- /api/models/index.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import sequelize from 'sequelize'; 3 | 4 | const db = {}; 5 | const env = process.env.NODE_ENV || 'development'; 6 | const sequelizeConfig = require(`${__dirname}/../../config.js`)[env]; 7 | 8 | const ORM = new sequelize( 9 | sequelizeConfig.database, 10 | sequelizeConfig.username, 11 | sequelizeConfig.password, 12 | sequelizeConfig 13 | ); 14 | 15 | db.User = ORM.import(path.join(__dirname, 'user.js')); 16 | db.Post = ORM.import(path.join(__dirname, 'post.js')); 17 | db.Comment = ORM.import(path.join(__dirname, 'comment.js')); 18 | 19 | Object.keys(db).forEach(modelName => { 20 | if (db[modelName].associate) { 21 | db[modelName].associate(db); 22 | } 23 | }); 24 | 25 | db.ORM = ORM; 26 | db.sequelize = sequelize; 27 | 28 | module.exports = db; 29 | -------------------------------------------------------------------------------- /api/models/post.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const Post = Sequelize.define('Post', { 3 | title: { 4 | type: DataTypes.TEXT, 5 | allowNull: false, 6 | }, 7 | content: { 8 | type: DataTypes.TEXT, 9 | allowNull: true, 10 | }, 11 | }); 12 | 13 | Post.associate = models => { 14 | Post.hasMany(models.Comment, { 15 | foreignKey: 'postId', 16 | as: 'comments', 17 | onDelete: 'CASCADE', 18 | }); 19 | 20 | Post.belongsTo(models.User, { 21 | foreignKey: 'userId', 22 | as: 'user', 23 | onDelete: 'CASCADE', 24 | }); 25 | }; 26 | 27 | return Post; 28 | }; 29 | -------------------------------------------------------------------------------- /api/models/user.js: -------------------------------------------------------------------------------- 1 | module.exports = (Sequelize, DataTypes) => { 2 | const User = Sequelize.define('User', { 3 | username: { 4 | type: DataTypes.STRING, 5 | allowNull: false, 6 | unique: true, 7 | validate: { 8 | is: /^[a-z0-9\_\-]+$/i, 9 | }, 10 | }, 11 | email: { 12 | type: DataTypes.STRING, 13 | allowNull: true, 14 | validate: { 15 | isEmail: true, 16 | }, 17 | }, 18 | password: { 19 | type: DataTypes.STRING, 20 | allowNull: false, 21 | }, 22 | salt: { 23 | type: DataTypes.STRING, 24 | allowNull: false, 25 | }, 26 | }); 27 | 28 | User.associate = models => { 29 | User.hasMany(models.Post, { 30 | foreignKey: 'userId', 31 | as: 'posts', 32 | onDelete: 'CASCADE', 33 | }); 34 | 35 | User.hasMany(models.Comment, { 36 | foreignKey: 'userId', 37 | as: 'comments', 38 | onDelete: 'CASCADE', 39 | }); 40 | }; 41 | 42 | return User; 43 | }; 44 | -------------------------------------------------------------------------------- /common/actions.js: -------------------------------------------------------------------------------- 1 | import * as HTTP from '../common/http'; 2 | 3 | const exception = error => { 4 | throw new Error(error); 5 | }; 6 | 7 | const redirect = route => { 8 | window.location.href = route; 9 | }; 10 | 11 | export const updateStoreKeys = data => { 12 | return { 13 | type: 'UPDATE_STORE_KEYS', 14 | data, 15 | }; 16 | }; 17 | 18 | export const viewerAuthenticated = viewer => { 19 | return { 20 | type: 'VIEWER_AUTHENTICATED', 21 | isAuthenticated: true, 22 | viewer, 23 | }; 24 | }; 25 | 26 | export const viewerLogout = () => { 27 | return { 28 | type: 'VIEWER_LOGOUT', 29 | }; 30 | }; 31 | 32 | export const viewerDelete = () => { 33 | return async dispatch => { 34 | const response = await HTTP.deleteViewer(); 35 | 36 | if (response.status !== 200) { 37 | return exception('error'); 38 | } 39 | 40 | return dispatch(requestLogout()); 41 | }; 42 | }; 43 | 44 | export const requestSaveComment = options => { 45 | return async dispatch => { 46 | const response = await HTTP.saveComment(options); 47 | 48 | if (response.status !== 200) { 49 | return exception('error'); 50 | } 51 | 52 | window.location.reload(); 53 | }; 54 | }; 55 | 56 | export const requestSaveReply = options => { 57 | return async dispatch => { 58 | const response = await HTTP.saveReply(options); 59 | 60 | if (response.status !== 200) { 61 | return exception('error'); 62 | } 63 | 64 | window.location.reload(); 65 | }; 66 | }; 67 | 68 | export const requestUpdateComment = options => { 69 | return async dispatch => { 70 | const response = await HTTP.updateComment(options); 71 | 72 | if (response.status !== 200) { 73 | return exception('error'); 74 | } 75 | 76 | window.location.reload(); 77 | }; 78 | }; 79 | 80 | export const requestDeleteComment = options => { 81 | return async dispatch => { 82 | const response = await HTTP.deleteComment(options); 83 | 84 | if (response.status !== 200) { 85 | return exception('error'); 86 | } 87 | 88 | window.location.reload(); 89 | }; 90 | }; 91 | 92 | export const requestDeletePost = id => { 93 | return async dispatch => { 94 | const response = await HTTP.deletePost(id); 95 | 96 | if (response.status !== 200) { 97 | return exception('error'); 98 | } 99 | 100 | window.location.reload(); 101 | }; 102 | }; 103 | 104 | export const requestUpdatePost = data => { 105 | return async dispatch => { 106 | const response = await HTTP.updatePost(data); 107 | 108 | if (response.status !== 200) { 109 | return exception('error'); 110 | } 111 | 112 | window.location.reload(); 113 | }; 114 | }; 115 | 116 | export const requestSavePost = data => { 117 | return async dispatch => { 118 | const response = await HTTP.savePost(data); 119 | 120 | if (response.status === 200) { 121 | return redirect('/'); 122 | } 123 | 124 | if (response.status === 403) { 125 | return dispatch(requestLogout()); 126 | } 127 | 128 | return exception('error'); 129 | }; 130 | }; 131 | 132 | export const requestLogout = () => { 133 | return async dispatch => { 134 | const response = await HTTP.logout(); 135 | 136 | if (response.status === 200) { 137 | return redirect('/'); 138 | } 139 | 140 | if (response.status === 403) { 141 | return redirect('/'); 142 | } 143 | 144 | return exception('error'); 145 | }; 146 | }; 147 | 148 | export const requestLogin = data => { 149 | return async dispatch => { 150 | const response = await HTTP.login(data); 151 | 152 | if (response.status !== 200) { 153 | return exception('error'); 154 | } 155 | 156 | return redirect('/'); 157 | }; 158 | }; 159 | 160 | export const requestSignup = data => { 161 | return async dispatch => { 162 | const response = await HTTP.signup(data); 163 | 164 | if (response.status !== 200) { 165 | return exception('error'); 166 | } 167 | 168 | return redirect('/'); 169 | }; 170 | }; 171 | -------------------------------------------------------------------------------- /common/enforce-ssl.js: -------------------------------------------------------------------------------- 1 | module.exports = function(options) { 2 | options = options ? options : {}; 3 | const maxAge = options.maxAge ? options.maxAge : 86400; 4 | const includeSubDomains = 5 | options.includeSubDomains === undefined ? true : options.includeSubdomains; 6 | 7 | return function(req, res, next) { 8 | let ignoreRequest = process.env.NODE_ENV !== 'production'; 9 | const secure = 10 | req.connection.encrypted || req.get('X-Forwarded-Proto') === 'https'; 11 | 12 | if (options.ignoreFilter) { 13 | ignoreRequest = ignoreRequest || options.ignoreFilter(req); 14 | } 15 | 16 | if (ignoreRequest) { 17 | next(); 18 | return; 19 | } 20 | 21 | if (secure) { 22 | let header = 'max-age=' + maxAge; 23 | if (includeSubDomains) { 24 | header += '; includeSubDomains'; 25 | } 26 | 27 | if (options.preload) { 28 | header += '; preload'; 29 | } 30 | 31 | res.setHeader('Strict-Transport-Security', header); 32 | next(); 33 | } else { 34 | res.writeHead(301, { 35 | Location: 'https://' + req.get('host') + req.url, 36 | }); 37 | res.end(); 38 | } 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /common/http.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | const requestHeaders = { 4 | Accept: 'application/json', 5 | 'Content-Type': 'application/json', 6 | }; 7 | 8 | export const getAllComments = () => { 9 | const options = { 10 | method: 'GET', 11 | headers: requestHeaders, 12 | credentials: 'include', 13 | }; 14 | 15 | return fetch(`/api/comments`, options); 16 | }; 17 | 18 | export const saveComment = ({ postId, content }) => { 19 | const options = { 20 | method: 'POST', 21 | headers: requestHeaders, 22 | credentials: 'include', 23 | body: JSON.stringify({ postId, content }), 24 | }; 25 | 26 | return fetch(`/api/comments`, options); 27 | }; 28 | 29 | export const saveReply = ({ postId, commentId, content }) => { 30 | const options = { 31 | method: 'POST', 32 | headers: requestHeaders, 33 | credentials: 'include', 34 | body: JSON.stringify({ postId, commentId, content }), 35 | }; 36 | 37 | return fetch(`/api/comments`, options); 38 | }; 39 | 40 | export const updateComment = ({ postId, commentId, content }) => { 41 | const options = { 42 | method: 'PUT', 43 | headers: requestHeaders, 44 | credentials: 'include', 45 | body: JSON.stringify({ commentId, content }), 46 | }; 47 | 48 | return fetch(`/api/posts/${postId}/comments/${commentId}`, options); 49 | }; 50 | 51 | export const deleteComment = ({ postId, commentId }) => { 52 | const options = { 53 | method: 'DELETE', 54 | headers: requestHeaders, 55 | credentials: 'include', 56 | }; 57 | 58 | return fetch(`/api/posts/${postId}/comments/${commentId}`, options); 59 | }; 60 | 61 | export const getAllPosts = () => { 62 | const options = { 63 | method: 'GET', 64 | headers: requestHeaders, 65 | credentials: 'include', 66 | }; 67 | 68 | return fetch(`/api/posts`, options); 69 | }; 70 | 71 | export const getPostById = id => { 72 | const options = { 73 | method: 'GET', 74 | headers: requestHeaders, 75 | credentials: 'include', 76 | }; 77 | 78 | return fetch(`/api/posts/${id}`, options); 79 | }; 80 | 81 | export const deleteViewer = () => { 82 | const options = { 83 | method: 'DELETE', 84 | headers: requestHeaders, 85 | credentials: 'include', 86 | }; 87 | 88 | return fetch(`/api/viewer/delete`, options); 89 | }; 90 | 91 | export const deletePost = id => { 92 | const options = { 93 | method: 'DELETE', 94 | headers: requestHeaders, 95 | credentials: 'include', 96 | }; 97 | 98 | return fetch(`/api/posts/${id}`, options); 99 | }; 100 | 101 | export const updatePost = ({ content, title, postId }) => { 102 | const options = { 103 | method: 'PUT', 104 | credentials: 'include', 105 | headers: requestHeaders, 106 | body: JSON.stringify({ content, title }), 107 | }; 108 | 109 | return fetch(`/api/posts/${postId}`, options); 110 | }; 111 | 112 | export const savePost = ({ content, title }) => { 113 | const options = { 114 | method: 'POST', 115 | credentials: 'include', 116 | headers: requestHeaders, 117 | body: JSON.stringify({ content, title }), 118 | }; 119 | 120 | return fetch(`/api/posts`, options); 121 | }; 122 | 123 | export const getAllUsers = () => { 124 | const options = { 125 | method: 'GET', 126 | headers: requestHeaders, 127 | credentials: 'include', 128 | }; 129 | 130 | return fetch(`/api/users`, options); 131 | }; 132 | 133 | export const login = ({ username, password }) => { 134 | const options = { 135 | method: 'POST', 136 | headers: requestHeaders, 137 | credentials: 'include', 138 | body: JSON.stringify({ username: username.toLowerCase(), password }), 139 | }; 140 | 141 | return fetch(`/api/login`, options); 142 | }; 143 | 144 | export const logout = () => { 145 | const options = { 146 | method: 'POST', 147 | credentials: 'include', 148 | headers: requestHeaders, 149 | }; 150 | 151 | return fetch(`/api/logout`, options); 152 | }; 153 | 154 | export const signup = ({ username, password, verify }) => { 155 | const options = { 156 | method: 'POST', 157 | headers: requestHeaders, 158 | credentials: 'include', 159 | body: JSON.stringify({ 160 | username: username.toLowerCase(), 161 | password, 162 | verify, 163 | }), 164 | }; 165 | 166 | return fetch(`/api/signup`, options); 167 | }; 168 | -------------------------------------------------------------------------------- /common/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import thunkMiddleware from 'redux-thunk'; 3 | 4 | const INITIAL_STATE = { 5 | users: [], 6 | posts: [], 7 | comments: [], 8 | post: undefined, 9 | viewer: undefined, 10 | isAuthenticated: false, 11 | }; 12 | 13 | const mergeUpdatedKeys = (data, state) => { 14 | return { ...state, ...data }; 15 | }; 16 | 17 | const mergeAuthState = ({ isAuthenticated, viewer }, state) => { 18 | return { ...state, isAuthenticated, viewer }; 19 | }; 20 | 21 | const mergeLogoutState = state => { 22 | return { ...state, isAuthenticated: false, viewer: undefined }; 23 | }; 24 | 25 | export const reducer = (state = INITIAL_STATE, action) => { 26 | switch (action.type) { 27 | case 'UPDATE_STORE_KEYS': 28 | return mergeUpdatedKeys(action.data, state); 29 | case 'VIEWER_AUTHENTICATED': 30 | return mergeAuthState(action, state); 31 | case 'VIEWER_LOGOUT': 32 | return mergeLogoutState(state); 33 | default: 34 | return state; 35 | } 36 | }; 37 | 38 | export const initStore = initialState => { 39 | return createStore(reducer, initialState, applyMiddleware(thunkMiddleware)); 40 | }; 41 | -------------------------------------------------------------------------------- /common/strings.js: -------------------------------------------------------------------------------- 1 | export const elide = (string, length = 140) => { 2 | if (isEmpty(string)) { 3 | return '...'; 4 | } 5 | 6 | if (string.length < length) { 7 | return string.trim(); 8 | } 9 | 10 | return `${string.substring(0, length)}...`; 11 | }; 12 | 13 | export const toDate = string => { 14 | const date = new Date(string); 15 | return `${date.getMonth() + 1}-${date.getDate()}-${date.getFullYear()}`; 16 | }; 17 | 18 | export const isEmpty = string => { 19 | return !string || string.length === 0; 20 | }; 21 | 22 | export const pluralize = (text, count) => { 23 | return count > 1 || count === 0 ? `${text}s` : text; 24 | }; 25 | -------------------------------------------------------------------------------- /components/AuthLoginForm.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as Actions from '../common/actions'; 3 | 4 | import { connect } from 'react-redux'; 5 | 6 | import Input from '../components/Input'; 7 | import Button from '../components/Button'; 8 | 9 | class AuthLoginForm extends React.Component { 10 | state = { 11 | username: '', 12 | password: '', 13 | }; 14 | 15 | _handleChange = e => { 16 | this.setState({ [e.target.name]: e.target.value }); 17 | }; 18 | 19 | _handleSubmit = e => { 20 | this.props.dispatch(Actions.requestLogin(this.state)); 21 | }; 22 | 23 | render() { 24 | return ( 25 |