diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js index c8c1808f..810ac4b3 100644 --- a/app/components/SocketProvider.js +++ b/app/components/SocketProvider.js @@ -250,6 +250,10 @@ class SocketProvider extends React.Component { documents.starredIds.set(event.documentId, false); }); + this.socket.on("documents.permanent_delete", (event) => { + documents.remove(event.documentId); + }); + // received when a user is given access to a collection // if the user is us then we go ahead and load the collection from API. this.socket.on("collections.add_user", (event) => { diff --git a/app/menus/DocumentMenu.js b/app/menus/DocumentMenu.js index dd0a4f19..79b3d987 100644 --- a/app/menus/DocumentMenu.js +++ b/app/menus/DocumentMenu.js @@ -9,6 +9,7 @@ import styled from "styled-components"; import Document from "models/Document"; import DocumentDelete from "scenes/DocumentDelete"; import DocumentMove from "scenes/DocumentMove"; +import DocumentPermanentDelete from "scenes/DocumentPermanentDelete"; import DocumentTemplatize from "scenes/DocumentTemplatize"; import CollectionIcon from "components/CollectionIcon"; import ContextMenu from "components/ContextMenu"; @@ -61,6 +62,10 @@ function DocumentMenu({ const { t } = useTranslation(); const [renderModals, setRenderModals] = React.useState(false); const [showDeleteModal, setShowDeleteModal] = React.useState(false); + const [ + showPermanentDeleteModal, + setShowPermanentDeleteModal, + ] = React.useState(false); const [showMoveModal, setShowMoveModal] = React.useState(false); const [showTemplateModal, setShowTemplateModal] = React.useState(false); const file = React.useRef(); @@ -327,6 +332,11 @@ function DocumentMenu({ onClick: () => setShowDeleteModal(true), visible: !!can.delete, }, + { + title: `${t("Permanently delete")}…`, + onClick: () => setShowPermanentDeleteModal(true), + visible: can.permanentDelete, + }, { title: `${t("Move")}…`, onClick: () => setShowMoveModal(true), @@ -357,40 +367,60 @@ function DocumentMenu({ {renderModals && ( <> - setShowMoveModal(false)} - isOpen={showMoveModal} - > - setShowMoveModal(false)} - /> - - setShowDeleteModal(false)} - isOpen={showDeleteModal} - > - setShowDeleteModal(false)} - /> - - setShowTemplateModal(false)} - isOpen={showTemplateModal} - > - setShowTemplateModal(false)} - /> - + isOpen={showMoveModal} + > + setShowMoveModal(false)} + /> + + )} + {can.delete && ( + setShowDeleteModal(false)} + isOpen={showDeleteModal} + > + setShowDeleteModal(false)} + /> + + )} + {can.permanentDelete && ( + setShowPermanentDeleteModal(false)} + isOpen={showPermanentDeleteModal} + > + setShowPermanentDeleteModal(false)} + /> + + )} + {can.update && ( + setShowTemplateModal(false)} + isOpen={showTemplateModal} + > + setShowTemplateModal(false)} + /> + + )} )} diff --git a/app/scenes/DocumentPermanentDelete.js b/app/scenes/DocumentPermanentDelete.js new file mode 100644 index 00000000..47944f6b --- /dev/null +++ b/app/scenes/DocumentPermanentDelete.js @@ -0,0 +1,60 @@ +// @flow +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import { useHistory } from "react-router-dom"; +import Document from "models/Document.js"; +import Button from "components/Button"; +import Flex from "components/Flex"; +import HelpText from "components/HelpText"; +import useStores from "hooks/useStores"; + +type Props = {| + document: Document, + onSubmit: () => void, +|}; + +function DocumentPermanentDelete({ document, onSubmit }: Props) { + const [isDeleting, setIsDeleting] = React.useState(false); + const { t } = useTranslation(); + const { ui, documents } = useStores(); + const { showToast } = ui; + const history = useHistory(); + + const handleSubmit = React.useCallback( + async (ev: SyntheticEvent<>) => { + ev.preventDefault(); + try { + setIsDeleting(true); + await documents.delete(document, { permanent: true }); + showToast(t("Document permanently deleted"), { type: "success" }); + onSubmit(); + history.push("/trash"); + } catch (err) { + showToast(err.message, { type: "error" }); + } finally { + setIsDeleting(false); + } + }, + [document, onSubmit, showToast, t, history, documents] + ); + + return ( + +
+ + }} + /> + + +
+
+ ); +} + +export default observer(DocumentPermanentDelete); diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index bc3a98b2..a0aed5af 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore { } @action - async delete(document: Document) { - await super.delete(document); + async delete(document: Document, options?: {| permanent: boolean |}) { + await super.delete(document, options); // check to see if we have any shares related to this document already // loaded in local state. If so we can go ahead and remove those too. diff --git a/server/api/documents.js b/server/api/documents.js index 8083af28..58b86e6b 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -5,6 +5,7 @@ import { subtractDate } from "../../shared/utils/date"; import documentCreator from "../commands/documentCreator"; import documentImporter from "../commands/documentImporter"; import documentMover from "../commands/documentMover"; +import { documentPermanentDeleter } from "../commands/documentPermanentDeleter"; import env from "../env"; import { NotFoundError, @@ -1174,24 +1175,53 @@ router.post("documents.archive", auth(), async (ctx) => { }); router.post("documents.delete", auth(), async (ctx) => { - const { id } = ctx.body; + const { id, permanent } = ctx.body; ctx.assertPresent(id, "id is required"); const user = ctx.state.user; - const document = await Document.findByPk(id, { userId: user.id }); - authorize(user, "delete", document); + if (permanent) { + const document = await Document.findByPk(id, { + userId: user.id, + paranoid: false, + }); + authorize(user, "permanentDelete", document); - await document.delete(user.id); + await Document.update( + { parentDocumentId: null }, + { + where: { + parentDocumentId: document.id, + }, + paranoid: false, + } + ); - await Event.create({ - name: "documents.delete", - documentId: document.id, - collectionId: document.collectionId, - teamId: document.teamId, - actorId: user.id, - data: { title: document.title }, - ip: ctx.request.ip, - }); + await documentPermanentDeleter([document]); + + await Event.create({ + name: "documents.permanent_delete", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, + }); + } else { + const document = await Document.findByPk(id, { userId: user.id }); + authorize(user, "delete", document); + + await document.delete(user.id); + await Event.create({ + name: "documents.delete", + documentId: document.id, + collectionId: document.collectionId, + teamId: document.teamId, + actorId: user.id, + data: { title: document.title }, + ip: ctx.request.ip, + }); + } ctx.body = { success: true, diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 09bb70dc..4e0983c8 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -2201,6 +2201,26 @@ describe("#documents.delete", () => { expect(body.success).toEqual(true); }); + it("should allow permanently deleting a document", async () => { + const user = await buildUser(); + const document = await buildDocument({ + userId: user.id, + teamId: user.teamId, + }); + + await server.post("/api/documents.delete", { + body: { token: user.getJwtToken(), id: document.id }, + }); + + const res = await server.post("/api/documents.delete", { + body: { token: user.getJwtToken(), id: document.id, permanent: true }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.success).toEqual(true); + }); + it("should allow deleting document without collection", async () => { const { user, document, collection } = await seed(); diff --git a/server/api/utils.js b/server/api/utils.js index 81fbbb2f..7d2bb1e9 100644 --- a/server/api/utils.js +++ b/server/api/utils.js @@ -2,10 +2,10 @@ import { subDays } from "date-fns"; import debug from "debug"; import Router from "koa-router"; +import { documentPermanentDeleter } from "../commands/documentPermanentDeleter"; import { AuthenticationError } from "../errors"; -import { Document, Attachment } from "../models"; -import { Op, sequelize } from "../sequelize"; -import parseAttachmentIds from "../utils/parseAttachmentIds"; +import { Document } from "../models"; +import { Op } from "../sequelize"; const router = new Router(); const log = debug("utils"); @@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => { log(`Permanently destroying upto ${limit} documents older than 30 days…`); const documents = await Document.scope("withUnpublished").findAll({ - attributes: ["id", "teamId", "text"], + attributes: ["id", "teamId", "text", "deletedAt"], where: { deletedAt: { [Op.lt]: subDays(new Date(), 30), @@ -30,54 +30,9 @@ router.post("utils.gc", async (ctx) => { limit, }); - const query = ` - SELECT COUNT(id) - FROM documents - WHERE "searchVector" @@ to_tsquery('english', :query) AND - "teamId" = :teamId AND - "id" != :documentId -`; + const countDeletedDocument = await documentPermanentDeleter(documents); - for (const document of documents) { - const attachmentIds = parseAttachmentIds(document.text); - - for (const attachmentId of attachmentIds) { - const [{ count }] = await sequelize.query(query, { - type: sequelize.QueryTypes.SELECT, - replacements: { - documentId: document.id, - teamId: document.teamId, - query: attachmentId, - }, - }); - - if (parseInt(count) === 0) { - const attachment = await Attachment.findOne({ - where: { - teamId: document.teamId, - id: attachmentId, - }, - }); - - if (attachment) { - await attachment.destroy(); - - log(`Attachment ${attachmentId} deleted`); - } else { - log(`Unknown attachment ${attachmentId} ignored`); - } - } - } - } - - await Document.scope("withUnpublished").destroy({ - where: { - id: documents.map((document) => document.id), - }, - force: true, - }); - - log(`Destroyed ${documents.length} documents`); + log(`Destroyed ${countDeletedDocument} documents`); ctx.body = { success: true, diff --git a/server/api/utils.test.js b/server/api/utils.test.js index e6d8ed10..20c54323 100644 --- a/server/api/utils.test.js +++ b/server/api/utils.test.js @@ -2,8 +2,8 @@ import { subDays } from "date-fns"; import TestServer from "fetch-test-server"; import app from "../app"; -import { Attachment, Document } from "../models"; -import { buildAttachment, buildDocument } from "../test/factories"; +import { Document } from "../models"; +import { buildDocument } from "../test/factories"; import { flushdb } from "../test/support"; const server = new TestServer(app.callback()); @@ -67,94 +67,6 @@ describe("#utils.gc", () => { expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); }); - it("should destroy attachments no longer referenced", async () => { - const document = await buildDocument({ - publishedAt: subDays(new Date(), 90), - deletedAt: subDays(new Date(), 60), - }); - - const attachment = await buildAttachment({ - teamId: document.teamId, - documentId: document.id, - }); - - document.text = `![text](${attachment.redirectUrl})`; - await document.save(); - - const res = await server.post("/api/utils.gc", { - body: { - token: process.env.UTILS_SECRET, - }, - }); - - expect(res.status).toEqual(200); - expect(await Attachment.count()).toEqual(0); - expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); - }); - - it("should handle unknown attachment ids", async () => { - const document = await buildDocument({ - publishedAt: subDays(new Date(), 90), - deletedAt: subDays(new Date(), 60), - }); - - const attachment = await buildAttachment({ - teamId: document.teamId, - documentId: document.id, - }); - - document.text = `![text](${attachment.redirectUrl})`; - await document.save(); - - // remove attachment so it no longer exists in the database, this is also - // representative of a corrupt attachment id in the doc or the regex returning - // an incorrect string - await attachment.destroy({ force: true }); - - const res = await server.post("/api/utils.gc", { - body: { - token: process.env.UTILS_SECRET, - }, - }); - - expect(res.status).toEqual(200); - expect(await Attachment.count()).toEqual(0); - expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); - }); - - it("should not destroy attachments referenced in other documents", async () => { - const document1 = await buildDocument(); - - const document = await buildDocument({ - teamId: document1.teamId, - publishedAt: subDays(new Date(), 90), - deletedAt: subDays(new Date(), 60), - }); - - const attachment = await buildAttachment({ - teamId: document1.teamId, - documentId: document.id, - }); - - document1.text = `![text](${attachment.redirectUrl})`; - await document1.save(); - - document.text = `![text](${attachment.redirectUrl})`; - await document.save(); - - expect(await Attachment.count()).toEqual(1); - - const res = await server.post("/api/utils.gc", { - body: { - token: process.env.UTILS_SECRET, - }, - }); - - expect(res.status).toEqual(200); - expect(await Attachment.count()).toEqual(1); - expect(await Document.unscoped().count({ paranoid: false })).toEqual(1); - }); - it("should destroy draft documents deleted more than 30 days ago", async () => { await buildDocument({ publishedAt: undefined, diff --git a/server/commands/documentPermanentDeleter.js b/server/commands/documentPermanentDeleter.js new file mode 100644 index 00000000..a87c2621 --- /dev/null +++ b/server/commands/documentPermanentDeleter.js @@ -0,0 +1,64 @@ +// @flow +import debug from "debug"; +import { Document, Attachment } from "../models"; +import { sequelize } from "../sequelize"; +import parseAttachmentIds from "../utils/parseAttachmentIds"; + +const log = debug("commands"); + +export async function documentPermanentDeleter(documents: Document[]) { + const activeDocument = documents.find((doc) => !doc.deletedAt); + + if (activeDocument) { + throw new Error( + `Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.` + ); + } + + const query = ` + SELECT COUNT(id) + FROM documents + WHERE "searchVector" @@ to_tsquery('english', :query) AND + "teamId" = :teamId AND + "id" != :documentId + `; + + for (const document of documents) { + const attachmentIds = parseAttachmentIds(document.text); + + for (const attachmentId of attachmentIds) { + const [{ count }] = await sequelize.query(query, { + type: sequelize.QueryTypes.SELECT, + replacements: { + documentId: document.id, + teamId: document.teamId, + query: attachmentId, + }, + }); + + if (parseInt(count) === 0) { + const attachment = await Attachment.findOne({ + where: { + teamId: document.teamId, + id: attachmentId, + }, + }); + + if (attachment) { + await attachment.destroy(); + + log(`Attachment ${attachmentId} deleted`); + } else { + log(`Unknown attachment ${attachmentId} ignored`); + } + } + } + } + + return Document.scope("withUnpublished").destroy({ + where: { + id: documents.map((document) => document.id), + }, + force: true, + }); +} diff --git a/server/commands/documentPermanentDeleter.test.js b/server/commands/documentPermanentDeleter.test.js new file mode 100644 index 00000000..a33cd9de --- /dev/null +++ b/server/commands/documentPermanentDeleter.test.js @@ -0,0 +1,123 @@ +// @flow +import { subDays } from "date-fns"; +import { Attachment, Document } from "../models"; +import { buildAttachment, buildDocument } from "../test/factories"; +import { flushdb } from "../test/support"; +import { documentPermanentDeleter } from "./documentPermanentDeleter"; + +jest.mock("aws-sdk", () => { + const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() }; + return { + S3: jest.fn(() => mS3), + Endpoint: jest.fn(), + }; +}); + +beforeEach(() => flushdb()); + +describe("documentPermanentDeleter", () => { + it("should destroy documents", async () => { + const document = await buildDocument({ + publishedAt: subDays(new Date(), 90), + deletedAt: new Date(), + }); + + const countDeletedDoc = await documentPermanentDeleter([document]); + + expect(countDeletedDoc).toEqual(1); + expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); + }); + + it("should error when trying to destroy undeleted documents", async () => { + const document = await buildDocument({ + publishedAt: new Date(), + }); + + let error; + try { + await documentPermanentDeleter([document]); + } catch (err) { + error = err.message; + } + + expect(error).toEqual( + `Cannot permanently delete ${document.id} document. Please delete it and try again.` + ); + }); + + it("should destroy attachments no longer referenced", async () => { + const document = await buildDocument({ + publishedAt: subDays(new Date(), 90), + deletedAt: new Date(), + }); + + const attachment = await buildAttachment({ + teamId: document.teamId, + documentId: document.id, + }); + + document.text = `![text](${attachment.redirectUrl})`; + await document.save(); + + const countDeletedDoc = await documentPermanentDeleter([document]); + + expect(countDeletedDoc).toEqual(1); + expect(await Attachment.count()).toEqual(0); + expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); + }); + + it("should handle unknown attachment ids", async () => { + const document = await buildDocument({ + publishedAt: subDays(new Date(), 90), + deletedAt: new Date(), + }); + + const attachment = await buildAttachment({ + teamId: document.teamId, + documentId: document.id, + }); + + document.text = `![text](${attachment.redirectUrl})`; + await document.save(); + + // remove attachment so it no longer exists in the database, this is also + // representative of a corrupt attachment id in the doc or the regex returning + // an incorrect string + await attachment.destroy({ force: true }); + + const countDeletedDoc = await documentPermanentDeleter([document]); + + expect(countDeletedDoc).toEqual(1); + expect(await Attachment.count()).toEqual(0); + expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); + }); + + it("should not destroy attachments referenced in other documents", async () => { + const document1 = await buildDocument(); + + const document = await buildDocument({ + teamId: document1.teamId, + publishedAt: subDays(new Date(), 90), + deletedAt: subDays(new Date(), 60), + }); + + const attachment = await buildAttachment({ + teamId: document1.teamId, + documentId: document.id, + }); + + document1.text = `![text](${attachment.redirectUrl})`; + await document1.save(); + + document.text = `![text](${attachment.redirectUrl})`; + await document.save(); + + expect(await Attachment.count()).toEqual(1); + + const countDeletedDoc = await documentPermanentDeleter([document]); + + expect(countDeletedDoc).toEqual(1); + expect(await Attachment.count()).toEqual(1); + expect(await Document.unscoped().count({ paranoid: false })).toEqual(1); + }); +}); diff --git a/server/events.js b/server/events.js index b797478d..90fe9e24 100644 --- a/server/events.js +++ b/server/events.js @@ -35,6 +35,7 @@ export type DocumentEvent = name: | "documents.create" // eslint-disable-line | "documents.publish" | "documents.delete" + | "documents.permanent_delete" | "documents.pin" | "documents.unpin" | "documents.archive" diff --git a/server/models/Event.js b/server/models/Event.js index c677475a..3423658b 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -65,6 +65,7 @@ Event.ACTIVITY_EVENTS = [ "documents.unpin", "documents.move", "documents.delete", + "documents.permanent_delete", "documents.restore", "users.create", ]; @@ -90,6 +91,7 @@ Event.AUDIT_EVENTS = [ "documents.unpin", "documents.move", "documents.delete", + "documents.permanent_delete", "documents.restore", "groups.create", "groups.update", diff --git a/server/policies/document.js b/server/policies/document.js index 14b76be4..e616111f 100644 --- a/server/policies/document.js +++ b/server/policies/document.js @@ -101,8 +101,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => { }); allow(User, "delete", Document, (user, document) => { - // unpublished drafts can always be deleted if (user.isViewer) return false; + if (document.deletedAt) return false; + + // allow deleting document without a collection + if (document.collection && cannot(user, "update", document.collection)) { + return false; + } + + // unpublished drafts can always be deleted if ( !document.deletedAt && !document.publishedAt && @@ -111,13 +118,18 @@ allow(User, "delete", Document, (user, document) => { return true; } + return user.teamId === document.teamId; +}); + +allow(User, "permanentDelete", Document, (user, document) => { + if (user.isViewer) return false; + if (!document.deletedAt) return false; + // allow deleting document without a collection if (document.collection && cannot(user, "update", document.collection)) { return false; } - if (document.deletedAt) return false; - return user.teamId === document.teamId; }); diff --git a/server/services/websockets.js b/server/services/websockets.js index 10f2708c..650e1875 100644 --- a/server/services/websockets.js +++ b/server/services/websockets.js @@ -79,6 +79,13 @@ export default class Websockets { ], }); } + case "documents.permanent_delete": { + return socketio + .to(`collection-${event.collectionId}`) + .emit(event.name, { + documentId: event.documentId, + }); + } case "documents.pin": case "documents.unpin": case "documents.update": { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index d0954b3f..c6903c26 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -181,12 +181,14 @@ "Create template": "Create template", "Duplicate": "Duplicate", "Unpublish": "Unpublish", + "Permanently delete": "Permanently delete", "Move": "Move", "History": "History", "Download": "Download", "Print": "Print", "Move {{ documentName }}": "Move {{ documentName }}", "Delete {{ documentName }}": "Delete {{ documentName }}", + "Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}", "Edit group": "Edit group", "Delete group": "Delete group", "Group options": "Group options", @@ -312,6 +314,8 @@ "I’m sure – Delete": "I’m sure – Delete", "Archiving": "Archiving", "Couldn’t create the document, try again?": "Couldn’t create the document, try again?", + "Document permanently deleted": "Document permanently deleted", + "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the {{ documentTitle }} document? This action is immediate and cannot be undone.", "Search documents": "Search documents", "No documents found for your filters.": "No documents found for your filters.", "You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",