Merge master
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
23
server/migrations/20171017055026-remove-document-html.js
Normal file
23
server/migrations/20171017055026-remove-document-html.js
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
12
server/migrations/20171019071915-user-avatar-url.js
Normal file
12
server/migrations/20171019071915-user-avatar-url.js
Normal 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');
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -9,8 +9,6 @@ const Revision = sequelize.define('revision', {
|
||||
},
|
||||
title: DataTypes.STRING,
|
||||
text: DataTypes.TEXT,
|
||||
html: DataTypes.TEXT,
|
||||
preview: DataTypes.TEXT,
|
||||
|
||||
userId: {
|
||||
type: 'UUID',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
15
server/presenters/revision.js
Normal file
15
server/presenters/revision.js
Normal 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;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user