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

View File

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

View File

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

View File

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

View File

@@ -53,25 +53,29 @@ export default function Api() {
<Description>
You can upload small files and images as part of your documents.
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.
</Description>
<Arguments>
<Argument
id="filename"
description="Filename of the uploaded file"
id="name"
description="Name of the uploaded file"
required
/>
<Argument
id="kind"
description="Mimetype of the document"
id="contentType"
description="Mimetype of the file"
required
/>
<Argument
id="size"
description="Filesize of the document"
description="Size in bytes of the file"
required
/>
<Argument
id="documentId"
description="UUID of the associated document"
/>
</Arguments>
</Method>