completed changes

This commit is contained in:
Kunwar Ashutosh Singh 2020-07-13 01:26:54 +05:30
parent 1917903b67
commit bbbbc9f9b8
43 changed files with 6612 additions and 3175 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
APP_ENV=dev|stage|production
APP_PORT=3000
SESSION_SECRET=someString
JWT_SECRET=someString
# Databse
# YOUR_MONGO_DB_NAME
DB_NAME=someString
DB_HOST=someString
DB_PORT=27017
DB_USER=someUser
DB_USER_PWD=secret

10
.gitignore vendored
View File

@ -18,3 +18,13 @@
#System Files
.DS_Store
Thumbs.db
# Environment Files
.env
.env.local
# logs
/logs
# build
/buid

View File

@ -1,23 +0,0 @@
import { IUserModel } from '../models/user-model';
export interface IArticle {
slug: string;
title: string;
description: string;
body: string;
tagList?: [string];
createdAt: Date;
updatedAt: Date;
favorited: boolean;
favoritesCount: number;
author: IUserModel;
}
export interface IQuery {
tagList: {$in: any[]};
author: string;
_id: {$in: any[]};
}

View File

@ -1,27 +0,0 @@
import { Request } from 'express';
import { IUserModel } from '../models/user-model';
import { IArticleModel } from '../models/article-model';
// Add jwt payload details to Express Request
export interface JWTRequest extends Request {
payload: {
id: string,
username: string,
exp: number,
iat: number
};
}
// Add profile details to JWT Request
export interface ProfileRequest extends JWTRequest {
profile: IUserModel;
}
// Add article details to ProfileRequest
export interface ArticleRequest extends ProfileRequest {
article: IArticleModel;
}

View File

@ -1,15 +0,0 @@
export interface IUser {
email: string;
username: string;
bio?: string;
image?: string;
}
export interface IProfile {
username: string;
bio: string;
image: string;
following: boolean;
}

View File

@ -1,41 +0,0 @@
import { model, Model, Schema, Document } from 'mongoose';
import { IArticle } from '../interfaces/article-interface';
import { IUserModel, User } from './user-model';
export interface IArticleModel extends IArticle, Document {
formatAsArticleJSON(user);
}
const ArticleSchema = new Schema({
slug: {type: String}, // FIXME: Add , lowercase: true, unique: true
title: String,
description: String,
body: String,
tagList: [String],
favoritesCount: {type: Number, default: 0},
author: {type: Schema.Types.ObjectId, ref: 'User'}
}, {timestamps: true});
ArticleSchema.methods.formatAsArticleJSON = function(user: IUserModel) {
return {
slug: this.slug,
title: this.title,
description: this.description,
body: this.body,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
tagList: this.tagList,
favorited: user ? user.isFavorite(this._id) : false,
favoritesCount: this.favoritesCount,
author: this.author.formatAsProfileJSON(user)
};
};
export const Article: Model<IArticleModel> = model<IArticleModel>('Article', ArticleSchema);

View File

@ -1,115 +0,0 @@
import { Document, Schema, Model, model } from 'mongoose';
import { IUser } from '../interfaces/user-interface';
import * as jwt from 'jsonwebtoken';
import { jwtSecret } from '../utilities/authentication';
import * as crypto from 'crypto';
export interface IUserModel extends IUser, Document {
token?: string;
favorites: [Schema.Types.ObjectId];
generateJWT();
formatAsUserJSON();
setPassword(password);
passwordIsValid(password);
formatAsProfileJSON(user);
isFollowing(id);
follow(id);
unfollow(id);
isFavorite(id);
}
// ISSUE: Own every parameter and any missing dependencies
const UserSchema = new Schema({
username: {type: String, lowercase: true, unique: true, required: [true, "can't be blank"],
match: [/^[a-zA-Z0-9]+$/, 'is invalid'], index: true},
email: {type: String, lowercase: true, unique: true, required: [true, "can't be blank"],
match: [/\S+@\S+\.\S+/, 'is invalid'], index: true},
bio: String,
image: String,
favorites: [{ type: Schema.Types.ObjectId, ref: 'Article' }],
following: [{ type: Schema.Types.ObjectId, ref: 'User' }],
hash: String,
salt: String
}, {timestamps: true});
UserSchema.methods.generateJWT = function(): string {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
return jwt.sign({
id: this._id,
username: this.username,
exp: exp.getTime() / 1000
}, jwtSecret);
};
UserSchema.methods.formatAsUserJSON = function() {
return {
username: this.username,
email: this.email,
token: this.generateJWT(),
bio: this.bio,
image: this.image
};
};
UserSchema.methods.setPassword = function(password: string) {
this.salt = crypto.randomBytes(16).toString('hex');
this.hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
};
UserSchema.methods.passwordIsValid = function(password: string) {
const hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
return hash === this.hash;
};
UserSchema.methods.formatAsProfileJSON = function(user: IUserModel) {
return {
username: this.username,
bio: this.bio,
image: this.image || 'https://static.productionready.io/images/smiley-cyrus.jpg',
following: user ? user.isFollowing(this._id) : false
};
};
UserSchema.methods.isFollowing = function(id: Schema.Types.ObjectId): boolean {
return this.following.some( (followingId: Schema.Types.ObjectId) => {
return followingId.toString() === id.toString();
});
};
// ISSUE: Why did these have to be converted to strings to evaluate correctly?
UserSchema.methods.follow = function(id: Schema.Types.ObjectId) {
if (this.following.indexOf(id) === -1) {
this.following.push(id);
}
return this.save();
};
UserSchema.methods.unfollow = function(id: Schema.Types.ObjectId) {
this.following.remove(id);
return this.save();
};
UserSchema.methods.isFavorite = function(id: Schema.Types.ObjectId): boolean {
return this.following.some( (favoriteId: Schema.Types.ObjectId) => {
return favoriteId.toString() === id.toString();
});
};
// ISSUE: Why did these have to be converted to strings to evaluate correctly?
export const User: Model<IUserModel> = model<IUserModel>('User', UserSchema);

View File

@ -1,246 +0,0 @@
import { Router, NextFunction, Response } from 'express';
import { authentication } from '../utilities/authentication';
import { ArticleRequest, JWTRequest, ProfileRequest } from '../interfaces/requests-interface';
import { Article, IArticleModel } from '../models/article-model';
import { IUserModel, User } from '../models/user-model';
import { IQuery } from '../interfaces/article-interface';
import { Schema } from 'mongoose';
import * as slugify from 'slugify';
const router: Router = Router();
const Promise = require('bluebird'); // FIXME: how to handle this in Typescript?
/**
* PARAM :slug
*/
router.param('slug', (req: ArticleRequest, res: Response, next: NextFunction, slug: string) => {
Article
.findOne({slug})
.populate('author')
.then( (article: IArticleModel) => {
req.article = article;
return next();
})
.catch(next);
});
/**
* Helper function to determine the requesting user (if any)
*/
// FIXME: Not sure there is a req.profile... make this robust...
function establishRequestingUser(req: ProfileRequest): IUserModel {
// Try to determine the user making the request
let thisUser: IUserModel;
// If authentication was performed was successful look up the profile relative to authenticated user
if (req.payload) {
User
.findById(req.payload.id)
.then( (user: IUserModel) => {
return thisUser = user.formatAsProfileJSON(user);
})
.catch();
// If authentication was NOT performed or successful look up profile relative to that same user (following = false)
} else {
thisUser = req.profile;
}
return thisUser;
}
/**
* GET /api/articles
*/
// FIXME: authorized user who has favorited own articles showing false.
// Should show true for all returned 'josh' articles
router.get('/', authentication.optional, (req: ProfileRequest, res: Response, next: NextFunction) => {
// Parse URL query strings and create a query object
const limit: number = req.query.limit ? Number(req.query.limit) : 20;
const offset: number = req.query.offset ? Number(req.query.offset) : 0;
const query = <IQuery> {}; // ISSUE: how to resolve?
// Handle single tag or multiple tags in query (...&tag=git&tag=node...)
if (typeof req.query.tag !== 'undefined') {
if (Array.isArray(req.query.tag)) {
query.tagList = {$in: req.query.tag};
} else {
query.tagList = {$in: [req.query.tag]};
}
}
Promise
.all([
req.query.author ? User.findOne({username: req.query.author}) : 'noAuthor',
req.query.favorited
? User
.find({username: req.query.favorited})
.then(users => {
let favoritedArticles: [Schema.Types.ObjectId];
users.forEach((user, userIndex) => {
user.favorites.forEach((favorite, favoriteIndex) => {
if (userIndex === 0 && favoriteIndex === 0) {
favoritedArticles = [favorite];
} else {
favoritedArticles.push(favorite);
}
});
});
return favoritedArticles;
})
: 'noFavorites'
])
.then( results => {
const author = results[0];
const favoritedArticleIds = results[1];
// Return no articles for unknown author, but ignore author filter if none was provided
if (author !== 'noAuthor') {
query.author = author;
}
/* Restrict the query results to only article IDs that are
favorited by the username(s) specified in the query string.
Note: Choosing to interpret multiple usernames as an 'or' operation,
meaning that articles favorited by ANY of the users will be returned,
as opposed to an 'and' operation wherein only articles favorited by
ALL usernames would be returned.
*/
if (favoritedArticleIds !== 'noFavorites') {
query._id = {$in: favoritedArticleIds};
}
// Define promises
const p1 = establishRequestingUser(req);
const p2 = Article.count(query).exec();
/* ISSUE: Should count be MIN(count, limit)? or should it count all results,
even if not displayed due to limit or offset query string parameter
*/
const p3 =
Article
.find(query)
.limit(limit)
.skip(offset) // FIXME: does order matter?
.populate('author')
.exec();
// Resolve and use promise results
Promise
.all([p1, p2, p3])
.then(results => {
const user: IUserModel = results[0];
const articlesCount: number = results[1];
const articles = results[2];
res.json(
{articles: articles.map((article: IArticleModel) => {
return article.formatAsArticleJSON(user);
}),
articlesCount});
})
.catch(next);
});
});
/**
* POST /api/articles
*/
router.post('/', authentication.required, (req: JWTRequest, res: Response, next: NextFunction) => {
// Examine the request body for completeness
const article: IArticleModel = new Article();
if (typeof req.body.article.title !== 'undefined' &&
typeof req.body.article.description !== 'undefined' &&
typeof req.body.article.body !== 'undefined') {
article.title = req.body.article.title;
article.description = req.body.article.description;
article.body = req.body.article.body;
article.slug = slugify(article.title, {lower: true});
} else {
res.json('Error in article input: missing title, desc, or body.');
}
if (typeof req.body.article.tagList !== 'undefined') {
article.tagList = req.body.article.tagList;
}
// Verify authentication successful, then save and return article
User
.findById(req.payload.id)
.then(user => {
article.author = user;
return article.save().then(() => {
return res.json({article: article.formatAsArticleJSON(user)});
});
})
.catch(next);
});
/**
* GET /api/articles/:slug
*/
// ISSUE: Possibly not showing following correctly for auth user...
router.get('/:slug', authentication.optional, (req: ArticleRequest, res: Response, next: NextFunction) => {
const user = establishRequestingUser(req);
console.log(user);
console.log(req.article);
const article: IArticleModel = req.article;
if (article) {
res.json(article.formatAsArticleJSON(user));
} else {
return next();
}
});
/**
* DELETE /api/articles/:slug
*/
router.delete('/:slug', authentication.required, (req: ArticleRequest, res: Response, next: NextFunction) => {
Article
.findOneAndRemove({slug: req.article.slug}, () => {
return res.json();
})
.catch(next);
});
/**
* PUT /api/articles/:slug
*/
router.put('/:slug', authentication.required, (req: ArticleRequest, res: Response, next: NextFunction) => {
});
// TODO: Remaining routes
// GET /api/articles/feed
// POST /api/articles/:slug/comments
// GET /api/articles/:slug/comments
// DELETE /api/articles/:slug/comments/:id
// POST /api/articles/:slug/favorite
// DELETE /api/articles/:slug/favorite
export const ArticlesRoutes: Router = router;

View File

@ -1,86 +0,0 @@
import { NextFunction, Response, Router } from 'express';
import { IUserModel, User } from '../models/user-model';
import { authentication } from '../utilities/authentication';
import { ProfileRequest } from '../interfaces/requests-interface';
const router: Router = Router();
/**
* PARAM :username
*/
router.param('username', (req: ProfileRequest, res: Response, next: NextFunction, username: string) => {
User
.findOne({username})
.then( (user: IUserModel) => {
req.profile = user;
return next();
})
.catch(next);
});
/**
* GET /api/profiles/:username
*/
router.get('/:username', authentication.optional, (req: ProfileRequest, res: Response, next: NextFunction) => {
// If authentication was performed and was successful look up the profile relative to authenticated user
if (req.payload) {
User
.findById(req.payload.id)
.then( (user: IUserModel) => {
res.status(200).json({profile: req.profile.formatAsProfileJSON(user)});
})
.catch(next);
// If authentication was NOT performed or successful look up profile relative to that same user (following = false)
} else {
res.status(200).json({profile: req.profile.formatAsProfileJSON(req.profile)});
}
});
/**
* POST /api/profiles/:username/follow
*/
router.post('/:username/follow', authentication.required, (req: ProfileRequest, res: Response, next: NextFunction) => {
const profileId = req.profile._id;
User
.findById(req.payload.id)
.then((user: IUserModel) => {
user.follow(profileId);
return user.save().then(() => {
return res.json({profile: req.profile.formatAsProfileJSON(user)});
});
})
.catch(next);
});
/**
* DELETE /api/profiles/:username/follow
*/
router.delete('/:username/follow', authentication.required, (req: ProfileRequest, res: Response, next: NextFunction) => {
const profileId = req.profile._id;
User
.findById(req.payload.id)
.then((user: IUserModel) => {
user.unfollow(profileId);
return user.save().then(() => {
return res.json({profile: req.profile.formatAsProfileJSON(user)});
});
})
.catch(next);
});
export const ProfilesRoutes: Router = router;

View File

@ -1,23 +0,0 @@
import { Article } from '../models/article-model';
import { Router, Request, Response, NextFunction } from 'express';
const router: Router = Router();
// FIXME: Rewrite to pull from Articles...
router.get('/', (req: Request, res: Response, next: NextFunction) => {
Article
.find()
.distinct('tagList')
.then((tagsArray: [string]) => {
return res.json({tags: tagsArray});
})
.catch(next);
});
export const TagRoutes: Router = router;

View File

@ -1,68 +0,0 @@
import { IUserModel, User } from '../models/user-model';
import { authentication } from '../utilities/authentication';
import { NextFunction, Response, Router } from 'express';
import { JWTRequest } from '../interfaces/requests-interface';
const router: Router = Router();
/**
* GET /api/user
*/
router.get('/', authentication.required, (req: JWTRequest, res: Response, next: NextFunction) => {
User
.findById(req.payload.id)
.then((user: IUserModel) => {
res.status(200).json({user: user.formatAsUserJSON()});
}
)
.catch(next);
}
);
/**
* PUT /api/user
*/
router.put('/', authentication.required, (req: JWTRequest, res: Response, next: NextFunction) => {
User
.findById(req.payload.id)
.then((user: IUserModel) => {
if (!user) {
return res.sendStatus(401);
}
// Update only fields that have values:
// ISSUE: DRY out code?
if (typeof req.body.user.email !== 'undefined' ) {
user.email = req.body.user.email;
}
if (typeof req.body.user.username !== 'undefined') {
user.username = req.body.user.username;
}
if (typeof req.body.user.password !== 'undefined') {
user.setPassword(req.body.user.password);
}
if (typeof req.body.user.image !== 'undefined') {
user.image = req.body.user.image;
}
if (typeof req.body.user.bio !== 'undefined') {
user.bio = req.body.user.bio;
}
return user.save().then( () => {
return res.json({user: user.formatAsUserJSON()});
});
})
.catch(next);
}
);
export const UserRoutes: Router = router;

View File

@ -1,60 +0,0 @@
import { Router, Response, NextFunction, Request } from 'express';
import { IUserModel, User } from '../models/user-model';
import * as passport from 'passport';
const router: Router = Router();
/**
* POST /api/users
*/
router.post('/', (req: Request, res: Response, next: NextFunction) => {
const user: IUserModel = new User();
user.username = req.body.user.username;
user.email = req.body.user.email;
user.setPassword(req.body.user.password);
user.bio = '';
user.image = '';
return user.save()
.then( () => {
return res.json({user: user.formatAsUserJSON()});
})
.catch(next);
});
// ISSUE: How does this work with the trailing (req, res, next)?
/**
* POST /api/users/login
*/
router.post('/login', (req: Request, res: Response, next: NextFunction) => {
if (!req.body.user.email) {
return res.status(422).json( {errors: {email: "Can't be blank"}} );
}
if (!req.body.user.password) {
return res.status(422).json( {errors: {password: "Can't be blank"}} );
}
passport.authenticate('local', {session: false}, (err, user, info) => {
if (err) { return next(err); }
if (user) {
user.token = user.generateJWT();
return res.json({user: user.formatAsUserJSON()});
} else {
return res.status(422).json(info);
}
})(req, res, next);
});
export const UsersRoutes: Router = router;

View File

@ -1,60 +0,0 @@
import { Request } from 'express';
import * as jwt from 'express-jwt';
export const jwtSecret = process.env.NODE_ENV === 'production' ? process.env.SECRET : 'secret';
function getTokenFromHeader(req: Request): string | null {
const headerAuth: string | string[] = req.headers.authorization;
if (headerAuth !== undefined && headerAuth !== null) {
if (Array.isArray(headerAuth)) {
return splitToken(headerAuth[0]);
} else {
return splitToken(headerAuth);
}
} else {
return null;
}
}
function splitToken(authString: string) {
if (authString.split(' ')[0] === 'Token') {
return authString.split(' ')[1];
} else {
return null;
}
}
const auth = {
required: jwt({
credentialsRequired: true,
secret: jwtSecret,
getToken: getTokenFromHeader,
userProperty: 'payload'}),
optional: jwt({
credentialsRequired: false,
secret: jwtSecret,
getToken: getTokenFromHeader,
userProperty: 'payload'})
};
// TODO: What was this for?
// function isRevokedCallback() {
//
// }
export const authentication = auth;

View File

@ -1,58 +0,0 @@
import * as mongoose from 'mongoose';
import * as Bluebird from 'bluebird';
// Use bluebird promises in lieu of mongoose promises throughout application.
(mongoose as any).Promise = Bluebird;
export function connectToMongoDB() {
const dbUri = 'mongodb://localhost:27017/conduit';
mongoose.connect(dbUri, {
useMongoClient: true
});
mongoose.set('debug', true); // FIXME: Allow for dev only
mongoose.connection.on('connected', () => {
console.log('Mongoose connected to ' + dbUri);
});
mongoose.connection.on('disconnected', () => {
console.log('Mongoose disconnected');
});
mongoose.connection.on('error', err => {
console.log('Mongoose connection error: ' + err);
});
// ADDITIONAL PROCESS EVENTS FOR UNIX MACHINES ONLY:
// CTRL-C
process.on('SIGINT', () => {
mongoose.connection.close(() => {
console.log('Mongoose disconnected through app termination (SIGINT)');
process.exit(0);
});
});
// Used on services on Heroku
process.on('SIGTERM', () => {
mongoose.connection.close(() => {
console.log('Mongoose disconnected through app termination (SIGTERM)');
process.exit(0);
});
});
// Node restart
process.once('SIGUSR2', () => {
mongoose.connection.close(() => {
console.log('Mongoose disconnected through app termination (SIGUSR2)');
process.kill(process.pid, 'SIGUSR2');
});
});
}

View File

@ -1,45 +0,0 @@
import { Application } from 'express';
export function loadErrorHandlers(app: Application) {
const isProduction = process.env.NODE_ENV === 'production';
// catch 404 errors and forward to error handler
app.use( (req, res, next) => {
interface BetterError extends Error {
status?: number;
}
const err: BetterError = new Error('Not Found');
err.status = 404;
next(err);
});
// Dev error handler
if (!isProduction) {
app.use( (err, req, res, next) => {
console.log(err.stack);
res.status(err.status || 500);
res.json({errors: {
message: err.message,
error: err
}});
});
}
// Production error handler (no stack traces displayed)
app.use( (err, req, res, next) => {
res.status(err.status || 500);
res.json({errors: {
message: err.message,
error: {}
}});
});
}

View File

@ -1,30 +0,0 @@
import * as passport from 'passport';
import { User } from '../models/user-model';
import * as passportLocal from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
passport.use(new LocalStrategy( {
// Strategy is based on username & password. Substitute email for username.
usernameField: 'user[email]',
passwordField: 'user[password]'},
(email, password, done) => {
User
.findOne( {email})
.then(user => {
if (!user) {
return done(null, false, { message: 'Incorrect email.' });
}
if (!user.passwordIsValid(password)) {
return done(null, false, { message: 'Incorrect password.' });
}
return done(null, user);
})
.catch(done);
}));

30
app.ts
View File

@ -1,30 +0,0 @@
import * as express from 'express';
import { Application } from 'express';
import * as bodyParser from 'body-parser';
import { MainRouter } from './api/routes/index';
import { connectToMongoDB } from './api/utilities/database';
import { loadErrorHandlers } from './api/utilities/error-handling';
import './api/utilities/passport';
// FIXME: Sort out passport stuff...
// import * as passport from 'passport';
import * as session from 'express-session';
const app: Application = express();
connectToMongoDB();
app.use(bodyParser.json());
app.use(session({ secret: 'conduit', cookie: { maxAge: 60000 }, resave: false, saveUninitialized: false }));
// app.use(passport.initialize());
// app.use(passport.session());
app.use('/api', MainRouter);
loadErrorHandlers(app);
const server = app.listen( 3000, () => {
console.log('Listening on port ' + server.address().port);
});

5610
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,10 +2,16 @@
"name": "typescript-node-express-realworld-example-app",
"version": "0.0.0",
"description": "> ### [YOUR_FRAMEWORK] codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API.",
"main": "app.ts",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon --exec 'ts-node' ./app.ts"
"start": "npm run build && npm run watch && npm run serve",
"serve": "node build/server.js",
"build": "npm run build-ts",
"watch": "concurrently -k -p \"[{name}]\" -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold,green.bold\" \"npm run watch-ts\" \"npm run watch-node\"",
"watch-node": "nodemon -r dotenv/config build/server.js",
"build-ts": "tsc",
"watch-ts": "tsc -w",
"test": "newman run ./tests/api-tests.postman.json -e ./tests/env-api-tests.postman.json"
},
"repository": {
"type": "git",
@ -18,29 +24,45 @@
},
"homepage": "https://",
"devDependencies": {
"@types/bluebird": "^3.5.8",
"@types/express": "^4.0.37",
"@types/express-jwt": "0.0.37",
"@types/express-session": "^1.15.3",
"@types/jsonwebtoken": "^7.2.3",
"@types/mongodb": "^2.2.11",
"@types/mongoose": "^4.7.21",
"@types/passport": "^0.3.4",
"@types/passport-local": "^1.0.31",
"nodemon": "^1.11.0"
"@types/body-parser": "1.19.0",
"@types/compression": "1.7.0",
"@types/cors": "2.8.6",
"@types/express": "4.17.6",
"@types/express-jwt": "0.0.42",
"@types/express-serve-static-core": "4.17.8",
"@types/express-session": "^1.17.0",
"@types/helmet": "0.0.47",
"@types/jsonwebtoken": "^8.5.0",
"@types/lodash": "^4.14.157",
"@types/mongodb": "^3.5.25",
"@types/mongoose": "^5.7.31",
"@types/mongoose-unique-validator": "1.0.4",
"@types/node": "14.0.22",
"@types/passport": "^1.0.4",
"@types/passport-local": "^1.0.33",
"newman": "^3.8.2",
"nodemon": "2.0.4",
"ts-node": "8.10.2",
"tslint": "6.1.2",
"typescript": "^3.9.6"
},
"dependencies": {
"bluebird": "^3.5.0",
"body-parser": "^1.17.2",
"express": "^4.15.4",
"express-jwt": "^5.3.0",
"express-session": "^1.15.5",
"jsonwebtoken": "^7.4.3",
"mongoose": "^4.11.9",
"passport": "^0.4.0",
"body-parser": "^1.19.0",
"compression": "1.7.4",
"cors": "2.8.5",
"dotenv": "8.2.0",
"express": "^4.17.1",
"express-jwt": "^6.0.0",
"express-session": "^1.17.1",
"helmet": "^3.23.3",
"jsonwebtoken": "^8.5.1",
"lodash": "^4.17.19",
"mongoose": "^5.9.23",
"mongoose-unique-validator": "2.0.3",
"passport": "^0.4.1",
"passport-local": "^1.0.0",
"slugify": "^1.2.1",
"ts-node": "^3.3.0",
"typescript": "^2.5.2"
"slugify": "^1.4.4",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0"
}
}

34
src/app.ts Normal file
View File

@ -0,0 +1,34 @@
import express from 'express';
import { Application } from 'express';
import * as bodyParser from 'body-parser';
import { MainRouter } from './routes';
import { loadErrorHandlers } from './utilities/error-handling';
import session from 'express-session';
import helmet from "helmet";
import compression from "compression";
import { SESSION_SECRET } from "./utilities/secrets";
import './database'; // initialize database
import './utilities/passport'
const app: Application = express();
app.use(helmet());
app.use(compression());
app.use(bodyParser.json());
app.use(session({
secret: SESSION_SECRET,
cookie: {
maxAge: 60000
},
resave : false,
saveUninitialized: false
}
));
app.use('/api', MainRouter);
loadErrorHandlers(app);
export default app;

58
src/database/index.ts Normal file
View File

@ -0,0 +1,58 @@
import mongoose from 'mongoose';
import logger from "../utilities/logger";
import { DB } from "../utilities/secrets";
// Build the connection string
const dbURI = `mongodb://${DB.USER}:${encodeURIComponent(DB.PASSWORD)}@${DB.HOST}:${DB.PORT}/${
DB.NAME
}`;
const options = {
useNewUrlParser : true,
useCreateIndex : true,
useUnifiedTopology: true,
useFindAndModify : false,
autoIndex : true,
poolSize : 10, // Maintain up to 10 socket connections
// If not connected, return errors immediately rather than waiting for reconnect
bufferMaxEntries : 0,
connectTimeoutMS : 10000, // Give up initial connection after 10 seconds
socketTimeoutMS : 45000, // Close sockets after 45 seconds of inactivity
};
logger.debug(dbURI);
// Create the database connection
mongoose
.connect(dbURI, options)
.then(() => {
logger.info('Mongoose connection done');
})
.catch((e) => {
logger.info('Mongoose connection error');
logger.error(e);
});
// CONNECTION EVENTS
// When successfully connected
mongoose.connection.on('connected', () => {
logger.info('Mongoose default connection open to ' + dbURI);
});
// If the connection throws an error
mongoose.connection.on('error', (err) => {
logger.error('Mongoose default connection error: ' + err);
});
// When the connection is disconnected
mongoose.connection.on('disconnected', () => {
logger.info('Mongoose default connection disconnected');
});
// If the Node process ends, close the Mongoose connection
process.on('SIGINT', () => {
mongoose.connection.close(() => {
logger.info('Mongoose default connection disconnected through app termination');
process.exit(0);
});
});

View File

@ -0,0 +1,91 @@
import { Document, Model, model, Schema } from 'mongoose';
import { IArticle } from '../../interfaces/article-interface';
import IUserModel, { User } from './user.model';
import mongooseUniqueValidator from 'mongoose-unique-validator';
import slugify from "slugify";
export default interface IArticleModel extends IArticle, Document {
toJSONFor(user: IUserModel): any;
slugify(): string;
updateFavoriteCount(): Promise<IArticleModel>;
}
const ArticleSchema = new Schema({
slug : {
type : Schema.Types.String,
lowercase: true,
unique : true
},
title : {
type: Schema.Types.String
},
description : {
type: Schema.Types.String
},
body : {
type: Schema.Types.String
},
tagList : [
{
type: Schema.Types.String
}
],
favoritesCount: {
type : Schema.Types.Number,
default: 0
},
author : {
type: Schema.Types.ObjectId,
ref : 'User'
},
comments : [
{
type: Schema.Types.ObjectId,
ref : 'Comment'
}
],
}, {
timestamps: true
});
ArticleSchema.methods.slugify = function () {
this.slug = slugify(this.title) + '-' + (Math.random() * Math.pow(36, 6) | 0).toString(36);
};
ArticleSchema.plugin(mongooseUniqueValidator, {message: 'is already taken'});
ArticleSchema.pre<IArticleModel>('validate', (function (next) {
if (!this.slug) {
this.slugify();
}
next();
}));
ArticleSchema.methods.updateFavoriteCount = function () {
const article = this;
return User.count({favorites: {$in: [article._id]}}).then(function (count) {
article.favoritesCount = count;
return article.save();
});
};
ArticleSchema.methods.toJSONFor = function (user: IUserModel) {
return {
slug : this.slug,
title : this.title,
description : this.description,
body : this.body,
createdAt : this.createdAt,
updatedAt : this.updatedAt,
tagList : this.tagList,
favorited : user ? user.isFavorite(this._id) : false,
favoritesCount: this.favoritesCount,
author : this.author.toProfileJSONFor(user)
};
};
export const Article: Model<IArticleModel> = model<IArticleModel>('Article', ArticleSchema);

View File

@ -0,0 +1,32 @@
import { Document, model, Model, Schema } from "mongoose";
import { IComment } from "../../interfaces/comment-interface";
import IUserModel from "./user.model";
export default interface ICommentModel extends IComment, Document {
toJSONFor(user: IUserModel): any;
}
const CommentSchema = new Schema({
body : {
type: Schema.Types.String
},
author : {
type: Schema.Types.ObjectId,
ref : 'User'
},
article: {
type: Schema.Types.ObjectId,
ref : 'Article'
}
}, {timestamps: true});
CommentSchema.methods.toJSONFor = function (user: IUserModel) {
return {
id : this._id,
body : this.body,
createdAt: this.createdAt,
author : this.author.toProfileJSONFor(user)
};
};
export const Comment: Model<ICommentModel> = model<ICommentModel>('Comment', CommentSchema);

View File

@ -0,0 +1,154 @@
import { Document, Model, model, Schema } from 'mongoose';
import { IUser } from '../../interfaces/user-interface';
import * as jwt from 'jsonwebtoken';
import * as crypto from 'crypto';
import { JWT_SECRET } from "../../utilities/secrets";
import mongooseUniqueValidator = require("mongoose-unique-validator");
export default interface IUserModel extends IUser, Document {
token?: string;
favorites: [Schema.Types.ObjectId];
generateJWT(): string;
toAuthJSON(): any;
setPassword(password: string): void;
validPassword(password: string): boolean;
toProfileJSONFor(user: IUserModel): any;
isFollowing(id: string): boolean;
follow(id: string): Promise<IUser>;
unfollow(id: string): Promise<IUser>;
favorite(id: string): Promise<IUser>;
unfavorite(id: string): Promise<IUser>;
isFavorite(id: string): boolean;
}
// ISSUE: Own every parameter and any missing dependencies
const UserSchema = new Schema({
username : {
type : Schema.Types.String,
lowercase: true,
unique : true,
required : [true, "can't be blank"],
match : [/^[a-zA-Z0-9]+$/, 'is invalid'],
index : true
},
email : {
type : Schema.Types.String,
lowercase: true,
unique : true,
required : [true, "can't be blank"],
match : [/\S+@\S+\.\S+/, 'is invalid'],
index : true
},
bio : {
type: Schema.Types.String
},
image : {
type: Schema.Types.String
},
favorites: [
{
type: Schema.Types.ObjectId,
ref : 'Article'
}
],
following: [
{
type: Schema.Types.ObjectId,
ref : 'User'
}
],
hash : {
type: Schema.Types.String
},
salt : {
type: Schema.Types.String
},
}, {timestamps: true});
UserSchema.plugin(mongooseUniqueValidator, {message: 'is already taken.'});
UserSchema.methods.validPassword = function (password: string): boolean {
const hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
return this.hash === hash;
};
UserSchema.methods.setPassword = function (password: string) {
this.salt = crypto.randomBytes(16).toString('hex');
this.hash = crypto.pbkdf2Sync(password, this.salt, 10000, 512, 'sha512').toString('hex');
};
UserSchema.methods.generateJWT = function (): string {
const today = new Date();
const exp = new Date(today);
exp.setDate(today.getDate() + 60);
return jwt.sign({
id : this._id,
username: this.username,
exp : exp.getTime() / 1000,
}, JWT_SECRET);
};
UserSchema.methods.toAuthJSON = function (): any {
return {
username: this.username,
email : this.email,
token : this.generateJWT(),
bio : this.bio,
image : this.image
};
};
UserSchema.methods.toProfileJSONFor = function (user: IUserModel) {
return {
username : this.username,
bio : this.bio,
image : this.image || 'https://static.productionready.io/images/smiley-cyrus.jpg',
following: user ? user.isFollowing(this._id) : false
};
};
UserSchema.methods.favorite = function (id: string) {
if (this.favorites.indexOf(id) === -1) {
this.favorites.push(id);
}
return this.save();
};
UserSchema.methods.unfavorite = function (id: string) {
this.favorites.remove(id);
return this.save();
};
UserSchema.methods.isFavorite = function (id: string) {
return this.favorites.some(function (favoriteId: string) {
return favoriteId.toString() === id.toString();
});
};
UserSchema.methods.follow = function (id: string) {
if (this.following.indexOf(id) === -1) {
this.following.push(id);
}
return this.save();
};
UserSchema.methods.unfollow = function (id: string) {
this.following.remove(id);
return this.save();
};
UserSchema.methods.isFollowing = function (id: string) {
return this.following.some(function (followId: string) {
return followId.toString() === id.toString();
});
};
export const User: Model<IUserModel> = model<IUserModel>('User', UserSchema);

View File

@ -0,0 +1,24 @@
import User from '../database/models/user.model';
import Comment from '../database/models/comment.model';
export interface IArticle {
slug: string;
title: string;
description: string;
body: string;
tagList?: [string];
createdAt: Date;
updatedAt: Date;
favorited: boolean;
favoritesCount: number;
author: User;
comments: Comment[]
}
export interface IQuery {
tagList: { $in: any[] };
author: string;
_id: { $in: any[] };
}

View File

@ -0,0 +1,8 @@
import User from "../database/models/user.model";
import Article from "../database/models/article.model";
export interface IComment {
body: string;
author: User;
article: Article;
}

View File

@ -0,0 +1,17 @@
import User from "../database/models/user.model";
export interface IUser {
email: string;
username: string;
bio?: string;
image?: string;
following: User[];
}
export interface IProfile {
username: string;
bio: string;
image: string;
following: boolean;
}

View File

@ -0,0 +1,302 @@
import { Request, Response, Router } from 'express';
import { authentication } from '../utilities/authentication';
import { User } from '../database/models/user.model';
import { Article } from "../database/models/article.model";
import { Comment } from "../database/models/comment.model";
const router: Router = Router();
// Preload article objects on routes with ':article'
router.param('article', function (req: Request, res: Response, next, slug) {
Article.findOne({slug: slug})
.populate('author')
.then(function (article) {
if (!article) {
return res.sendStatus(404);
}
req.article = article;
return next();
}).catch(next);
});
router.param('comment', function (req: Request, res: Response, next, id) {
Comment.findById(id).then(function (comment) {
if (!comment) {
return res.sendStatus(404);
}
req.comment = comment;
return next();
}).catch(next);
});
router.get('/', authentication.optional, function (req: Request, res: Response, next) {
const query: any = {};
let limit = 20;
let offset = 0;
if (typeof req.query.limit !== 'undefined') {
limit = parseInt(req.query.limit as string);
}
if (typeof req.query.offset !== 'undefined') {
offset = parseInt(req.query.offset as string);
}
if (typeof req.query.tag !== 'undefined') {
query.tagList = {"$in": [req.query.tag]};
}
Promise.all([
req.query.author ? User.findOne({username: req.query.author as string}) : null,
req.query.favorited ? User.findOne({username: req.query.favorited as string}) : null
]).then(function (results) {
const author = results[0];
const favoriter = results[1];
if (author) {
query.author = author._id;
}
if (favoriter) {
query._id = {$in: favoriter.favorites};
} else if (req.query.favorited) {
query._id = {$in: []};
}
return Promise.all([
Article.find(query)
.limit(Number(limit))
.skip(Number(offset))
.sort({createdAt: 'desc'})
.populate('author')
.exec(),
Article.count(query).exec(),
req.payload ? User.findById(req.payload.id) : null,
]).then(function (results) {
const articles = results[0];
const articlesCount = results[1];
const user = results[2];
return res.json({
articles : articles.map(function (article) {
return article.toJSONFor(user);
}),
articlesCount: articlesCount
});
});
}).catch(next);
});
router.get('/feed', authentication.required, function (req: Request, res: Response, next) {
let limit = 20;
let offset = 0;
if (typeof req.query.limit !== 'undefined') {
limit = parseInt(req.query.limit as string);
}
if (typeof req.query.offset !== 'undefined') {
offset = parseInt(req.query.offset as string);
}
User.findById(req.payload.id).then(function (user) {
if (!user) {
return res.sendStatus(401);
}
Promise.all([
Article.find({author: {$in: user.following}})
.limit(Number(limit))
.skip(Number(offset))
.populate('author')
.exec(),
Article.count({author: {$in: user.following}})
]).then(function (results) {
const articles = results[0];
const articlesCount = results[1];
return res.json({
articles : articles.map(function (article) {
return article.toJSONFor(user);
}),
articlesCount: articlesCount
});
}).catch(next);
});
});
router.post('/', authentication.required, function (req: Request, res: Response, next) {
User.findById(req.payload.id).then(function (user) {
if (!user) {
return res.sendStatus(401);
}
const article = new Article(req.body.article);
article.author = user;
return article.save().then(function () {
console.log(article.author);
return res.json({article: article.toJSONFor(user)});
});
}).catch(next);
});
// return a article
router.get('/:article', authentication.optional, function (req: Request, res: Response, next) {
Promise.all([
req.payload ? User.findById(req.payload.id) : null,
req.article.populate('author').execPopulate()
]).then(function (results) {
const user = results[0];
return res.json({article: req.article.toJSONFor(user)});
}).catch(next);
});
// update article
router.put('/:article', authentication.required, function (req: Request, res: Response, next) {
User.findById(req.payload.id).then(function (user) {
if (req.article.author._id.toString() === req.payload.id.toString()) {
if (typeof req.body.article.title !== 'undefined') {
req.article.title = req.body.article.title;
}
if (typeof req.body.article.description !== 'undefined') {
req.article.description = req.body.article.description;
}
if (typeof req.body.article.body !== 'undefined') {
req.article.body = req.body.article.body;
}
if (typeof req.body.article.tagList !== 'undefined') {
req.article.tagList = req.body.article.tagList
}
req.article.save().then(function (article) {
return res.json({article: article.toJSONFor(user)});
}).catch(next);
} else {
return res.sendStatus(403);
}
});
});
// delete article
router.delete('/:article', authentication.required, function (req: Request, res: Response, next) {
User.findById(req.payload.id).then(function (user) {
if (!user) {
return res.sendStatus(401);
}
if (req.article.author._id.toString() === req.payload.id.toString()) {
return req.article.remove().then(function () {
return res.sendStatus(204);
});
} else {
return res.sendStatus(403);
}
}).catch(next);
});
// Favorite an article
router.post('/:article/favorite', authentication.required, function (req: Request, res: Response, next) {
const articleId = req.article._id;
User.findById(req.payload.id).then(function (user) {
if (!user) {
return res.sendStatus(401);
}
return user.favorite(articleId).then(function () {
return req.article.updateFavoriteCount().then(function (article) {
return res.json({article: article.toJSONFor(user)});
});
});
}).catch(next);
});
// Unfavorite an article
router.delete('/:article/favorite', authentication.required, function (req: Request, res: Response, next) {
const articleId = req.article._id;
User.findById(req.payload.id).then(function (user) {
if (!user) {
return res.sendStatus(401);
}
return user.unfavorite(articleId).then(function () {
return req.article.updateFavoriteCount().then(function (article) {
return res.json({article: article.toJSONFor(user)});
});
});
}).catch(next);
});
// return an article's comments
router.get('/:article/comments', authentication.optional, function (req: Request, res: Response, next) {
Promise.resolve(req.payload ? User.findById(req.payload.id) : null).then(function (user) {
return req.article.populate({
path : 'comments',
populate: {
path: 'author'
},
options : {
sort: {
createdAt: 'desc'
}
}
}).execPopulate().then(function (article) {
return res.json({
comments: req.article.comments.map(function (comment) {
return comment.toJSONFor(user);
})
});
});
}).catch(next);
});
// create a new comment
router.post('/:article/comments', authentication.required, function (req: Request, res: Response, next) {
User.findById(req.payload.id)
// @ts-ignore
.then(function (user) {
if (!user) {
return res.sendStatus(401);
}
const comment = new Comment(req.body.comment);
comment.article = req.article;
comment.author = user;
return comment.save().then(function () {
req.article.comments.push(comment);
return req.article.save().then(function (article) {
res.json({comment: comment.toJSONFor(user)});
});
});
}).catch(next);
});
router.delete('/:article/comments/:comment', authentication.required, function (req: Request, res: Response, next) {
if (req.comment.author.toString() === req.payload.id.toString()) {
// @ts-ignore
req.article.comments.remove(req.comment._id);
req.article.save()
.then(() => Comment.find({_id: req.comment._id}).remove().exec())
.then(function () {
res.sendStatus(204);
});
} else {
res.sendStatus(403);
}
});
export const ArticlesRoutes: Router = router;

View File

@ -1,7 +1,5 @@
import { Router } from 'express';
import { TagRoutes } from './tag-routes';
import { UserRoutes } from './user-routes';
import { UsersRoutes } from './users-routes';
import { ProfilesRoutes } from './profiles-routes';
import { ArticlesRoutes } from './articles-routes';
@ -11,8 +9,7 @@ const router: Router = Router();
router.use('/tags', TagRoutes);
router.use('/user', UserRoutes);
router.use('/users', UsersRoutes);
router.use('/', UsersRoutes);
router.use('/profiles', ProfilesRoutes);
router.use('/articles', ArticlesRoutes);

View File

@ -0,0 +1,82 @@
import { NextFunction, Request, Response, Router } from 'express';
import IUserModel, { User } from '../database/models/user.model';
import { authentication } from '../utilities/authentication';
const router: Router = Router();
/**
* PARAM :username
*/
router.param('username', (req: Request, res: Response, next: NextFunction, username: string) => {
User
.findOne({username})
.then((user: IUserModel) => {
req.profile = user;
return next();
})
.catch(next);
});
/**
* GET /api/profiles/:username
*/
router.get('/:username', authentication.optional, (req: Request, res: Response, next: NextFunction) => {
// If authentication was performed and was successful look up the profile relative to authenticated user
if (req.payload) {
User
.findById(req.payload.id)
.then((user: IUserModel) => {
res.status(200).json({profile: req.profile.toProfileJSONFor(user)});
})
.catch(next);
// If authentication was NOT performed or successful look up profile relative to that same user (following = false)
} else {
res.status(200).json({profile: req.profile.toProfileJSONFor(req.profile)});
}
});
/**
* POST /api/profiles/:username/follow
*/
router.post('/:username/follow', authentication.required, (req: Request, res: Response, next: NextFunction) => {
const profileId = req.profile._id;
User
.findById(req.payload.id)
.then((user: IUserModel) => {
return user.follow(profileId).then(() => {
return res.json({profile: req.profile.toProfileJSONFor(user)});
});
})
.catch(next);
});
/**
* DELETE /api/profiles/:username/follow
*/
router.delete('/:username/follow', authentication.required, (req: Request, res: Response, next: NextFunction) => {
const profileId = req.profile._id;
User
.findById(req.payload.id)
.then((user: IUserModel) => {
return user.unfollow(profileId).then(() => {
return res.json({profile: req.profile.toProfileJSONFor(user)});
});
})
.catch(next);
});
export const ProfilesRoutes: Router = router;

22
src/routes/tag-routes.ts Normal file
View File

@ -0,0 +1,22 @@
import { Article } from '../database/models/article.model';
import { NextFunction, Request, Response, Router } from 'express';
const router: Router = Router();
// FIXME: Rewrite to pull from Articles...
router.get('/', (req: Request, res: Response, next: NextFunction) => {
Article
.find()
.distinct('tagList')
.then((tagsArray: [string]) => {
return res.json({tags: tagsArray});
})
.catch(next);
});
export const TagRoutes: Router = router;

118
src/routes/users-routes.ts Normal file
View File

@ -0,0 +1,118 @@
import { NextFunction, Request, Response, Router } from 'express';
import IUserModel, { User } from '../database/models/user.model';
import passport from 'passport';
import { authentication } from "../utilities/authentication";
const router: Router = Router();
/**
* GET /api/user
*/
router.get('/user', authentication.required, (req: Request, res: Response, next: NextFunction) => {
User
.findById(req.payload.id)
.then((user: IUserModel) => {
res.status(200).json({user: user.toAuthJSON()});
}
)
.catch(next);
}
);
/**
* PUT /api/user
*/
router.put('/user', authentication.required, (req: Request, res: Response, next: NextFunction) => {
User
.findById(req.payload.id)
.then((user: IUserModel) => {
if (!user) {
return res.sendStatus(401);
}
// Update only fields that have values:
// ISSUE: DRY out code?
if (typeof req.body.user.email !== 'undefined') {
user.email = req.body.user.email;
}
if (typeof req.body.user.username !== 'undefined') {
user.username = req.body.user.username;
}
if (typeof req.body.user.password !== 'undefined') {
user.setPassword(req.body.user.password);
}
if (typeof req.body.user.image !== 'undefined') {
user.image = req.body.user.image;
}
if (typeof req.body.user.bio !== 'undefined') {
user.bio = req.body.user.bio;
}
return user.save().then(() => {
return res.json({user: user.toAuthJSON()});
});
})
.catch(next);
}
);
/**
* POST /api/users
*/
router.post('/users', (req: Request, res: Response, next: NextFunction) => {
const user: IUserModel = new User();
user.username = req.body.user.username;
user.email = req.body.user.email;
user.setPassword(req.body.user.password);
user.bio = '';
user.image = '';
return user.save()
.then(() => {
return res.json({user: user.toAuthJSON()});
})
.catch(next);
});
// ISSUE: How does this work with the trailing (req, res, next)?
/**
* POST /api/users/login
*/
router.post('/users/login', (req: Request, res: Response, next: NextFunction) => {
if (!req.body.user.email) {
return res.status(422).json({errors: {email: "Can't be blank"}});
}
if (!req.body.user.password) {
return res.status(422).json({errors: {password: "Can't be blank"}});
}
passport.authenticate('local', {session: false}, (err, user, info) => {
if (err) {
return next(err);
}
if (user) {
user.token = user.generateJWT();
return res.json({user: user.toAuthJSON()});
} else {
return res.status(422).json(info);
}
})(req, res, next);
});
export const UsersRoutes: Router = router;

10
src/server.ts Normal file
View File

@ -0,0 +1,10 @@
import app from './app';
import { APP_PORT } from "./utilities/secrets";
import logger from "./utilities/logger";
app
.listen(APP_PORT, () => {
logger.info(`server running on port : ${APP_PORT}`);
console.log(`server running on port : ${APP_PORT}`);
})
.on('error', (e) => logger.error(e));

View File

@ -0,0 +1,18 @@
import Article from "../database/models/article.model";
import Comment from "../database/models/comment.model";
import User from "../database/models/user.model";
declare module "express" {
export interface Request {
article?: Article;
comment?: Comment;
profile?: User;
payload?: {
id: string,
username: string,
exp: number,
iat: number
};
}
}

View File

@ -0,0 +1,55 @@
import { Request } from 'express';
const jwt = require('express-jwt');
import { JWT_SECRET } from "./secrets";
function getTokenFromHeader(req: Request): string | null {
const headerAuth: string | string[] = req.headers.authorization;
if (headerAuth !== undefined && headerAuth !== null) {
if (Array.isArray(headerAuth)) {
return splitToken(headerAuth[0]);
} else {
return splitToken(headerAuth);
}
} else {
return null;
}
}
function splitToken(authString: string) {
if (authString.split(' ')[0] === 'Token') {
return authString.split(' ')[1];
} else {
return null;
}
}
const auth = {
required: jwt({
credentialsRequired: true,
secret : JWT_SECRET,
getToken : getTokenFromHeader,
userProperty : 'payload',
// @ts-ignore
algorithms : ['HS256']
}),
optional: jwt({
credentialsRequired: false,
secret : JWT_SECRET,
getToken : getTokenFromHeader,
userProperty : 'payload',
algorithms : ['HS256']
})
};
export const authentication = auth;

View File

@ -0,0 +1,41 @@
import { Application, Request, Response } from 'express';
import { IS_PRODUCTION } from "./secrets";
import logger from "./logger";
export function loadErrorHandlers(app: Application) {
// catch 404 errors and forward to error handler
app.use((req, res, next) => {
interface BetterError extends Error {
status?: number;
}
const err: BetterError = new Error('Not Found');
err.status = 404;
next(err);
});
app.use((err: any, req: Request, res: Response, next: any) => {
if (err.name === 'ValidationError') {
return res.status(422).json({
errors: Object.keys(err.errors).reduce(function (errors: any, key: string) {
errors[key] = err.errors[key].message;
return errors;
}, {})
});
}
logger.error(err);
res.status(err.status || 500);
res.json({
errors: {
message: err.message,
error : !IS_PRODUCTION ? err : {}
}
});
});
}

42
src/utilities/logger.ts Normal file
View File

@ -0,0 +1,42 @@
import { createLogger, format, transports } from 'winston';
import * as fs from 'fs';
import DailyRotateFile from 'winston-daily-rotate-file';
import { ENVIRONMENT, LOG_DIRECTORY } from "./secrets";
let dir = LOG_DIRECTORY;
// create directory if it is not present
if (!fs.existsSync(dir)) {
// Create the directory if it does not exist
fs.mkdirSync(dir);
}
const logLevel = ENVIRONMENT === 'dev' ? 'debug' : 'warn';
const options = {
file: {
level : logLevel,
filename : dir + '/%DATE%.log',
datePattern : 'YYYY-MM-DD',
zippedArchive : true,
timestamp : true,
handleExceptions : true,
humanReadableUnhandledException: true,
prettyPrint : true,
json : true,
maxSize : '20m',
colorize : true,
maxFiles : '14d',
},
};
export default createLogger({
transports : [
new transports.Console({
stderrLevels: ["info", "error"],
format: format.combine(format.errors({stack: true}), format.prettyPrint()),
}),
],
exceptionHandlers: [new DailyRotateFile(options.file)],
exitOnError : false, // do not exit on handled exceptions
});

30
src/utilities/passport.ts Normal file
View File

@ -0,0 +1,30 @@
import passport from 'passport';
import { User } from '../database/models/user.model';
import passportLocal from 'passport-local';
const LocalStrategy = passportLocal.Strategy;
passport.use(new LocalStrategy({
// Strategy is based on username & password. Substitute email for username.
usernameField: 'user[email]',
passwordField: 'user[password]'
},
(email, password, done) => {
User
.findOne({email})
.then(user => {
if (!user) {
return done(null, false, {message: 'Incorrect email.'});
}
if (!user.validPassword(password)) {
return done(null, false, {message: 'Incorrect password.'});
}
return done(null, user);
})
.catch(done);
}));

19
src/utilities/secrets.ts Normal file
View File

@ -0,0 +1,19 @@
import * as dotenv from "dotenv";
import * as _ from "lodash";
import * as path from "path";
dotenv.config({path: ".env"});
export const ENVIRONMENT = _.defaultTo(process.env.APP_ENV, "dev");
export const IS_PRODUCTION = ENVIRONMENT === "production";
export const APP_PORT = _.defaultTo(parseInt(process.env.APP_PORT), 3000);
export const LOG_DIRECTORY = _.defaultTo(process.env.LOG_DIRECTORY, path.resolve('logs'));
export const JWT_SECRET = _.defaultTo(process.env.JWT_SECRET, "secret");
export const SESSION_SECRET = _.defaultTo(process.env.SESSION_SECRET, "secret");
export const DB = {
USER : _.defaultTo(process.env.DB_USER, "root"),
PASSWORD: _.defaultTo(process.env.DB_USER_PWD, "secret"),
HOST : _.defaultTo(process.env.DB_HOST, "localhost"),
NAME : _.defaultTo(process.env.DB_NAME, "conduit"),
PORT : _.defaultTo(parseInt(process.env.DB_PORT), 27017),
}

1900
tests/api-tests.postman.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,14 @@
{
"id": "4aa60b52-97fc-456d-4d4f-14a350e95dff",
"name": "Conduit API Tests - Environment",
"values": [{
"enabled": true,
"key": "apiUrl",
"value": "http://localhost:3000/api",
"type": "text"
}],
"timestamp": 1505871382668,
"_postman_variable_scope": "environment",
"_postman_exported_at": "2017-09-20T01:36:34.835Z",
"_postman_exported_using": "Postman/5.2.0"
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es2017",
"noImplicitAny": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"sourceMap": true,
"outDir": "build",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
]
}
},
"include": ["src/**/*"]
}

60
tslint.json Normal file
View File

@ -0,0 +1,60 @@
{
"rules": {
"class-name": true,
"comment-format": [
true,
"check-space"
],
"indent": [
true,
"spaces"
],
"one-line": [
true,
"check-open-brace",
"check-whitespace"
],
"no-var-keyword": true,
"quotemark": [
true,
"single",
"avoid-escape"
],
"semicolon": [
true,
"always",
"ignore-bound-class-methods"
],
"whitespace": [
true,
"check-branch",
"check-decl",
"check-operator",
"check-module",
"check-separator",
"check-type"
],
"typedef-whitespace": [
true,
{
"call-signature": "nospace",
"index-signature": "nospace",
"parameter": "nospace",
"property-declaration": "nospace",
"variable-declaration": "nospace"
},
{
"call-signature": "onespace",
"index-signature": "onespace",
"parameter": "onespace",
"property-declaration": "onespace",
"variable-declaration": "onespace"
}
],
"no-internal-module": true,
"no-trailing-whitespace": true,
"no-null-keyword": false,
"prefer-const": true,
"jsdoc-format": true
}
}