diff --git a/app/components/Editor/Editor.js b/app/components/Editor/Editor.js index fd3b78c05..3a6975c7b 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor/Editor.js @@ -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 { @observable redirectTo: ?string; onUploadImage = async (file: File) => { - const result = await uploadFile(file); + const result = await uploadFile(file, { documentId: this.props.id }); return result.url; }; diff --git a/app/scenes/Settings/components/ImageUpload.js b/app/scenes/Settings/components/ImageUpload.js index feb1bed3a..a8695c589 100644 --- a/app/scenes/Settings/components/ImageUpload.js +++ b/app/scenes/Settings/components/ImageUpload.js @@ -49,7 +49,9 @@ class DropToImport extends React.Component { 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); diff --git a/app/utils/uploadFile.js b/app/utils/uploadFile.js index 81b738ef4..8e158de5f 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -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; }; diff --git a/server/api/users.js b/server/api/users.js index 69edc8024..8c9f8ba6f 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -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 Attachment.create({ + key, + size, + url, + contentType, + documentId, + teamId: user.teamId, + userId: user.id, + }); + await Event.create({ name: 'user.s3Upload', - data: { - filename, - kind, - size, - url, - }, - teamId: ctx.state.user.teamId, - userId: ctx.state.user.id, + 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, }, diff --git a/server/migrations/20200104233831-attachments.js b/server/migrations/20200104233831-attachments.js new file mode 100644 index 000000000..1593add24 --- /dev/null +++ b/server/migrations/20200104233831-attachments.js @@ -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'); + }, +}; diff --git a/server/models/Attachment.js b/server/models/Attachment.js new file mode 100644 index 000000000..e3a13a8b3 --- /dev/null +++ b/server/models/Attachment.js @@ -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; diff --git a/server/models/index.js b/server/models/index.js index caeda47c8..6c4e86c60 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -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, diff --git a/server/pages/developers/Api.js b/server/pages/developers/Api.js index 87b43b9a1..1cb463652 100644 --- a/server/pages/developers/Api.js +++ b/server/pages/developers/Api.js @@ -53,25 +53,29 @@ export default function Api() { 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. +