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';
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
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
|
// @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,
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user