fix: Add ability to permanently delete documents in trash (#2192)
* Align false conditions before true * Update documents.delete endpoint for permanent delete * Add permanent delete to events table and integrate with socket.io * Add permanent delete to document menu * Update parentDocumentId of direct child to null * Add translation * Add test for permanent delete * Add space * Update app/scenes/DocumentPermanentDelete.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * Update app/stores/DocumentsStore.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * Update server/commands/documentPermanentDeleter.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * Update app/scenes/DocumentPermanentDelete.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * Change socket room from team to collection * Add translation * Create log func for commands * Move tests from utils to permanentDeleter command * Add additional tests * Set redirect to trash * Return promise from beforeEach * Add undeleted documents validation * Include deleteAt attribute in db query * Update server/commands/documentPermanentDeleter.js Co-authored-by: Tom Moor <tom.moor@gmail.com> * tweak language Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
@ -250,6 +250,10 @@ class SocketProvider extends React.Component<Props> {
|
|||||||
documents.starredIds.set(event.documentId, false);
|
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
|
// 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.
|
// if the user is us then we go ahead and load the collection from API.
|
||||||
this.socket.on("collections.add_user", (event) => {
|
this.socket.on("collections.add_user", (event) => {
|
||||||
|
@ -9,6 +9,7 @@ import styled from "styled-components";
|
|||||||
import Document from "models/Document";
|
import Document from "models/Document";
|
||||||
import DocumentDelete from "scenes/DocumentDelete";
|
import DocumentDelete from "scenes/DocumentDelete";
|
||||||
import DocumentMove from "scenes/DocumentMove";
|
import DocumentMove from "scenes/DocumentMove";
|
||||||
|
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
|
||||||
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
import DocumentTemplatize from "scenes/DocumentTemplatize";
|
||||||
import CollectionIcon from "components/CollectionIcon";
|
import CollectionIcon from "components/CollectionIcon";
|
||||||
import ContextMenu from "components/ContextMenu";
|
import ContextMenu from "components/ContextMenu";
|
||||||
@ -61,6 +62,10 @@ function DocumentMenu({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [renderModals, setRenderModals] = React.useState(false);
|
const [renderModals, setRenderModals] = React.useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||||
|
const [
|
||||||
|
showPermanentDeleteModal,
|
||||||
|
setShowPermanentDeleteModal,
|
||||||
|
] = React.useState(false);
|
||||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||||
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
|
||||||
const file = React.useRef<?HTMLInputElement>();
|
const file = React.useRef<?HTMLInputElement>();
|
||||||
@ -327,6 +332,11 @@ function DocumentMenu({
|
|||||||
onClick: () => setShowDeleteModal(true),
|
onClick: () => setShowDeleteModal(true),
|
||||||
visible: !!can.delete,
|
visible: !!can.delete,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: `${t("Permanently delete")}…`,
|
||||||
|
onClick: () => setShowPermanentDeleteModal(true),
|
||||||
|
visible: can.permanentDelete,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: `${t("Move")}…`,
|
title: `${t("Move")}…`,
|
||||||
onClick: () => setShowMoveModal(true),
|
onClick: () => setShowMoveModal(true),
|
||||||
@ -357,6 +367,7 @@ function DocumentMenu({
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
{renderModals && (
|
{renderModals && (
|
||||||
<>
|
<>
|
||||||
|
{can.move && (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Move {{ documentName }}", {
|
title={t("Move {{ documentName }}", {
|
||||||
documentName: document.noun,
|
documentName: document.noun,
|
||||||
@ -369,6 +380,8 @@ function DocumentMenu({
|
|||||||
onRequestClose={() => setShowMoveModal(false)}
|
onRequestClose={() => setShowMoveModal(false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
)}
|
||||||
|
{can.delete && (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Delete {{ documentName }}", {
|
title={t("Delete {{ documentName }}", {
|
||||||
documentName: document.noun,
|
documentName: document.noun,
|
||||||
@ -381,6 +394,22 @@ function DocumentMenu({
|
|||||||
onSubmit={() => setShowDeleteModal(false)}
|
onSubmit={() => setShowDeleteModal(false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
)}
|
||||||
|
{can.permanentDelete && (
|
||||||
|
<Modal
|
||||||
|
title={t("Permanently delete {{ documentName }}", {
|
||||||
|
documentName: document.noun,
|
||||||
|
})}
|
||||||
|
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||||
|
isOpen={showPermanentDeleteModal}
|
||||||
|
>
|
||||||
|
<DocumentPermanentDelete
|
||||||
|
document={document}
|
||||||
|
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{can.update && (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("Create template")}
|
title={t("Create template")}
|
||||||
onRequestClose={() => setShowTemplateModal(false)}
|
onRequestClose={() => setShowTemplateModal(false)}
|
||||||
@ -391,6 +420,7 @@ function DocumentMenu({
|
|||||||
onSubmit={() => setShowTemplateModal(false)}
|
onSubmit={() => setShowTemplateModal(false)}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
60
app/scenes/DocumentPermanentDelete.js
Normal file
60
app/scenes/DocumentPermanentDelete.js
Normal file
@ -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 (
|
||||||
|
<Flex column>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<HelpText>
|
||||||
|
<Trans
|
||||||
|
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
|
||||||
|
values={{ documentTitle: document.titleWithDefault }}
|
||||||
|
components={{ em: <strong /> }}
|
||||||
|
/>
|
||||||
|
</HelpText>
|
||||||
|
<Button type="submit" danger>
|
||||||
|
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(DocumentPermanentDelete);
|
@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async delete(document: Document) {
|
async delete(document: Document, options?: {| permanent: boolean |}) {
|
||||||
await super.delete(document);
|
await super.delete(document, options);
|
||||||
|
|
||||||
// check to see if we have any shares related to this document already
|
// 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.
|
// loaded in local state. If so we can go ahead and remove those too.
|
||||||
|
@ -5,6 +5,7 @@ import { subtractDate } from "../../shared/utils/date";
|
|||||||
import documentCreator from "../commands/documentCreator";
|
import documentCreator from "../commands/documentCreator";
|
||||||
import documentImporter from "../commands/documentImporter";
|
import documentImporter from "../commands/documentImporter";
|
||||||
import documentMover from "../commands/documentMover";
|
import documentMover from "../commands/documentMover";
|
||||||
|
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
|
||||||
import env from "../env";
|
import env from "../env";
|
||||||
import {
|
import {
|
||||||
NotFoundError,
|
NotFoundError,
|
||||||
@ -1174,15 +1175,43 @@ router.post("documents.archive", auth(), async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post("documents.delete", 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");
|
ctx.assertPresent(id, "id is required");
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
if (permanent) {
|
||||||
|
const document = await Document.findByPk(id, {
|
||||||
|
userId: user.id,
|
||||||
|
paranoid: false,
|
||||||
|
});
|
||||||
|
authorize(user, "permanentDelete", document);
|
||||||
|
|
||||||
|
await Document.update(
|
||||||
|
{ parentDocumentId: null },
|
||||||
|
{
|
||||||
|
where: {
|
||||||
|
parentDocumentId: document.id,
|
||||||
|
},
|
||||||
|
paranoid: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
const document = await Document.findByPk(id, { userId: user.id });
|
||||||
authorize(user, "delete", document);
|
authorize(user, "delete", document);
|
||||||
|
|
||||||
await document.delete(user.id);
|
await document.delete(user.id);
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: "documents.delete",
|
name: "documents.delete",
|
||||||
documentId: document.id,
|
documentId: document.id,
|
||||||
@ -1192,6 +1221,7 @@ router.post("documents.delete", auth(), async (ctx) => {
|
|||||||
data: { title: document.title },
|
data: { title: document.title },
|
||||||
ip: ctx.request.ip,
|
ip: ctx.request.ip,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
|
@ -2201,6 +2201,26 @@ describe("#documents.delete", () => {
|
|||||||
expect(body.success).toEqual(true);
|
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 () => {
|
it("should allow deleting document without collection", async () => {
|
||||||
const { user, document, collection } = await seed();
|
const { user, document, collection } = await seed();
|
||||||
|
|
||||||
|
@ -2,10 +2,10 @@
|
|||||||
import { subDays } from "date-fns";
|
import { subDays } from "date-fns";
|
||||||
import debug from "debug";
|
import debug from "debug";
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
|
||||||
import { AuthenticationError } from "../errors";
|
import { AuthenticationError } from "../errors";
|
||||||
import { Document, Attachment } from "../models";
|
import { Document } from "../models";
|
||||||
import { Op, sequelize } from "../sequelize";
|
import { Op } from "../sequelize";
|
||||||
import parseAttachmentIds from "../utils/parseAttachmentIds";
|
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
const log = debug("utils");
|
const log = debug("utils");
|
||||||
@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => {
|
|||||||
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
|
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
|
||||||
|
|
||||||
const documents = await Document.scope("withUnpublished").findAll({
|
const documents = await Document.scope("withUnpublished").findAll({
|
||||||
attributes: ["id", "teamId", "text"],
|
attributes: ["id", "teamId", "text", "deletedAt"],
|
||||||
where: {
|
where: {
|
||||||
deletedAt: {
|
deletedAt: {
|
||||||
[Op.lt]: subDays(new Date(), 30),
|
[Op.lt]: subDays(new Date(), 30),
|
||||||
@ -30,54 +30,9 @@ router.post("utils.gc", async (ctx) => {
|
|||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
const query = `
|
const countDeletedDocument = await documentPermanentDeleter(documents);
|
||||||
SELECT COUNT(id)
|
|
||||||
FROM documents
|
|
||||||
WHERE "searchVector" @@ to_tsquery('english', :query) AND
|
|
||||||
"teamId" = :teamId AND
|
|
||||||
"id" != :documentId
|
|
||||||
`;
|
|
||||||
|
|
||||||
for (const document of documents) {
|
log(`Destroyed ${countDeletedDocument} 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`);
|
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
success: true,
|
success: true,
|
||||||
|
@ -2,8 +2,8 @@
|
|||||||
import { subDays } from "date-fns";
|
import { subDays } from "date-fns";
|
||||||
import TestServer from "fetch-test-server";
|
import TestServer from "fetch-test-server";
|
||||||
import app from "../app";
|
import app from "../app";
|
||||||
import { Attachment, Document } from "../models";
|
import { Document } from "../models";
|
||||||
import { buildAttachment, buildDocument } from "../test/factories";
|
import { buildDocument } from "../test/factories";
|
||||||
import { flushdb } from "../test/support";
|
import { flushdb } from "../test/support";
|
||||||
|
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
@ -67,94 +67,6 @@ describe("#utils.gc", () => {
|
|||||||
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
|
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 = ``;
|
|
||||||
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 = ``;
|
|
||||||
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 = ``;
|
|
||||||
await document1.save();
|
|
||||||
|
|
||||||
document.text = ``;
|
|
||||||
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 () => {
|
it("should destroy draft documents deleted more than 30 days ago", async () => {
|
||||||
await buildDocument({
|
await buildDocument({
|
||||||
publishedAt: undefined,
|
publishedAt: undefined,
|
||||||
|
64
server/commands/documentPermanentDeleter.js
Normal file
64
server/commands/documentPermanentDeleter.js
Normal file
@ -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,
|
||||||
|
});
|
||||||
|
}
|
123
server/commands/documentPermanentDeleter.test.js
Normal file
123
server/commands/documentPermanentDeleter.test.js
Normal file
@ -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 = ``;
|
||||||
|
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 = ``;
|
||||||
|
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 = ``;
|
||||||
|
await document1.save();
|
||||||
|
|
||||||
|
document.text = ``;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
@ -35,6 +35,7 @@ export type DocumentEvent =
|
|||||||
name: | "documents.create" // eslint-disable-line
|
name: | "documents.create" // eslint-disable-line
|
||||||
| "documents.publish"
|
| "documents.publish"
|
||||||
| "documents.delete"
|
| "documents.delete"
|
||||||
|
| "documents.permanent_delete"
|
||||||
| "documents.pin"
|
| "documents.pin"
|
||||||
| "documents.unpin"
|
| "documents.unpin"
|
||||||
| "documents.archive"
|
| "documents.archive"
|
||||||
|
@ -65,6 +65,7 @@ Event.ACTIVITY_EVENTS = [
|
|||||||
"documents.unpin",
|
"documents.unpin",
|
||||||
"documents.move",
|
"documents.move",
|
||||||
"documents.delete",
|
"documents.delete",
|
||||||
|
"documents.permanent_delete",
|
||||||
"documents.restore",
|
"documents.restore",
|
||||||
"users.create",
|
"users.create",
|
||||||
];
|
];
|
||||||
@ -90,6 +91,7 @@ Event.AUDIT_EVENTS = [
|
|||||||
"documents.unpin",
|
"documents.unpin",
|
||||||
"documents.move",
|
"documents.move",
|
||||||
"documents.delete",
|
"documents.delete",
|
||||||
|
"documents.permanent_delete",
|
||||||
"documents.restore",
|
"documents.restore",
|
||||||
"groups.create",
|
"groups.create",
|
||||||
"groups.update",
|
"groups.update",
|
||||||
|
@ -101,8 +101,15 @@ allow(User, ["pin", "unpin"], Document, (user, document) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
allow(User, "delete", Document, (user, document) => {
|
allow(User, "delete", Document, (user, document) => {
|
||||||
// unpublished drafts can always be deleted
|
|
||||||
if (user.isViewer) return false;
|
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 (
|
if (
|
||||||
!document.deletedAt &&
|
!document.deletedAt &&
|
||||||
!document.publishedAt &&
|
!document.publishedAt &&
|
||||||
@ -111,13 +118,18 @@ allow(User, "delete", Document, (user, document) => {
|
|||||||
return true;
|
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
|
// allow deleting document without a collection
|
||||||
if (document.collection && cannot(user, "update", document.collection)) {
|
if (document.collection && cannot(user, "update", document.collection)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.deletedAt) return false;
|
|
||||||
|
|
||||||
return user.teamId === document.teamId;
|
return user.teamId === document.teamId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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.pin":
|
||||||
case "documents.unpin":
|
case "documents.unpin":
|
||||||
case "documents.update": {
|
case "documents.update": {
|
||||||
|
@ -181,12 +181,14 @@
|
|||||||
"Create template": "Create template",
|
"Create template": "Create template",
|
||||||
"Duplicate": "Duplicate",
|
"Duplicate": "Duplicate",
|
||||||
"Unpublish": "Unpublish",
|
"Unpublish": "Unpublish",
|
||||||
|
"Permanently delete": "Permanently delete",
|
||||||
"Move": "Move",
|
"Move": "Move",
|
||||||
"History": "History",
|
"History": "History",
|
||||||
"Download": "Download",
|
"Download": "Download",
|
||||||
"Print": "Print",
|
"Print": "Print",
|
||||||
"Move {{ documentName }}": "Move {{ documentName }}",
|
"Move {{ documentName }}": "Move {{ documentName }}",
|
||||||
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
||||||
|
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Delete group": "Delete group",
|
"Delete group": "Delete group",
|
||||||
"Group options": "Group options",
|
"Group options": "Group options",
|
||||||
@ -312,6 +314,8 @@
|
|||||||
"I’m sure – Delete": "I’m sure – Delete",
|
"I’m sure – Delete": "I’m sure – Delete",
|
||||||
"Archiving": "Archiving",
|
"Archiving": "Archiving",
|
||||||
"Couldn’t create the document, try again?": "Couldn’t create the document, try again?",
|
"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 <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.": "Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone.",
|
||||||
"Search documents": "Search documents",
|
"Search documents": "Search documents",
|
||||||
"No documents found for your filters.": "No documents found for your filters.",
|
"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.",
|
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||||
|
Reference in New Issue
Block a user