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:
32
server/api/attachments.js
Normal file
32
server/api/attachments.js
Normal 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;
|
||||
86
server/api/attachments.test.js
Normal file
86
server/api/attachments.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user