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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
65
server/migrations/20200104233831-attachments.js
Normal file
65
server/migrations/20200104233831-attachments.js
Normal 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');
|
||||
},
|
||||
};
|
||||
53
server/models/Attachment.js
Normal file
53
server/models/Attachment.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user