diff --git a/server/api/attachments.js b/server/api/attachments.js index f7553378c..06525da1d 100644 --- a/server/api/attachments.js +++ b/server/api/attachments.js @@ -3,7 +3,7 @@ import Router from 'koa-router'; import auth from '../middlewares/authentication'; import { Attachment, Document } from '../models'; import { getSignedImageUrl } from '../utils/s3'; - +import { NotFoundError } from '../errors'; import policy from '../policies'; const { authorize } = policy; @@ -15,6 +15,9 @@ router.post('attachments.redirect', auth(), async ctx => { const user = ctx.state.user; const attachment = await Attachment.findByPk(id); + if (!attachment) { + throw new NotFoundError(); + } if (attachment.isPrivate) { if (attachment.documentId) { diff --git a/server/api/users.js b/server/api/users.js index aaf3cf86e..e6c102e94 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -9,7 +9,7 @@ import { publicS3Endpoint, makeCredential, } from '../utils/s3'; -import { Attachment, Event, User, Team } from '../models'; +import { Document, Attachment, Event, User, Team } from '../models'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; import userInviter from '../commands/userInviter'; @@ -96,6 +96,11 @@ router.post('users.s3Upload', auth(), async ctx => { const endpoint = publicS3Endpoint(); const url = `${endpoint}/${key}`; + if (documentId) { + const document = await Document.findByPk(documentId, { userId: user.id }); + authorize(user, 'update', document); + } + const attachment = await Attachment.create({ key, acl, diff --git a/server/api/utils.js b/server/api/utils.js index d35e7b1db..9f135994c 100644 --- a/server/api/utils.js +++ b/server/api/utils.js @@ -1,11 +1,13 @@ // @flow +import debug from 'debug'; import Router from 'koa-router'; import subDays from 'date-fns/sub_days'; import { AuthenticationError } from '../errors'; -import { Document } from '../models'; +import { Document, Attachment } from '../models'; import { Op } from '../sequelize'; const router = new Router(); +const log = debug('utils'); router.post('utils.gc', async ctx => { const { token } = ctx.body; @@ -14,15 +16,33 @@ router.post('utils.gc', async ctx => { throw new AuthenticationError('Invalid secret token'); } - await Document.scope('withUnpublished').destroy({ - where: { - deletedAt: { - [Op.lt]: subDays(new Date(), 30), - }, + log('Permanently deleting documents older than 30 days…'); + + const where = { + deletedAt: { + [Op.lt]: subDays(new Date(), 30), }, + }; + + const documents = await Document.scope('withUnpublished').findAll({ + attributes: ['id'], + where, + }); + const documentIds = documents.map(d => d.id); + + await Attachment.destroy({ + where: { + documentId: documentIds, + }, + }); + + await Document.scope('withUnpublished').destroy({ + where, force: true, }); + log(`Deleted ${documentIds.length} documents`); + ctx.body = { success: true, }; diff --git a/server/migrations/20200328175012-cascade-delete.js b/server/migrations/20200328175012-cascade-delete.js new file mode 100644 index 000000000..73156afa1 --- /dev/null +++ b/server/migrations/20200328175012-cascade-delete.js @@ -0,0 +1,43 @@ +const tableName = 'attachments'; + +// because of this issue in Sequelize the foreign key constraint may be named differently depending +// on when the previous migrations were ran https://github.com/sequelize/sequelize/pull/9890 +const constraintNames = ['attachments_documentId_fkey', 'attachments_foreign_idx']; + +module.exports = { + up: async (queryInterface, Sequelize) => { + let error; + for (const constraintName of constraintNames) { + try { + await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`) + await queryInterface.sequelize.query( + `alter table "${tableName}" + add constraint "${constraintName}" foreign key("documentId") references "documents" ("id") + on delete cascade` + ); + return; + } catch (err) { + error = err; + } + } + throw error; + }, + + down: async (queryInterface, Sequelize) => { + let error; + for (const constraintName of constraintNames) { + try { + await queryInterface.sequelize.query(`alter table "${tableName}" drop constraint "${constraintName}"`) + await queryInterface.sequelize.query( + `alter table "${tableName}"\ + add constraint "${constraintName}" foreign key("documentId") references "documents" ("id") + on delete no action` + ); + return; + } catch (err) { + error = err; + } + } + throw error; + }, +}; \ No newline at end of file diff --git a/server/models/Attachment.js b/server/models/Attachment.js index eb99cddd2..7e14987a1 100644 --- a/server/models/Attachment.js +++ b/server/models/Attachment.js @@ -1,6 +1,7 @@ // @flow import path from 'path'; import { DataTypes, sequelize } from '../sequelize'; +import { deleteFromS3 } from '../utils/s3'; const Attachment = sequelize.define( 'attachment', @@ -50,6 +51,10 @@ const Attachment = sequelize.define( } ); +Attachment.beforeDestroy(async model => { + await deleteFromS3(model.key); +}); + Attachment.associate = models => { Attachment.belongsTo(models.Team); Attachment.belongsTo(models.Document); diff --git a/server/utils/s3.js b/server/utils/s3.js index 53046c010..af851cd1f 100644 --- a/server/utils/s3.js +++ b/server/utils/s3.js @@ -123,6 +123,15 @@ export const uploadToS3FromUrl = async ( } }; +export const deleteFromS3 = (key: string) => { + return s3 + .deleteObject({ + Bucket: process.env.AWS_S3_UPLOAD_BUCKET_NAME, + Key: key, + }) + .promise(); +}; + export const getSignedImageUrl = async (key: string) => { invariant(AWS_S3_UPLOAD_BUCKET_NAME, 'AWS_S3_UPLOAD_BUCKET_NAME not set'); const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);