Merge master

This commit is contained in:
Tom Moor
2017-10-21 21:12:56 -07:00
106 changed files with 1259 additions and 1044 deletions

View File

@@ -55,6 +55,10 @@ router.post('auth.slack', async ctx => {
expires: new Date('2100'),
});
// Update user's avatar
await user.updateAvatar();
await user.save();
ctx.body = {
data: {
user: await presentUser(ctx, user),

View File

@@ -4,10 +4,16 @@ import httpErrors from 'http-errors';
import auth from './middlewares/authentication';
import pagination from './middlewares/pagination';
import { presentDocument } from '../presenters';
import { Document, Collection, Star, View } from '../models';
import { presentDocument, presentRevision } from '../presenters';
import { Document, Collection, Star, View, Revision } from '../models';
const authDocumentForUser = (ctx, document) => {
const user = ctx.state.user;
if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound();
};
const router = new Router();
router.post('documents.list', auth(), pagination(), async ctx => {
let { sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
@@ -101,23 +107,38 @@ router.post('documents.info', auth(), async ctx => {
ctx.assertPresent(id, 'id is required');
const document = await Document.findById(id);
if (!document) throw httpErrors.NotFound();
// Don't expose private documents outside the team
if (document.private) {
if (!ctx.state.user) throw httpErrors.NotFound();
const user = await ctx.state.user;
if (document.teamId !== user.teamId) {
throw httpErrors.NotFound();
}
}
authDocumentForUser(ctx, document);
ctx.body = {
data: await presentDocument(ctx, document),
};
});
router.post('documents.revisions', auth(), pagination(), async ctx => {
let { id, sort = 'updatedAt', direction } = ctx.body;
if (direction !== 'ASC') direction = 'DESC';
ctx.assertPresent(id, 'id is required');
const document = await Document.findById(id);
authDocumentForUser(ctx, document);
const revisions = await Revision.findAll({
where: { documentId: id },
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
});
const data = await Promise.all(
revisions.map(revision => presentRevision(ctx, revision))
);
ctx.body = {
pagination: ctx.state.pagination,
data,
};
});
router.post('documents.search', auth(), async ctx => {
const { query } = ctx.body;
ctx.assertPresent(query, 'query is required');
@@ -142,8 +163,7 @@ router.post('documents.star', auth(), async ctx => {
const user = await ctx.state.user;
const document = await Document.findById(id);
if (!document || document.teamId !== user.teamId)
throw httpErrors.BadRequest();
authDocumentForUser(ctx, document);
await Star.findOrCreate({
where: { documentId: document.id, userId: user.id },
@@ -156,8 +176,7 @@ router.post('documents.unstar', auth(), async ctx => {
const user = await ctx.state.user;
const document = await Document.findById(id);
if (!document || document.teamId !== user.teamId)
throw httpErrors.BadRequest();
authDocumentForUser(ctx, document);
await Star.destroy({
where: { documentId: document.id, userId: user.id },
@@ -228,7 +247,7 @@ router.post('documents.update', auth(), async ctx => {
const document = await Document.findById(id);
const collection = document.collection;
if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound();
authDocumentForUser(ctx, document);
// Update document
if (title) document.title = title;
@@ -254,15 +273,14 @@ router.post('documents.move', auth(), async ctx => {
ctx.assertUuid(parentDocument, 'parentDocument must be an uuid');
if (index) ctx.assertPositiveInteger(index, 'index must be an integer (>=0)');
const user = ctx.state.user;
const document = await Document.findById(id);
const collection = await Collection.findById(document.atlasId);
authDocumentForUser(ctx, document);
if (collection.type !== 'atlas')
throw httpErrors.BadRequest("This document can't be moved");
if (!document || document.teamId !== user.teamId) throw httpErrors.NotFound();
// Set parent document
if (parentDocument) {
const parent = await Document.findById(parentDocument);
@@ -292,12 +310,10 @@ router.post('documents.delete', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const document = await Document.findById(id);
const collection = await Collection.findById(document.atlasId);
if (!document || document.teamId !== user.teamId)
throw httpErrors.BadRequest();
authDocumentForUser(ctx, document);
if (collection.type === 'atlas') {
// Don't allow deletion of root docs

View File

@@ -43,6 +43,24 @@ describe('#documents.list', async () => {
});
});
describe('#documents.revision', async () => {
it("should return document's revisions", async () => {
const { user, document } = await seed();
const res = await server.post('/api/documents.revisions', {
body: {
token: user.getJwtToken(),
id: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).not.toEqual(document.id);
expect(body.data[0].title).toEqual(document.title);
});
});
describe('#documents.search', async () => {
it('should return results', async () => {
const { user } = await seed();

View File

@@ -65,8 +65,8 @@ if (process.env.NODE_ENV === 'development') {
app.use(logger());
}
if (process.env.NODE_ENV === 'production') {
bugsnag.register('ad7a85f99b1b9324a31e16732cdf3192');
if (process.env.NODE_ENV === 'production' && process.env.BUGSNAG_KEY) {
bugsnag.register(process.env.BUGSNAG_KEY);
app.on('error', bugsnag.koaHandler);
}

View File

@@ -0,0 +1,12 @@
module.exports = {
up: function(queryInterface, Sequelize) {
queryInterface.removeColumn('collections', 'navigationTree');
},
down: function(queryInterface, Sequelize) {
queryInterface.addColumn('collections', 'navigationTree', {
type: Sequelize.JSONB,
allowNull: true,
});
},
};

View File

@@ -0,0 +1,23 @@
module.exports = {
up: function(queryInterface, Sequelize) {
queryInterface.removeColumn('documents', 'html');
queryInterface.removeColumn('documents', 'preview');
queryInterface.removeColumn('revisions', 'html');
queryInterface.removeColumn('revisions', 'preview');
},
down: function(queryInterface, Sequelize) {
queryInterface.addColumn('documents', 'html', {
type: Sequelize.TEXT,
});
queryInterface.addColumn('documents', 'preview', {
type: Sequelize.TEXT,
});
queryInterface.addColumn('revisions', 'html', {
type: Sequelize.TEXT,
});
queryInterface.addColumn('revisions', 'preview', {
type: Sequelize.TEXT,
});
},
};

View File

@@ -0,0 +1,12 @@
module.exports = {
up: function(queryInterface, Sequelize) {
queryInterface.addColumn('users', 'avatarUrl', {
type: Sequelize.TEXT,
allowNull: true,
});
},
down: function(queryInterface, Sequelize) {
queryInterface.removeColumn('users', 'avatarUrl');
},
};

View File

@@ -29,7 +29,6 @@ const Collection = sequelize.define(
creatorId: DataTypes.UUID,
/* type: atlas */
navigationTree: DataTypes.JSONB, // legacy
documentStructure: DataTypes.JSONB,
},
{
@@ -98,28 +97,6 @@ Collection.prototype.getUrl = function() {
return `/collections/${this.id}`;
};
Collection.prototype.getDocumentsStructure = async function() {
// Lazy fill this.documentStructure - TMP for internal release
if (!this.documentStructure) {
this.documentStructure = this.navigationTree.children;
// Remove parent references from all root documents
await this.navigationTree.children.forEach(async ({ id }) => {
const document = await Document.findById(id);
document.parentDocumentId = null;
await document.save();
});
// Remove root document
const rootDocument = await Document.findById(this.navigationTree.id);
await rootDocument.destroy();
await this.save();
}
return this.documentStructure;
};
Collection.prototype.addDocumentToStructure = async function(
document,
index,

View File

@@ -2,12 +2,9 @@
import slug from 'slug';
import _ from 'lodash';
import randomstring from 'randomstring';
import emojiRegex from 'emoji-regex';
import isUUID from 'validator/lib/isUUID';
import { DataTypes, sequelize } from '../sequelize';
import { convertToMarkdown } from '../../frontend/utils/markdown';
import { truncateMarkdown } from '../utils/truncate';
import parseTitle from '../../shared/parseTitle';
import Revision from './Revision';
@@ -25,8 +22,6 @@ const createRevision = doc => {
return Revision.create({
title: doc.title,
text: doc.text,
html: doc.html,
preview: doc.preview,
userId: doc.lastModifiedById,
documentId: doc.id,
});
@@ -40,8 +35,6 @@ const beforeSave = async doc => {
const { emoji } = parseTitle(doc.text);
doc.emoji = emoji;
doc.html = convertToMarkdown(doc.text);
doc.preview = truncateMarkdown(doc.text, 160);
doc.revisionCount += 1;
// Collaborators
@@ -74,8 +67,6 @@ const Document = sequelize.define(
private: { type: DataTypes.BOOLEAN, defaultValue: true },
title: DataTypes.STRING,
text: DataTypes.TEXT,
html: DataTypes.TEXT,
preview: DataTypes.TEXT,
revisionCount: { type: DataTypes.INTEGER, defaultValue: 0 },
parentDocumentId: DataTypes.UUID,
createdById: {

View File

@@ -9,8 +9,6 @@ const Revision = sequelize.define('revision', {
},
title: DataTypes.STRING,
text: DataTypes.TEXT,
html: DataTypes.TEXT,
preview: DataTypes.TEXT,
userId: {
type: 'UUID',

View File

@@ -1,7 +1,9 @@
// @flow
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import uuid from 'uuid';
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
import { uploadToS3FromUrl } from '../utils/s3';
import JWT from 'jsonwebtoken';
@@ -18,6 +20,7 @@ const User = sequelize.define(
email: { type: DataTypes.STRING },
username: { type: DataTypes.STRING },
name: DataTypes.STRING,
avatarUrl: { type: DataTypes.STRING, allowNull: true },
password: DataTypes.VIRTUAL,
passwordDigest: DataTypes.STRING,
isAdmin: DataTypes.BOOLEAN,
@@ -66,6 +69,12 @@ User.prototype.verifyPassword = function(password) {
});
});
};
User.prototype.updateAvatar = async function() {
this.avatarUrl = await uploadToS3FromUrl(
this.slackData.image_192,
`avatars/${this.id}/${uuid.v4()}`
);
};
const setRandomJwtSecret = model => {
model.jwtSecret = crypto.randomBytes(64).toString('hex');

View File

@@ -19,7 +19,7 @@ async function present(ctx: Object, collection: Collection) {
};
if (collection.type === 'atlas') {
data.documents = await collection.getDocumentsStructure();
data.documents = collection.documentStructure;
}
if (collection.documents) {

View File

@@ -21,8 +21,6 @@ async function present(ctx: Object, document: Document, options: ?Options) {
private: document.private,
title: document.title,
text: document.text,
html: document.html,
preview: document.preview,
emoji: document.emoji,
createdAt: document.createdAt,
createdBy: presentUser(ctx, document.createdBy),

View File

@@ -2,6 +2,7 @@
import presentUser from './user';
import presentView from './view';
import presentDocument from './document';
import presentRevision from './revision';
import presentCollection from './collection';
import presentApiKey from './apiKey';
import presentTeam from './team';
@@ -10,6 +11,7 @@ export {
presentUser,
presentView,
presentDocument,
presentRevision,
presentCollection,
presentApiKey,
presentTeam,

View File

@@ -0,0 +1,15 @@
// @flow
import _ from 'lodash';
import { Revision } from '../models';
function present(ctx: Object, revision: Revision) {
return {
id: revision.id,
title: revision.title,
text: revision.text,
createdAt: revision.createdAt,
updatedAt: revision.updatedAt,
};
}
export default present;

View File

@@ -8,7 +8,8 @@ function present(ctx: Object, user: User) {
id: user.id,
username: user.username,
name: user.name,
avatarUrl: user.slackData ? user.slackData.image_192 : null,
avatarUrl: user.avatarUrl ||
(user.slackData ? user.slackData.image_192 : null),
};
}

View File

@@ -15,7 +15,6 @@ export async function request(endpoint: string, body: Object) {
} catch (e) {
throw httpErrors.BadRequest();
}
console.log('DATA', data);
if (!data.ok) throw httpErrors.BadRequest(data.error);
return data;
@@ -28,7 +27,7 @@ export async function oauthAccess(
return request('oauth.access', {
client_id: process.env.SLACK_KEY,
client_secret: process.env.SLACK_SECRET,
redirect_uri: `${process.env.URL || ''}/auth/slack`,
redirect_uri,
code,
});
}

View File

@@ -26,6 +26,6 @@
<body>
<div id="root"></div>
</body>
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-3.min.js" data-apikey="8165e2069605bc20ccd0792dbbfae7bf"></script>
<script src="//d2wy8f7a9ursnm.cloudfront.net/bugsnag-3.min.js" data-apikey="<%= BUGSNAG_KEY %>"></script>
</html>
</html>

View File

@@ -1,5 +1,18 @@
// @flow
import crypto from 'crypto';
import moment from 'moment';
import AWS from 'aws-sdk';
import invariant from 'invariant';
import fetch from 'isomorphic-fetch';
import bugsnag from 'bugsnag';
AWS.config.update({
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
const AWS_S3_UPLOAD_BUCKET_NAME = process.env.AWS_S3_UPLOAD_BUCKET_NAME;
const makePolicy = () => {
const policy = {
@@ -19,13 +32,37 @@ const makePolicy = () => {
return new Buffer(JSON.stringify(policy)).toString('base64');
};
const signPolicy = policy => {
const signPolicy = (policy: any) => {
invariant(AWS_SECRET_ACCESS_KEY, 'AWS_SECRET_ACCESS_KEY not set');
const signature = crypto
.createHmac('sha1', process.env.AWS_SECRET_ACCESS_KEY)
.createHmac('sha1', AWS_SECRET_ACCESS_KEY)
.update(policy)
.digest('base64');
return signature;
};
export { makePolicy, signPolicy };
const uploadToS3FromUrl = async (url: string, key: string) => {
const s3 = new AWS.S3();
invariant(AWS_S3_UPLOAD_BUCKET_NAME, 'AWS_S3_UPLOAD_BUCKET_NAME not set');
try {
// $FlowIssue dunno it's fine
const res = await fetch(url);
const buffer = await res.buffer();
await s3
.putObject({
Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME,
Key: key,
ContentType: res.headers['content-type'],
ContentLength: res.headers['content-length'],
Body: buffer,
})
.promise();
return `https://s3.amazonaws.com/${AWS_S3_UPLOAD_BUCKET_NAME}/${key}`;
} catch (e) {
bugsnag.notify(e);
}
};
export { makePolicy, signPolicy, uploadToS3FromUrl };

View File

@@ -1,16 +0,0 @@
import truncate from 'truncate-html';
import { convertToMarkdown } from '../../frontend/utils/markdown';
truncate.defaultOptions = {
stripTags: false,
ellipsis: '...',
decodeEntities: false,
excludes: ['h1', 'pre'],
};
const truncateMarkdown = (text, length) => {
const html = convertToMarkdown(text);
return truncate(html, length);
};
export { truncateMarkdown };