feat: Allow deletion of imports (#5907)

This commit is contained in:
Tom Moor
2023-10-01 21:24:50 -04:00
committed by GitHub
parent 16cd82a732
commit e7b7032284
24 changed files with 304 additions and 184 deletions

View File

@@ -126,6 +126,7 @@ const Subtitle = styled.p<{ $small?: boolean; $selected?: boolean }>`
export const Actions = styled(Flex)<{ $selected?: boolean }>`
align-self: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) =>
props.$selected ? props.theme.white : props.theme.textSecondary};
`;

View File

@@ -2,17 +2,21 @@ import { DownloadIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { FileOperationState, FileOperationType } from "@shared/types";
import FileOperation from "~/models/FileOperation";
import ContextMenu from "~/components/ContextMenu";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Template from "~/components/ContextMenu/Template";
import usePolicy from "~/hooks/usePolicy";
type Props = {
id: string;
fileOperation: FileOperation;
onDelete: (ev: React.SyntheticEvent) => Promise<void>;
};
function FileOperationMenu({ id, onDelete }: Props) {
function FileOperationMenu({ fileOperation, onDelete }: Props) {
const { t } = useTranslation();
const can = usePolicy(fileOperation.id);
const menu = useMenuState({
modal: true,
});
@@ -28,7 +32,10 @@ function FileOperationMenu({ id, onDelete }: Props) {
type: "link",
title: t("Download"),
icon: <DownloadIcon />,
href: "/api/fileOperations.redirect?id=" + id,
visible:
fileOperation.type === FileOperationType.Export &&
fileOperation.state === FileOperationState.Complete,
href: fileOperation.downloadUrl,
},
{
type: "separator",
@@ -36,6 +43,7 @@ function FileOperationMenu({ id, onDelete }: Props) {
{
type: "button",
title: t("Delete"),
visible: can.delete,
icon: <TrashIcon />,
dangerous: true,
onClick: onDelete,

View File

@@ -29,6 +29,11 @@ class FileOperation extends Model {
get sizeInMB(): string {
return bytesToHumanReadable(this.size);
}
@computed
get downloadUrl(): string {
return `/api/fileOperations.redirect?id=${this.id}`;
}
}
export default FileOperation;

View File

@@ -10,7 +10,6 @@ import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import ExportDialog from "../../components/ExportDialog";
import FileOperationListItem from "./components/FileOperationListItem";
@@ -18,7 +17,6 @@ function Export() {
const { t } = useTranslation();
const user = useCurrentUser();
const { fileOperations, dialogs } = useStores();
const { showToast } = useToasts();
const handleOpenDialog = React.useCallback(
async (ev: React.SyntheticEvent) => {
@@ -33,20 +31,6 @@ function Export() {
[dialogs, t]
);
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]
);
return (
<Scene title={t("Export")} icon={<DownloadIcon />}>
<Heading>{t("Export")}</Heading>
@@ -77,11 +61,7 @@ function Export() {
</h2>
}
renderItem={(item: FileOperation) => (
<FileOperationListItem
key={item.id}
fileOperation={item}
handleDelete={handleDelete}
/>
<FileOperationListItem key={item.id} fileOperation={item} />
)}
/>
</Scene>

View File

@@ -10,21 +10,26 @@ import {
} from "@shared/types";
import FileOperation from "~/models/FileOperation";
import { Action } from "~/components/Actions";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import ListItem from "~/components/List/Item";
import Spinner from "~/components/Spinner";
import Time from "~/components/Time";
import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import FileOperationMenu from "~/menus/FileOperationMenu";
type Props = {
fileOperation: FileOperation;
handleDelete?: (fileOperation: FileOperation) => Promise<void>;
};
const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
const FileOperationListItem = ({ fileOperation }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const theme = useTheme();
const { dialogs, fileOperations } = useStores();
const { showToast } = useToasts();
const stateMapping = {
[FileOperationState.Creating]: t("Processing"),
[FileOperationState.Uploading]: t("Processing"),
@@ -55,6 +60,46 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
? fileOperation.name
: t("All collections");
const handleDelete = React.useCallback(async () => {
try {
await fileOperations.delete(fileOperation);
if (fileOperation.type === FileOperationType.Import) {
showToast(t("Import deleted"));
} else {
showToast(t("Export deleted"));
}
} catch (err) {
showToast(err.message, {
type: "error",
});
}
}, [fileOperation, fileOperations, showToast, t]);
const handleConfirmDelete = React.useCallback(async () => {
dialogs.openModal({
isCentered: true,
title: t("Are you sure you want to delete this import?"),
content: (
<ConfirmationDialog
onSubmit={handleDelete}
submitText={t("Im sure")}
savingText={`${t("Deleting")}`}
danger
>
{t(
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone."
)}
</ConfirmationDialog>
),
});
}, [dialogs, t, handleDelete]);
const showMenu =
(fileOperation.type === FileOperationType.Export &&
fileOperation.state === FileOperationState.Complete) ||
fileOperation.type === FileOperationType.Import;
return (
<ListItem
title={title}
@@ -76,17 +121,18 @@ const FileOperationListItem = ({ fileOperation, handleDelete }: Props) => {
</>
}
actions={
fileOperation.state === FileOperationState.Complete && handleDelete ? (
showMenu && (
<Action>
<FileOperationMenu
id={fileOperation.id}
onDelete={async (ev) => {
ev.preventDefault();
await handleDelete(fileOperation);
}}
fileOperation={fileOperation}
onDelete={
fileOperation.type === FileOperationType.Import
? handleConfirmDelete
: handleDelete
}
/>
</Action>
) : undefined
)
}
/>
);

View File

@@ -0,0 +1,36 @@
import { Transaction } from "sequelize";
import { Collection, Event, User } from "@server/models";
type Props = {
/** The collection to delete */
collection: Collection;
/** The actor who is deleting the collection */
user: User;
/** The database transaction to use */
transaction: Transaction;
/** The IP address of the current request */
ip: string;
};
export default async function collectionDestroyer({
collection,
transaction,
user,
ip,
}: Props) {
await collection.destroy({ transaction });
await Event.create(
{
name: "collections.delete",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: {
name: collection.name,
},
ip,
},
{ transaction }
);
}

View File

@@ -1,23 +0,0 @@
import { FileOperation } from "@server/models";
import { buildAdmin, buildFileOperation } from "@server/test/factories";
import fileOperationDeleter from "./fileOperationDeleter";
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({
where: {
teamId: admin.teamId,
},
})
).toEqual(0);
});
});

View File

@@ -1,32 +1,30 @@
import { Transaction } from "sequelize";
import { FileOperation, Event, User } from "@server/models";
import { sequelize } from "@server/storage/database";
export default async function fileOperationDeleter(
fileOperation: FileOperation,
user: User,
ip: string
) {
const transaction = await sequelize.transaction();
type Props = {
fileOperation: FileOperation;
user: User;
ip: string;
transaction: Transaction;
};
try {
await fileOperation.destroy({
export default async function fileOperationDeleter({
fileOperation,
user,
ip,
transaction,
}: Props) {
await fileOperation.destroy({ transaction });
await Event.create(
{
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
ip,
},
{
transaction,
});
await Event.create(
{
name: "fileOperations.delete",
teamId: user.teamId,
actorId: user.id,
modelId: fileOperation.id,
ip,
},
{
transaction,
}
);
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
}
);
}

View File

@@ -0,0 +1,14 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn("file_operations", "deletedAt", {
type: Sequelize.DATE,
allowNull: true,
});
},
async down (queryInterface) {
await queryInterface.removeColumn("file_operations", "deletedAt");
}
};

View File

@@ -29,6 +29,7 @@ import {
Scopes,
DataType,
Length as SimpleLength,
BeforeDestroy,
} from "sequelize-typescript";
import isUUID from "validator/lib/isUUID";
import type { CollectionSort } from "@shared/types";
@@ -37,6 +38,7 @@ import { sortNavigationNodes } from "@shared/utils/collections";
import slugify from "@shared/utils/slugify";
import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers";
import { CollectionValidation } from "@shared/validations";
import { ValidationError } from "@server/errors";
import Document from "./Document";
import FileOperation from "./FileOperation";
import Group from "./Group";
@@ -265,6 +267,18 @@ class Collection extends ParanoidModel {
}
}
@BeforeDestroy
static async checkLastCollection(model: Collection) {
const total = await this.count({
where: {
teamId: model.teamId,
},
});
if (total === 1) {
throw ValidationError("Cannot delete last collection");
}
}
@AfterDestroy
static async onAfterDestroy(model: Collection) {
await Document.destroy({

View File

@@ -459,16 +459,18 @@ class Document extends ParanoidModel {
return null;
}
const { includeState, userId, ...rest } = options;
// allow default preloading of collection membership if `userId` is passed in find options
// almost every endpoint needs the collection membership to determine policy permissions.
const scope = this.scope([
...(options.includeState ? [] : ["withoutState"]),
...(includeState ? [] : ["withoutState"]),
"withDrafts",
{
method: ["withCollectionPermissions", options.userId, options.paranoid],
method: ["withCollectionPermissions", userId, rest.paranoid],
},
{
method: ["withViews", options.userId],
method: ["withViews", userId],
},
]);
@@ -477,7 +479,7 @@ class Document extends ParanoidModel {
where: {
id,
},
...options,
...rest,
});
}
@@ -487,7 +489,7 @@ class Document extends ParanoidModel {
where: {
urlId: match[1],
},
...options,
...rest,
});
}

View File

@@ -17,7 +17,7 @@ import FileStorage from "@server/storage/files";
import Collection from "./Collection";
import Team from "./Team";
import User from "./User";
import IdModel from "./base/IdModel";
import ParanoidModel from "./base/ParanoidModel";
import Fix from "./decorators/Fix";
@DefaultScope(() => ({
@@ -36,7 +36,7 @@ import Fix from "./decorators/Fix";
}))
@Table({ tableName: "file_operations", modelName: "file_operation" })
@Fix
class FileOperation extends IdModel {
class FileOperation extends ParanoidModel {
@Column(DataType.ENUM(...Object.values(FileOperationType)))
type: FileOperationType;
@@ -73,7 +73,7 @@ class FileOperation extends IdModel {
throw err;
}
}
await this.save();
return this.save();
};
/**

View File

@@ -1,3 +1,4 @@
import { FileOperationState, FileOperationType } from "@shared/types";
import { User, Team, FileOperation } from "@server/models";
import { allow } from "./cancan";
@@ -13,9 +14,22 @@ allow(
}
);
allow(User, ["read", "delete"], FileOperation, (user, fileOperation) => {
allow(User, "read", FileOperation, (user, fileOperation) => {
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
return false;
}
return user.isAdmin;
});
allow(User, "delete", FileOperation, (user, fileOperation) => {
if (!fileOperation || user.isViewer || user.teamId !== fileOperation.teamId) {
return false;
}
if (
fileOperation.type === FileOperationType.Export &&
fileOperation.state !== FileOperationState.Complete
) {
return false;
}
return user.isAdmin;
});

View File

@@ -0,0 +1,35 @@
import teamUpdater from "@server/commands/teamUpdater";
import { Team, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Event as TEvent, CollectionEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class CollectionDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["collections.delete"];
async perform(event: CollectionEvent) {
await sequelize.transaction(async (transaction) => {
const team = await Team.findByPk(event.teamId, {
rejectOnEmpty: true,
transaction,
lock: transaction.LOCK.UPDATE,
});
if (team?.defaultCollectionId === event.collectionId) {
const user = await User.findByPk(event.actorId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
await teamUpdater({
params: { defaultCollectionId: null },
user,
team,
transaction,
ip: event.ip,
});
}
});
}
}

View File

@@ -8,7 +8,7 @@ import { Notification } from "@server/models";
import { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class NotificationsProcessor extends BaseProcessor {
export default class EmailsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["notifications.create"];
async perform(event: NotificationEvent) {

View File

@@ -1,4 +1,3 @@
import invariant from "invariant";
import { FileOperationFormat, FileOperationType } from "@shared/types";
import { FileOperation } from "@server/models";
import { Event as TEvent, FileOperationEvent } from "@server/types";
@@ -10,16 +9,13 @@ import ImportMarkdownZipTask from "../tasks/ImportMarkdownZipTask";
import ImportNotionTask from "../tasks/ImportNotionTask";
import BaseProcessor from "./BaseProcessor";
export default class FileOperationsProcessor extends BaseProcessor {
export default class FileOperationCreatedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["fileOperations.create"];
async perform(event: FileOperationEvent) {
if (event.name !== "fileOperations.create") {
return;
}
const fileOperation = await FileOperation.findByPk(event.modelId);
invariant(fileOperation, "fileOperation not found");
const fileOperation = await FileOperation.findByPk(event.modelId, {
rejectOnEmpty: true,
});
// map file operation type and format to the appropriate task
if (fileOperation.type === FileOperationType.Import) {

View File

@@ -0,0 +1,54 @@
import { FileOperationState, FileOperationType } from "@shared/types";
import collectionDestroyer from "@server/commands/collectionDestroyer";
import Logger from "@server/logging/Logger";
import { Collection, FileOperation, User } from "@server/models";
import { sequelize } from "@server/storage/database";
import { Event as TEvent, FileOperationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
export default class FileOperationDeletedProcessor extends BaseProcessor {
static applicableEvents: TEvent["name"][] = ["fileOperations.delete"];
async perform(event: FileOperationEvent) {
await sequelize.transaction(async (transaction) => {
const fileOperation = await FileOperation.findByPk(event.modelId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
if (
fileOperation.type === FileOperationType.Export ||
fileOperation.state !== FileOperationState.Complete
) {
return;
}
const user = await User.findByPk(event.actorId, {
rejectOnEmpty: true,
paranoid: false,
transaction,
});
const collections = await Collection.findAll({
transaction,
lock: transaction.LOCK.UPDATE,
where: {
teamId: fileOperation.teamId,
importId: fileOperation.id,
},
});
for (const collection of collections) {
Logger.debug("processor", "Destroying collection created from import", {
collectionId: collection.id,
});
await collectionDestroyer({
collection,
transaction,
user,
ip: event.ip,
});
}
});
}
}

View File

@@ -13,7 +13,7 @@ describe("DetachDraftsFromCollectionTask", () => {
createdById: collection.createdById,
teamId: collection.teamId,
});
await collection.destroy();
await collection.destroy({ hooks: false });
const task = new DetachDraftsFromCollectionTask();
await task.perform({

View File

@@ -7,9 +7,9 @@ import {
FileOperationState,
FileOperationType,
} from "@shared/types";
import collectionDestroyer from "@server/commands/collectionDestroyer";
import collectionExporter from "@server/commands/collectionExporter";
import teamUpdater from "@server/commands/teamUpdater";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
@@ -803,44 +803,15 @@ router.post(
}).findByPk(id, {
transaction,
});
const team = await Team.findByPk(user.teamId);
authorize(user, "delete", collection);
const total = await Collection.count({
where: {
teamId: user.teamId,
},
await collectionDestroyer({
collection,
transaction,
user,
ip: ctx.request.ip,
});
if (total === 1) {
throw ValidationError("Cannot delete last collection");
}
await collection.destroy({ transaction });
if (team && team.defaultCollectionId === collection.id) {
await teamUpdater({
params: { defaultCollectionId: null },
ip: ctx.request.ip,
user,
team,
transaction,
});
}
await Event.create(
{
name: "collections.delete",
collectionId: collection.id,
teamId: collection.teamId,
actorId: user.id,
data: {
name: collection.name,
},
ip: ctx.request.ip,
},
{ transaction }
);
ctx.body = {
success: true,

View File

@@ -2301,7 +2301,7 @@ describe("#documents.restore", () => {
teamId: team.id,
});
await document.destroy();
await collection.destroy();
await collection.destroy({ hooks: false });
const res = await server.post("/api/documents.restore", {
body: {
token: user.getJwtToken(),

View File

@@ -150,7 +150,7 @@ describe("#fileOperations.list", () => {
userId: admin.id,
collectionId: collection.id,
});
await collection.destroy();
await collection.destroy({ hooks: false });
const isCollectionPresent = await Collection.findByPk(collection.id);
expect(isCollectionPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", {

View File

@@ -3,6 +3,7 @@ import { WhereOptions } from "sequelize";
import fileOperationDeleter from "@server/commands/fileOperationDeleter";
import { ValidationError } from "@server/errors";
import auth from "@server/middlewares/authentication";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import { FileOperation, Team } from "@server/models";
import { authorize } from "@server/policies";
@@ -105,16 +106,23 @@ router.post(
"fileOperations.delete",
auth({ admin: true }),
validate(T.FileOperationsDeleteSchema),
transaction(),
async (ctx: APIContext<T.FileOperationsDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const fileOperation = await FileOperation.unscoped().findByPk(id, {
rejectOnEmpty: true,
});
authorize(user, "delete", fileOperation);
await fileOperationDeleter(fileOperation, user, ctx.request.ip);
await fileOperationDeleter({
fileOperation,
user,
ip: ctx.request.ip,
transaction,
});
ctx.body = {
success: true,

View File

@@ -216,48 +216,6 @@ describe("#team.update", () => {
expect(body.data.defaultCollectionId).toEqual(collection.id);
});
it("should default to home if default collection is deleted", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
teamId: team.id,
userId: admin.id,
});
await buildCollection({
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/team.update", {
body: {
token: admin.getJwtToken(),
defaultCollectionId: collection.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.defaultCollectionId).toEqual(collection.id);
const deleteRes = await server.post("/api/collections.delete", {
body: {
token: admin.getJwtToken(),
id: collection.id,
},
});
expect(deleteRes.status).toEqual(200);
const res3 = await server.post("/api/auth.info", {
body: {
token: admin.getJwtToken(),
},
});
const body3 = await res3.json();
expect(res3.status).toEqual(200);
expect(body3.data.team.defaultCollectionId).toEqual(null);
});
it("should update default collection to null when collection is made private", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });

View File

@@ -739,6 +739,11 @@
"Completed": "Completed",
"Failed": "Failed",
"All collections": "All collections",
"Import deleted": "Import deleted",
"Export deleted": "Export deleted",
"Are you sure you want to delete this import?": "Are you sure you want to delete this import?",
"Im sure": "Im sure",
"Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.": "Deleting this import will also delete all collections and documents that were created from it. This cannot be undone.",
"{{userName}} requested": "{{userName}} requested",
"Upload": "Upload",
"Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload": "Drag and drop the zip file from the JSON export option in {{appName}}, or click to upload",
@@ -782,7 +787,6 @@
"Danger": "Danger",
"You can delete this entire workspace including collections, documents, and users.": "You can delete this entire workspace including collections, documents, and users.",
"Export data": "Export data",
"Export deleted": "Export deleted",
"A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.": "A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when its complete.",
"Recent exports": "Recent exports",
"Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.": "Manage optional and beta features. Changing these settings will affect the experience for all members of the workspace.",
@@ -851,7 +855,6 @@
"Choose a photo or image to represent yourself.": "Choose a photo or image to represent yourself.",
"This could be your real name, or a nickname — however youd like people to refer to you.": "This could be your real name, or a nickname — however youd like people to refer to you.",
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
"Im sure": "Im sure",
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
"Settings that impact the access, security, and content of your knowledge base.": "Settings that impact the access, security, and content of your knowledge base.",
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",