feat: private content (#1137)

* save images as private and serve via signed url from images.info api

* download private images to directory on export

* fix lint errors

* private s3 default, AWS.s3 module level scope, default s3 url expiry

* combine regex to one, and only replace when there are matches

* fix lint

* code not needed anymore, remove

* updates after pulling master

* revert the uploadToS3FromUrl url return

* use model gettr to compact code, rename to attachments api

* basic checking of document read permission to allow attachment viewing

* fix: Continue to upload avatars as public
fix: Allow redirect for non-private attachments

* add support for publicly shared documents

* catch errors which crash the app during zip export and user creation

* add tests

* enable AWS signature v4 for s3

* switch to use factories to build models for testing

* add isDocker flag for local serving of attachment redirect url

* fix redirect tests

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Huss
2020-02-13 03:40:44 +00:00
committed by GitHub
parent 064d8cea44
commit 8e2b19dc7a
15 changed files with 316 additions and 39 deletions

32
server/api/attachments.js Normal file
View File

@@ -0,0 +1,32 @@
// @flow
import Router from 'koa-router';
import auth from '../middlewares/authentication';
import { Attachment, Document } from '../models';
import { getSignedImageUrl } from '../utils/s3';
import policy from '../policies';
const { authorize } = policy;
const router = new Router();
router.post('attachments.redirect', auth(), async ctx => {
const { id } = ctx.body;
ctx.assertPresent(id, 'id is required');
const user = ctx.state.user;
const attachment = await Attachment.findByPk(id);
if (attachment.isPrivate) {
const document = await Document.findByPk(attachment.documentId, {
userId: user.id,
});
authorize(user, 'read', document);
const accessUrl = await getSignedImageUrl(attachment.key);
ctx.redirect(accessUrl);
} else {
ctx.redirect(attachment.url);
}
});
export default router;

View File

@@ -0,0 +1,86 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from 'fetch-test-server';
import app from '../app';
import { flushdb } from '../test/support';
import {
buildUser,
buildCollection,
buildAttachment,
buildDocument,
} from '../test/factories';
const server = new TestServer(app.callback());
beforeEach(flushdb);
afterAll(server.close);
describe('#attachments.redirect', async () => {
it('should require authentication', async () => {
const res = await server.post('/api/attachments.redirect');
expect(res.status).toEqual(401);
});
it('should return a redirect for an attachment belonging to a document user has access to', async () => {
const user = await buildUser();
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
});
const res = await server.post('/api/attachments.redirect', {
body: { token: user.getJwtToken(), id: attachment.id },
redirect: 'manual',
});
expect(res.status).toEqual(302);
});
it('should always return a redirect for a public attachment', async () => {
const user = await buildUser();
const collection = await buildCollection({
teamId: user.teamId,
userId: user.id,
private: true,
});
const document = await buildDocument({
teamId: user.teamId,
userId: user.id,
collectionId: collection.id,
});
const attachment = await buildAttachment({
teamId: user.teamId,
userId: user.id,
documentId: document.id,
});
const res = await server.post('/api/attachments.redirect', {
body: { token: user.getJwtToken(), id: attachment.id },
redirect: 'manual',
});
expect(res.status).toEqual(302);
});
it('should not return a redirect for a private attachment belonging to a document user does not have access to', async () => {
const user = await buildUser();
const collection = await buildCollection({
private: true,
});
const document = await buildDocument({
teamId: collection.teamId,
userId: collection.userId,
collectionId: collection.id,
});
const attachment = await buildAttachment({
teamId: document.teamId,
userId: document.userId,
documentId: document.id,
acl: 'private',
});
const res = await server.post('/api/attachments.redirect', {
body: { token: user.getJwtToken(), id: attachment.id },
});
expect(res.status).toEqual(403);
});
});

View File

@@ -16,6 +16,7 @@ import team from './team';
import integrations from './integrations';
import notificationSettings from './notificationSettings';
import utils from './utils';
import attachments from './attachments';
import { NotFoundError } from '../errors';
import errorHandling from '../middlewares/errorHandling';
@@ -48,6 +49,7 @@ router.use('/', shares.routes());
router.use('/', team.routes());
router.use('/', integrations.routes());
router.use('/', notificationSettings.routes());
router.use('/', attachments.routes());
router.use('/', utils.routes());
router.post('*', ctx => {
ctx.throw(new NotFoundError('Endpoint not found'));

View File

@@ -1,7 +1,6 @@
// @flow
import Router from 'koa-router';
import { Team } from '../models';
import { publicS3Endpoint } from '../utils/s3';
import auth from '../middlewares/authentication';
import { presentTeam, presentPolicies } from '../presenters';
@@ -19,8 +18,6 @@ router.post('team.update', auth(), async ctx => {
guestSignin,
documentEmbeds,
} = ctx.body;
const endpoint = publicS3Endpoint();
const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
authorize(user, 'update', team);
@@ -33,9 +30,7 @@ router.post('team.update', auth(), async ctx => {
if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (guestSignin !== undefined) team.guestSignin = guestSignin;
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
team.avatarUrl = avatarUrl;
}
if (avatarUrl !== undefined) team.avatarUrl = avatarUrl;
await team.save();
ctx.body = {

View File

@@ -17,6 +17,7 @@ import userInviter from '../commands/userInviter';
import { presentUser } from '../presenters';
import policy from '../policies';
const AWS_S3_ACL = process.env.AWS_S3_ACL || 'private';
const { authorize } = policy;
const router = new Router();
@@ -61,12 +62,9 @@ router.post('users.info', auth(), async ctx => {
router.post('users.update', auth(), async ctx => {
const { user } = ctx.state;
const { name, avatarUrl } = ctx.body;
const endpoint = publicS3Endpoint();
if (name) user.name = name;
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
user.avatarUrl = avatarUrl;
}
if (avatarUrl) user.avatarUrl = avatarUrl;
await user.save();
@@ -89,14 +87,19 @@ router.post('users.s3Upload', auth(), async ctx => {
const { user } = ctx.state;
const s3Key = uuid.v4();
const key = `uploads/${user.id}/${s3Key}/${name}`;
const acl =
ctx.body.public === undefined
? AWS_S3_ACL
: ctx.body.public ? 'public-read' : 'private';
const credential = makeCredential();
const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z');
const policy = makePolicy(credential, longDate);
const policy = makePolicy(credential, longDate, acl);
const endpoint = publicS3Endpoint();
const url = `${endpoint}/${key}`;
await Attachment.create({
const attachment = await Attachment.create({
key,
acl,
size,
url,
contentType,
@@ -120,7 +123,7 @@ router.post('users.s3Upload', auth(), async ctx => {
form: {
'Cache-Control': 'max-age=31557600',
'Content-Type': contentType,
acl: 'public-read',
acl,
key,
policy,
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
@@ -131,7 +134,7 @@ router.post('users.s3Upload', auth(), async ctx => {
asset: {
contentType,
name,
url,
url: attachment.redirectUrl,
size,
},
},