fix: Delete collection exports (#2595)
This commit is contained in:
43
app/menus/FileOperationMenu.js
Normal file
43
app/menus/FileOperationMenu.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// @flow
|
||||||
|
import * as React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMenuState } from "reakit/Menu";
|
||||||
|
import ContextMenu from "components/ContextMenu";
|
||||||
|
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
|
||||||
|
import Template from "components/ContextMenu/Template";
|
||||||
|
|
||||||
|
type Props = {|
|
||||||
|
id: string,
|
||||||
|
onDelete: (ev: SyntheticEvent<>) => Promise<void>,
|
||||||
|
|};
|
||||||
|
|
||||||
|
function FileOperationMenu({ id, onDelete }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const menu = useMenuState({ modal: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OverflowMenuButton aria-label={t("Show Menu")} {...menu} />
|
||||||
|
<ContextMenu {...menu} aria-label={t("File Operation options")}>
|
||||||
|
<Template
|
||||||
|
{...menu}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
title: t("Download"),
|
||||||
|
href: "/api/fileOperations.redirect?id=" + id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "separator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Delete"),
|
||||||
|
onClick: onDelete,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ContextMenu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileOperationMenu;
|
||||||
@@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
|
|||||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { parseOutlineExport } from "shared/utils/zip";
|
import { parseOutlineExport } from "shared/utils/zip";
|
||||||
|
import FileOperation from "models/FileOperation";
|
||||||
import Button from "components/Button";
|
import Button from "components/Button";
|
||||||
import Heading from "components/Heading";
|
import Heading from "components/Heading";
|
||||||
import HelpText from "components/HelpText";
|
import HelpText from "components/HelpText";
|
||||||
@@ -102,6 +103,18 @@ function ImportExport() {
|
|||||||
[t, collections, showToast]
|
[t, collections, showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDelete = React.useCallback(
|
||||||
|
async (fileOperation: FileOperation) => {
|
||||||
|
try {
|
||||||
|
await fileOperations.delete(fileOperation);
|
||||||
|
showToast(t("Export deleted"));
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, { type: "error" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fileOperations, showToast, t]
|
||||||
|
);
|
||||||
|
|
||||||
const hasCollections = importDetails
|
const hasCollections = importDetails
|
||||||
? !!importDetails.filter((detail) => detail.type === "collection").length
|
? !!importDetails.filter((detail) => detail.type === "collection").length
|
||||||
: false;
|
: false;
|
||||||
@@ -216,6 +229,7 @@ function ImportExport() {
|
|||||||
<FileOperationListItem
|
<FileOperationListItem
|
||||||
key={item.id + item.state}
|
key={item.id + item.state}
|
||||||
fileOperation={item}
|
fileOperation={item}
|
||||||
|
handleDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import FileOperation from "models/FileOperation";
|
import FileOperation from "models/FileOperation";
|
||||||
import Button from "components/Button";
|
import { Action } from "components/Actions";
|
||||||
import ListItem from "components/List/Item";
|
import ListItem from "components/List/Item";
|
||||||
import Time from "components/Time";
|
import Time from "components/Time";
|
||||||
|
import useCurrentUser from "hooks/useCurrentUser";
|
||||||
|
import FileOperationMenu from "menus/FileOperationMenu";
|
||||||
type Props = {|
|
type Props = {|
|
||||||
fileOperation: FileOperation,
|
fileOperation: FileOperation,
|
||||||
|
handleDelete: (FileOperation) => Promise<void>,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
const FileOperationListItem = ({ fileOperation }: Props) => {
|
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const user = useCurrentUser();
|
||||||
|
|
||||||
const stateMapping = {
|
const stateMapping = {
|
||||||
creating: t("Processing"),
|
creating: t("Processing"),
|
||||||
@@ -34,7 +37,7 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
|||||||
)}
|
)}
|
||||||
{t(`{{userName}} requested`, {
|
{t(`{{userName}} requested`, {
|
||||||
userName:
|
userName:
|
||||||
fileOperation.id === fileOperation.user.id
|
user.id === fileOperation.user.id
|
||||||
? t("You")
|
? t("You")
|
||||||
: fileOperation.user.name,
|
: fileOperation.user.name,
|
||||||
})}
|
})}
|
||||||
@@ -45,13 +48,15 @@ const FileOperationListItem = ({ fileOperation }: Props) => {
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
fileOperation.state === "complete" ? (
|
fileOperation.state === "complete" ? (
|
||||||
<Button
|
<Action>
|
||||||
as="a"
|
<FileOperationMenu
|
||||||
href={`/api/fileOperations.redirect?id=${fileOperation.id}`}
|
id={fileOperation.id}
|
||||||
neutral
|
onDelete={async (ev) => {
|
||||||
>
|
ev.preventDefault();
|
||||||
{t("Download")}
|
await handleDelete(fileOperation);
|
||||||
</Button>
|
}}
|
||||||
|
/>
|
||||||
|
</Action>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import BaseStore from "./BaseStore";
|
|||||||
import RootStore from "./RootStore";
|
import RootStore from "./RootStore";
|
||||||
|
|
||||||
export default class FileOperationsStore extends BaseStore<FileOperation> {
|
export default class FileOperationsStore extends BaseStore<FileOperation> {
|
||||||
actions = ["list", "info"];
|
actions = ["list", "info", "delete"];
|
||||||
|
|
||||||
constructor(rootStore: RootStore) {
|
constructor(rootStore: RootStore) {
|
||||||
super(rootStore, FileOperation);
|
super(rootStore, FileOperation);
|
||||||
|
|||||||
31
server/commands/fileOperationDeleter.js
Normal file
31
server/commands/fileOperationDeleter.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// @flow
|
||||||
|
import { FileOperation, Event, User } from "../models";
|
||||||
|
import { sequelize } from "../sequelize";
|
||||||
|
|
||||||
|
export default async function fileOperationDeleter(
|
||||||
|
fileOp: FileOperation,
|
||||||
|
user: User,
|
||||||
|
ip: string
|
||||||
|
) {
|
||||||
|
let transaction = await sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fileOp.destroy({ transaction });
|
||||||
|
|
||||||
|
await Event.create(
|
||||||
|
{
|
||||||
|
name: "fileOperations.delete",
|
||||||
|
teamId: user.teamId,
|
||||||
|
actorId: user.id,
|
||||||
|
data: fileOp.dataValues,
|
||||||
|
ip,
|
||||||
|
},
|
||||||
|
{ transaction }
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
server/commands/fileOperationDeleter.test.js
Normal file
31
server/commands/fileOperationDeleter.test.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// @flow
|
||||||
|
import { FileOperation } from "../models";
|
||||||
|
import { buildAdmin, buildFileOperation } from "../test/factories";
|
||||||
|
import { flushdb } from "../test/support";
|
||||||
|
import fileOperationDeleter from "./fileOperationDeleter";
|
||||||
|
|
||||||
|
jest.mock("aws-sdk", () => {
|
||||||
|
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||||
|
return {
|
||||||
|
S3: jest.fn(() => mS3),
|
||||||
|
Endpoint: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => flushdb());
|
||||||
|
|
||||||
|
describe("fileOperationDeleter", () => {
|
||||||
|
const ip = "127.0.0.1";
|
||||||
|
|
||||||
|
it("should destroy file operation", async () => {
|
||||||
|
const admin = await buildAdmin();
|
||||||
|
const fileOp = await buildFileOperation({
|
||||||
|
userId: admin.id,
|
||||||
|
teamId: admin.teamId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fileOperationDeleter(fileOp, admin, ip);
|
||||||
|
|
||||||
|
expect(await FileOperation.count()).toEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,10 +34,14 @@ const FileOperation = sequelize.define("file_operations", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
FileOperation.beforeDestroy(async (model) => {
|
||||||
|
await deleteFromS3(model.key);
|
||||||
|
});
|
||||||
|
|
||||||
FileOperation.prototype.expire = async function () {
|
FileOperation.prototype.expire = async function () {
|
||||||
this.state = "expired";
|
this.state = "expired";
|
||||||
await deleteFromS3(this.key);
|
await deleteFromS3(this.key);
|
||||||
this.save();
|
await this.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
FileOperation.associate = (models) => {
|
FileOperation.associate = (models) => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import Router from "koa-router";
|
import Router from "koa-router";
|
||||||
|
import fileOperationDeleter from "../../commands/fileOperationDeleter";
|
||||||
import { NotFoundError, ValidationError } from "../../errors";
|
import { NotFoundError, ValidationError } from "../../errors";
|
||||||
import auth from "../../middlewares/authentication";
|
import auth from "../../middlewares/authentication";
|
||||||
import { FileOperation, Team } from "../../models";
|
import { FileOperation, Team } from "../../models";
|
||||||
@@ -88,7 +89,7 @@ router.post("fileOperations.redirect", auth(), async (ctx) => {
|
|||||||
authorize(user, fileOp.type, team);
|
authorize(user, fileOp.type, team);
|
||||||
|
|
||||||
if (fileOp.state !== "complete") {
|
if (fileOp.state !== "complete") {
|
||||||
throw new ValidationError("file operation is not complete yet");
|
throw new ValidationError(`${fileOp.type} is not complete yet`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessUrl = await getSignedUrl(fileOp.key);
|
const accessUrl = await getSignedUrl(fileOp.key);
|
||||||
@@ -96,4 +97,24 @@ router.post("fileOperations.redirect", auth(), async (ctx) => {
|
|||||||
ctx.redirect(accessUrl);
|
ctx.redirect(accessUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("fileOperations.delete", auth(), async (ctx) => {
|
||||||
|
const { id } = ctx.body;
|
||||||
|
ctx.assertUuid(id, "id is required");
|
||||||
|
|
||||||
|
const user = ctx.state.user;
|
||||||
|
const team = await Team.findByPk(user.teamId);
|
||||||
|
const fileOp = await FileOperation.findByPk(id);
|
||||||
|
|
||||||
|
if (!fileOp) {
|
||||||
|
throw new NotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize(user, fileOp.type, team);
|
||||||
|
|
||||||
|
await fileOperationDeleter(fileOp, user, ctx.request.ip);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import TestServer from "fetch-test-server";
|
import TestServer from "fetch-test-server";
|
||||||
|
|
||||||
import { Collection, User } from "../../models";
|
import { Collection, User, Event, FileOperation } from "../../models";
|
||||||
import webService from "../../services/web";
|
import webService from "../../services/web";
|
||||||
import {
|
import {
|
||||||
buildAdmin,
|
buildAdmin,
|
||||||
@@ -15,6 +15,14 @@ import { flushdb } from "../../test/support";
|
|||||||
const app = webService();
|
const app = webService();
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
|
|
||||||
|
jest.mock("aws-sdk", () => {
|
||||||
|
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
|
||||||
|
return {
|
||||||
|
S3: jest.fn(() => mS3),
|
||||||
|
Endpoint: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => flushdb());
|
beforeEach(() => flushdb());
|
||||||
afterAll(() => server.close());
|
afterAll(() => server.close());
|
||||||
|
|
||||||
@@ -234,7 +242,7 @@ describe("#fileOperations.redirect", () => {
|
|||||||
|
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(res.status).toEqual(400);
|
expect(res.status).toEqual(400);
|
||||||
expect(body.message).toEqual("file operation is not complete yet");
|
expect(body.message).toEqual("export is not complete yet");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,3 +289,27 @@ describe("#fileOperations.info", () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("#fileOperations.delete", () => {
|
||||||
|
it("should delete file operation", async () => {
|
||||||
|
const team = await buildTeam();
|
||||||
|
const admin = await buildAdmin({ teamId: team.id });
|
||||||
|
const exportData = await buildFileOperation({
|
||||||
|
type: "export",
|
||||||
|
teamId: team.id,
|
||||||
|
userId: admin.id,
|
||||||
|
state: "complete",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteResponse = await server.post("/api/fileOperations.delete", {
|
||||||
|
body: {
|
||||||
|
token: admin.getJwtToken(),
|
||||||
|
id: exportData.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.status).toBe(200);
|
||||||
|
expect(await Event.count()).toBe(1);
|
||||||
|
expect(await FileOperation.count()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -268,15 +268,12 @@ export async function buildFileOperation(overrides: Object = {}) {
|
|||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!overrides.collectionId) {
|
|
||||||
const collection = await buildCollection(overrides);
|
|
||||||
overrides.collectionId = collection.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return FileOperation.create({
|
return FileOperation.create({
|
||||||
state: "creating",
|
state: "creating",
|
||||||
size: 0,
|
size: 0,
|
||||||
key: "key/to/aws/file.zip",
|
key: "uploads/key/to/file.zip",
|
||||||
|
collectionId: null,
|
||||||
|
type: "export",
|
||||||
url: "https://www.urltos3file.com/file.zip",
|
url: "https://www.urltos3file.com/file.zip",
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export type CollectionExportAllEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type FileOperationEvent = {
|
export type FileOperationEvent = {
|
||||||
name: "fileOperations.update",
|
name: "fileOperations.update" | "fileOperation.delete",
|
||||||
teamId: string,
|
teamId: string,
|
||||||
actorId: string,
|
actorId: string,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -227,6 +227,8 @@
|
|||||||
"Print": "Print",
|
"Print": "Print",
|
||||||
"Move {{ documentName }}": "Move {{ documentName }}",
|
"Move {{ documentName }}": "Move {{ documentName }}",
|
||||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||||
|
"Show Menu": "Show Menu",
|
||||||
|
"File Operation options": "File Operation options",
|
||||||
"Edit group": "Edit group",
|
"Edit group": "Edit group",
|
||||||
"Delete group": "Delete group",
|
"Delete group": "Delete group",
|
||||||
"Group options": "Group options",
|
"Group options": "Group options",
|
||||||
@@ -518,6 +520,7 @@
|
|||||||
"No groups have been created yet": "No groups have been created yet",
|
"No groups have been created yet": "No groups have been created yet",
|
||||||
"Import started": "Import started",
|
"Import started": "Import started",
|
||||||
"Export in progress…": "Export in progress…",
|
"Export in progress…": "Export in progress…",
|
||||||
|
"Export deleted": "Export deleted",
|
||||||
"It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.",
|
"It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.": "It is possible to import a zip file of folders and Markdown files previously exported from an Outline instance. Support will soon be added for importing from other services.",
|
||||||
"Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.": "Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.",
|
"Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.": "Your file has been uploaded and the import is currently being processed, you can safely leave this page while it completes.",
|
||||||
"Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.": "Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.",
|
"Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.": "Sorry, the file <em>{{ fileName }}</em> is missing valid collections or documents.",
|
||||||
|
|||||||
Reference in New Issue
Block a user