feat: Store image uploads as attachments in database (#1144)

* First pass

* Documentation

* Added optional documentId relationship

* name -> key

* cleanup: No need for separate documentId prop
This commit is contained in:
Tom Moor
2020-01-16 09:42:42 -08:00
committed by GitHub
parent 22230c25e5
commit 8e5a5a57a9
8 changed files with 171 additions and 31 deletions

View File

@ -15,6 +15,7 @@ import Embed from './Embed';
import embeds from '../../embeds'; import embeds from '../../embeds';
type Props = { type Props = {
id: string,
defaultValue?: string, defaultValue?: string,
readOnly?: boolean, readOnly?: boolean,
grow?: boolean, grow?: boolean,
@ -28,7 +29,7 @@ class Editor extends React.Component<Props> {
@observable redirectTo: ?string; @observable redirectTo: ?string;
onUploadImage = async (file: File) => { onUploadImage = async (file: File) => {
const result = await uploadFile(file); const result = await uploadFile(file, { documentId: this.props.id });
return result.url; return result.url;
}; };

View File

@ -49,7 +49,9 @@ class DropToImport extends React.Component<Props> {
const canvas = this.avatarEditorRef.getImage(); const canvas = this.avatarEditorRef.getImage();
const imageBlob = dataUrlToBlob(canvas.toDataURL()); const imageBlob = dataUrlToBlob(canvas.toDataURL());
try { try {
const asset = await uploadFile(imageBlob, { name: this.file.name }); const asset = await uploadFile(imageBlob, {
name: this.file.name,
});
this.props.onSuccess(asset.url); this.props.onSuccess(asset.url);
} catch (err) { } catch (err) {
this.props.onError(err.message); this.props.onError(err.message);

View File

@ -4,17 +4,19 @@ import invariant from 'invariant';
type Options = { type Options = {
name?: string, name?: string,
documentId?: string,
}; };
export const uploadFile = async ( export const uploadFile = async (
file: File | Blob, file: File | Blob,
option?: Options = { name: '' } options?: Options = { name: '' }
) => { ) => {
const filename = file instanceof File ? file.name : option.name; const name = file instanceof File ? file.name : options.name;
const response = await client.post('/users.s3Upload', { const response = await client.post('/users.s3Upload', {
kind: file.type, documentId: options.documentId,
contentType: file.type,
size: file.size, size: file.size,
filename, name,
}); });
invariant(response, 'Response should be available'); invariant(response, 'Response should be available');
@ -35,11 +37,10 @@ export const uploadFile = async (
formData.append('file', file); formData.append('file', file);
} }
const options: Object = { await fetch(data.uploadUrl, {
method: 'post', method: 'post',
body: formData, body: formData,
}; });
await fetch(data.uploadUrl, options);
return asset; return asset;
}; };

View File

@ -10,7 +10,7 @@ import {
makeCredential, makeCredential,
} from '../utils/s3'; } from '../utils/s3';
import { ValidationError } from '../errors'; import { ValidationError } from '../errors';
import { Event, User, Team } from '../models'; import { Attachment, Event, User, Team } from '../models';
import auth from '../middlewares/authentication'; import auth from '../middlewares/authentication';
import pagination from './middlewares/pagination'; import pagination from './middlewares/pagination';
import userInviter from '../commands/userInviter'; import userInviter from '../commands/userInviter';
@ -76,29 +76,40 @@ router.post('users.update', auth(), async ctx => {
}); });
router.post('users.s3Upload', auth(), async ctx => { router.post('users.s3Upload', auth(), async ctx => {
const { filename, kind, size } = ctx.body; let { name, filename, documentId, contentType, kind, size } = ctx.body;
ctx.assertPresent(filename, 'filename is required');
ctx.assertPresent(kind, 'kind is required'); // backwards compatability
name = name || filename;
contentType = contentType || kind;
ctx.assertPresent(name, 'name is required');
ctx.assertPresent(contentType, 'contentType is required');
ctx.assertPresent(size, 'size is required'); ctx.assertPresent(size, 'size is required');
const { user } = ctx.state;
const s3Key = uuid.v4(); const s3Key = uuid.v4();
const key = `uploads/${ctx.state.user.id}/${s3Key}/${filename}`; const key = `uploads/${user.id}/${s3Key}/${name}`;
const credential = makeCredential(); const credential = makeCredential();
const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z'); const longDate = format(new Date(), 'YYYYMMDDTHHmmss\\Z');
const policy = makePolicy(credential, longDate); const policy = makePolicy(credential, longDate);
const endpoint = publicS3Endpoint(); const endpoint = publicS3Endpoint();
const url = `${endpoint}/${key}`; const url = `${endpoint}/${key}`;
await Attachment.create({
key,
size,
url,
contentType,
documentId,
teamId: user.teamId,
userId: user.id,
});
await Event.create({ await Event.create({
name: 'user.s3Upload', name: 'user.s3Upload',
data: { data: { name },
filename, teamId: user.teamId,
kind, userId: user.id,
size,
url,
},
teamId: ctx.state.user.teamId,
userId: ctx.state.user.id,
ip: ctx.request.ip, ip: ctx.request.ip,
}); });
@ -108,7 +119,7 @@ router.post('users.s3Upload', auth(), async ctx => {
uploadUrl: endpoint, uploadUrl: endpoint,
form: { form: {
'Cache-Control': 'max-age=31557600', 'Cache-Control': 'max-age=31557600',
'Content-Type': kind, 'Content-Type': contentType,
acl: 'public-read', acl: 'public-read',
key, key,
policy, policy,
@ -118,8 +129,8 @@ router.post('users.s3Upload', auth(), async ctx => {
'x-amz-signature': getSignature(policy), 'x-amz-signature': getSignature(policy),
}, },
asset: { asset: {
contentType: kind, contentType,
name: filename, name,
url, url,
size, size,
}, },

View File

@ -0,0 +1,65 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('attachments', {
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'teams',
},
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: 'users',
},
},
documentId: {
type: Sequelize.UUID,
allowNull: true,
references: {
model: 'documents',
},
},
key: {
type: Sequelize.STRING,
allowNull: false,
},
url: {
type: Sequelize.STRING,
allowNull: false,
},
contentType: {
type: Sequelize.STRING,
allowNull: false,
},
size: {
type: Sequelize.BIGINT,
allowNull: false,
},
acl: {
type: Sequelize.STRING,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
});
await queryInterface.addIndex('attachments', ['documentId']);
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('attachments');
},
};

View File

@ -0,0 +1,53 @@
// @flow
import path from 'path';
import { DataTypes, sequelize } from '../sequelize';
const Attachment = sequelize.define(
'attachment',
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
key: {
type: DataTypes.STRING,
allowNull: false,
},
url: {
type: DataTypes.STRING,
allowNull: false,
},
contentType: {
type: DataTypes.STRING,
allowNull: false,
},
size: {
type: DataTypes.BIGINT,
allowNull: false,
},
acl: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'public-read',
validate: {
isIn: [['private', 'public-read']],
},
},
},
{
getterMethods: {
name: function() {
return path.parse(this.key).base;
},
},
}
);
Attachment.associate = models => {
Attachment.belongsTo(models.Team);
Attachment.belongsTo(models.Document);
Attachment.belongsTo(models.User);
};
export default Attachment;

View File

@ -1,5 +1,6 @@
// @flow // @flow
import ApiKey from './ApiKey'; import ApiKey from './ApiKey';
import Attachment from './Attachment';
import Authentication from './Authentication'; import Authentication from './Authentication';
import Backlink from './Backlink'; import Backlink from './Backlink';
import Collection from './Collection'; import Collection from './Collection';
@ -18,6 +19,7 @@ import View from './View';
const models = { const models = {
ApiKey, ApiKey,
Attachment,
Authentication, Authentication,
Backlink, Backlink,
Collection, Collection,
@ -44,6 +46,7 @@ Object.keys(models).forEach(modelName => {
export { export {
ApiKey, ApiKey,
Attachment,
Authentication, Authentication,
Backlink, Backlink,
Collection, Collection,

View File

@ -53,25 +53,29 @@ export default function Api() {
<Description> <Description>
You can upload small files and images as part of your documents. You can upload small files and images as part of your documents.
All files are stored using Amazon S3. Instead of uploading files All files are stored using Amazon S3. Instead of uploading files
to Outline, you need to upload them directly to S3 with special to Outline, you need to upload them directly to S3 with
credentials which can be obtained through this endpoint. credentials which can be obtained through this endpoint.
</Description> </Description>
<Arguments> <Arguments>
<Argument <Argument
id="filename" id="name"
description="Filename of the uploaded file" description="Name of the uploaded file"
required required
/> />
<Argument <Argument
id="kind" id="contentType"
description="Mimetype of the document" description="Mimetype of the file"
required required
/> />
<Argument <Argument
id="size" id="size"
description="Filesize of the document" description="Size in bytes of the file"
required required
/> />
<Argument
id="documentId"
description="UUID of the associated document"
/>
</Arguments> </Arguments>
</Method> </Method>