fix: Refactor collection exports to not send email attachment (#2460)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Saumya Pandey 2021-08-29 02:57:07 +05:30 committed by GitHub
parent 28aef82af9
commit 00ba65f3ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1252 additions and 167 deletions

View File

@ -128,11 +128,11 @@ export type Props = {|
fullwidth?: boolean, fullwidth?: boolean,
autoFocus?: boolean, autoFocus?: boolean,
style?: Object, style?: Object,
as?: React.ComponentType<any>, as?: React.ComponentType<any> | string,
to?: string, to?: string,
onClick?: (event: SyntheticEvent<>) => mixed, onClick?: (event: SyntheticEvent<>) => mixed,
borderOnHover?: boolean, borderOnHover?: boolean,
href?: string,
"data-on"?: string, "data-on"?: string,
"data-event-category"?: string, "data-event-category"?: string,
"data-event-action"?: string, "data-event-action"?: string,

View File

@ -8,6 +8,7 @@ import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore"; import CollectionsStore from "stores/CollectionsStore";
import DocumentPresenceStore from "stores/DocumentPresenceStore"; import DocumentPresenceStore from "stores/DocumentPresenceStore";
import DocumentsStore from "stores/DocumentsStore"; import DocumentsStore from "stores/DocumentsStore";
import FileOperationsStore from "stores/FileOperationsStore";
import GroupsStore from "stores/GroupsStore"; import GroupsStore from "stores/GroupsStore";
import MembershipsStore from "stores/MembershipsStore"; import MembershipsStore from "stores/MembershipsStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
@ -28,6 +29,7 @@ type Props = {
views: ViewsStore, views: ViewsStore,
auth: AuthStore, auth: AuthStore,
toasts: ToastsStore, toasts: ToastsStore,
fileOperations: FileOperationsStore,
}; };
@observer @observer
@ -80,6 +82,7 @@ class SocketProvider extends React.Component<Props> {
policies, policies,
presence, presence,
views, views,
fileOperations,
} = this.props; } = this.props;
if (!auth.token) return; if (!auth.token) return;
@ -287,6 +290,21 @@ class SocketProvider extends React.Component<Props> {
} }
}); });
this.socket.on("fileOperations.update", async (event) => {
const user = auth.user;
let collection = null;
if (event.collectionId)
collection = await collections.fetch(event.collectionId);
if (user) {
fileOperations.add({
...event,
user,
collection,
});
}
});
// received a message from the API server that we should request // received a message from the API server that we should request
// to join a specific room. Forward that to the ws server. // to join a specific room. Forward that to the ws server.
this.socket.on("join", (event) => { this.socket.on("join", (event) => {
@ -345,5 +363,6 @@ export default inject(
"memberships", "memberships",
"presence", "presence",
"policies", "policies",
"views" "views",
"fileOperations"
)(SocketProvider); )(SocketProvider);

View File

@ -22,6 +22,7 @@ import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton"; import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template"; import Template from "components/ContextMenu/Template";
import Modal from "components/Modal"; import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts"; import useToasts from "hooks/useToasts";
import getDataTransferFiles from "utils/getDataTransferFiles"; import getDataTransferFiles from "utils/getDataTransferFiles";
@ -46,6 +47,7 @@ function CollectionMenu({
}: Props) { }: Props) {
const menu = useMenuState({ modal, placement }); const menu = useMenuState({ modal, placement });
const [renderModals, setRenderModals] = React.useState(false); const [renderModals, setRenderModals] = React.useState(false);
const team = useCurrentTeam();
const { documents, policies } = useStores(); const { documents, policies } = useStores();
const { showToast } = useToasts(); const { showToast } = useToasts();
const { t } = useTranslation(); const { t } = useTranslation();
@ -120,6 +122,8 @@ function CollectionMenu({
); );
const can = policies.abilities(collection.id); const can = policies.abilities(collection.id);
const canUserInTeam = policies.abilities(team.id);
const items = React.useMemo( const items = React.useMemo(
() => [ () => [
{ {
@ -151,7 +155,7 @@ function CollectionMenu({
}, },
{ {
title: `${t("Export")}`, title: `${t("Export")}`,
visible: !!(collection && can.export), visible: !!(collection && canUserInTeam.export),
onClick: () => setShowCollectionExport(true), onClick: () => setShowCollectionExport(true),
icon: <ExportIcon />, icon: <ExportIcon />,
}, },
@ -165,7 +169,15 @@ function CollectionMenu({
icon: <TrashIcon />, icon: <TrashIcon />,
}, },
], ],
[can, collection, handleNewDocument, handleImportDocument, t] [
t,
can.update,
can.delete,
handleNewDocument,
handleImportDocument,
collection,
canUserInTeam.export,
]
); );
if (!items.length) { if (!items.length) {

View File

@ -124,10 +124,6 @@ export default class Collection extends BaseModel {
}; };
export = () => { export = () => {
return client.get( return client.get("/collections.export", { id: this.id });
"/collections.export",
{ id: this.id },
{ download: true }
);
}; };
} }

View File

@ -0,0 +1,27 @@
// @flow
import { computed } from "mobx";
import BaseModal from "./BaseModel";
import Collection from "./Collection";
import User from "./User";
class FileOperation extends BaseModal {
id: string;
state: string;
collection: ?Collection;
size: number;
type: string;
user: User;
createdAt: string;
@computed
get sizeInMB(): string {
const inKB = this.size / 1024;
if (inKB < 1024) {
return inKB.toFixed(2) + "KB";
}
return (inKB / 1024).toFixed(2) + "MB";
}
}
export default FileOperation;

View File

@ -6,7 +6,7 @@ import Collection from "models/Collection";
import Button from "components/Button"; import Button from "components/Button";
import Flex from "components/Flex"; import Flex from "components/Flex";
import HelpText from "components/HelpText"; import HelpText from "components/HelpText";
import useToasts from "hooks/useToasts";
type Props = { type Props = {
collection: Collection, collection: Collection,
onSubmit: () => void, onSubmit: () => void,
@ -15,6 +15,7 @@ type Props = {
function CollectionExport({ collection, onSubmit }: Props) { function CollectionExport({ collection, onSubmit }: Props) {
const [isLoading, setIsLoading] = React.useState(); const [isLoading, setIsLoading] = React.useState();
const { t } = useTranslation(); const { t } = useTranslation();
const { showToast } = useToasts();
const handleSubmit = React.useCallback( const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => { async (ev: SyntheticEvent<>) => {
@ -23,9 +24,12 @@ function CollectionExport({ collection, onSubmit }: Props) {
setIsLoading(true); setIsLoading(true);
await collection.export(); await collection.export();
setIsLoading(false); setIsLoading(false);
showToast(
t("Export started, you will receive an email when its complete.")
);
onSubmit(); onSubmit();
}, },
[collection, onSubmit] [collection, onSubmit, showToast, t]
); );
return ( return (
@ -33,7 +37,7 @@ function CollectionExport({ collection, onSubmit }: Props) {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<HelpText> <HelpText>
<Trans <Trans
defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format." defaults="Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip."
values={{ collectionName: collection.name }} values={{ collectionName: collection.name }}
components={{ em: <strong /> }} components={{ em: <strong /> }}
/> />

View File

@ -11,7 +11,10 @@ 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";
import Notice from "components/Notice"; import Notice from "components/Notice";
import PaginatedList from "components/PaginatedList";
import Scene from "components/Scene"; import Scene from "components/Scene";
import Subheading from "components/Subheading";
import FileOperationListItem from "./components/FileOperationListItem";
import useCurrentUser from "hooks/useCurrentUser"; import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts"; import useToasts from "hooks/useToasts";
@ -22,7 +25,7 @@ function ImportExport() {
const { t } = useTranslation(); const { t } = useTranslation();
const user = useCurrentUser(); const user = useCurrentUser();
const fileRef = React.useRef(); const fileRef = React.useRef();
const { collections } = useStores(); const { fileOperations, collections } = useStores();
const { showToast } = useToasts(); const { showToast } = useToasts();
const [isLoading, setLoading] = React.useState(false); const [isLoading, setLoading] = React.useState(false);
const [isImporting, setImporting] = React.useState(false); const [isImporting, setImporting] = React.useState(false);
@ -178,11 +181,10 @@ function ImportExport() {
{t("Choose File")} {t("Choose File")}
</Button> </Button>
)} )}
<Heading>{t("Export")}</Heading> <Heading>{t("Export")}</Heading>
<HelpText> <HelpText>
<Trans <Trans
defaults="A full export might take some time, consider exporting a single document or collection if possible. Well put together a zip of all your documents in Markdown format and email it to <em>{{ userEmail }}</em>." defaults="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 we will email a link to <em>{{ userEmail }}</em> when it's complete."
values={{ userEmail: user.email }} values={{ userEmail: user.email }}
components={{ em: <strong /> }} components={{ em: <strong /> }}
/> />
@ -199,6 +201,24 @@ function ImportExport() {
? `${t("Requesting Export")}` ? `${t("Requesting Export")}`
: t("Export Data")} : t("Export Data")}
</Button> </Button>
<br />
<br />
<PaginatedList
items={fileOperations.orderedDataExports}
fetch={fileOperations.fetchPage}
options={{ type: "export" }}
heading={
<Subheading>
<Trans>Recent exports</Trans>
</Subheading>
}
renderItem={(item) => (
<FileOperationListItem
key={item.id + item.state}
fileOperation={item}
/>
)}
/>
</Scene> </Scene>
); );
} }

View File

@ -0,0 +1,61 @@
// @flow
import * as React from "react";
import { useTranslation } from "react-i18next";
import FileOperation from "models/FileOperation";
import Button from "components/Button";
import ListItem from "components/List/Item";
import Time from "components/Time";
type Props = {|
fileOperation: FileOperation,
|};
const FileOperationListItem = ({ fileOperation }: Props) => {
const { t } = useTranslation();
const stateMapping = {
creating: t("Processing"),
expired: t("Expired"),
uploading: t("Processing"),
error: t("Error"),
};
return (
<ListItem
title={
fileOperation.collection
? fileOperation.collection.name
: t("All collections")
}
subtitle={
<>
{fileOperation.state !== "complete" && (
<>{stateMapping[fileOperation.state]}&nbsp;&nbsp;</>
)}
{t(`{{userName}} requested`, {
userName:
fileOperation.id === fileOperation.user.id
? t("You")
: fileOperation.user.name,
})}
&nbsp;
<Time dateTime={fileOperation.createdAt} addSuffix shorten />
&nbsp;&nbsp;{fileOperation.sizeInMB}
</>
}
actions={
fileOperation.state === "complete" ? (
<Button
as="a"
href={`/api/fileOperations.redirect?id=${fileOperation.id}`}
neutral
>
{t("Download")}
</Button>
) : undefined
}
/>
);
};
export default FileOperationListItem;

View File

@ -0,0 +1,27 @@
// @flow
import { orderBy } from "lodash";
import { computed } from "mobx";
import FileOperation from "models/FileOperation";
import BaseStore from "./BaseStore";
import RootStore from "./RootStore";
export default class FileOperationsStore extends BaseStore<FileOperation> {
actions = ["list", "info"];
constructor(rootStore: RootStore) {
super(rootStore, FileOperation);
}
@computed
get exports(): FileOperation[] {
return Array.from(this.data.values()).reduce(
(acc, fileOp) => (fileOp.type === "export" ? [...acc, fileOp] : acc),
[]
);
}
@computed
get orderedDataExports(): FileOperation[] {
return orderBy(this.exports, "createdAt", "desc");
}
}

View File

@ -6,6 +6,7 @@ import CollectionsStore from "./CollectionsStore";
import DocumentPresenceStore from "./DocumentPresenceStore"; import DocumentPresenceStore from "./DocumentPresenceStore";
import DocumentsStore from "./DocumentsStore"; import DocumentsStore from "./DocumentsStore";
import EventsStore from "./EventsStore"; import EventsStore from "./EventsStore";
import FileOperationsStore from "./FileOperationsStore";
import GroupMembershipsStore from "./GroupMembershipsStore"; import GroupMembershipsStore from "./GroupMembershipsStore";
import GroupsStore from "./GroupsStore"; import GroupsStore from "./GroupsStore";
import IntegrationsStore from "./IntegrationsStore"; import IntegrationsStore from "./IntegrationsStore";
@ -39,6 +40,7 @@ export default class RootStore {
users: UsersStore; users: UsersStore;
views: ViewsStore; views: ViewsStore;
toasts: ToastsStore; toasts: ToastsStore;
fileOperations: FileOperationsStore;
constructor() { constructor() {
// PoliciesStore must be initialized before AuthStore // PoliciesStore must be initialized before AuthStore
@ -60,6 +62,7 @@ export default class RootStore {
this.ui = new UiStore(); this.ui = new UiStore();
this.users = new UsersStore(this); this.users = new UsersStore(this);
this.views = new ViewsStore(this); this.views = new ViewsStore(this);
this.fileOperations = new FileOperationsStore(this);
this.toasts = new ToastsStore(); this.toasts = new ToastsStore();
} }
@ -79,6 +82,7 @@ export default class RootStore {
this.policies.clear(); this.policies.clear();
this.revisions.clear(); this.revisions.clear();
this.shares.clear(); this.shares.clear();
this.fileOperations.clear();
// this.ui omitted to keep ui settings between sessions // this.ui omitted to keep ui settings between sessions
this.users.clear(); this.users.clear();
this.views.clear(); this.views.clear();

View File

@ -11,7 +11,7 @@ import {
getSignature, getSignature,
publicS3Endpoint, publicS3Endpoint,
makeCredential, makeCredential,
getSignedImageUrl, getSignedUrl,
} from "../utils/s3"; } from "../utils/s3";
const { authorize } = policy; const { authorize } = policy;
@ -146,7 +146,7 @@ router.post("attachments.redirect", auth(), async (ctx) => {
authorize(user, "read", document); authorize(user, "read", document);
} }
const accessUrl = await getSignedImageUrl(attachment.key); const accessUrl = await getSignedUrl(attachment.key);
ctx.redirect(accessUrl); ctx.redirect(accessUrl);
} else { } else {
ctx.redirect(attachment.url); ctx.redirect(attachment.url);

View File

@ -1,5 +1,4 @@
// @flow // @flow
import fs from "fs";
import fractionalIndex from "fractional-index"; import fractionalIndex from "fractional-index";
import Router from "koa-router"; import Router from "koa-router";
import { ValidationError } from "../errors"; import { ValidationError } from "../errors";
@ -14,6 +13,7 @@ import {
User, User,
Group, Group,
Attachment, Attachment,
FileOperation,
} from "../models"; } from "../models";
import policy from "../policies"; import policy from "../policies";
import { import {
@ -23,12 +23,13 @@ import {
presentMembership, presentMembership,
presentGroup, presentGroup,
presentCollectionGroupMembership, presentCollectionGroupMembership,
presentFileOperation,
} from "../presenters"; } from "../presenters";
import { Op, sequelize } from "../sequelize"; import { Op, sequelize } from "../sequelize";
import collectionIndexing from "../utils/collectionIndexing"; import collectionIndexing from "../utils/collectionIndexing";
import removeIndexCollision from "../utils/removeIndexCollision"; import removeIndexCollision from "../utils/removeIndexCollision";
import { archiveCollection, archiveCollections } from "../utils/zip"; import { getAWSKeyForFileOp } from "../utils/s3";
import pagination from "./middlewares/pagination"; import pagination from "./middlewares/pagination";
const { authorize } = policy; const { authorize } = policy;
@ -454,59 +455,70 @@ router.post("collections.export", auth(), async (ctx) => {
ctx.assertUuid(id, "id is required"); ctx.assertUuid(id, "id is required");
const user = ctx.state.user; const user = ctx.state.user;
const team = await Team.findByPk(user.teamId);
authorize(user, "export", team);
const collection = await Collection.scope({ const collection = await Collection.scope({
method: ["withMembership", user.id], method: ["withMembership", user.id],
}).findByPk(id); }).findByPk(id);
authorize(user, "export", collection);
const filePath = await archiveCollection(collection); ctx.assertPresent(collection, "Collection should be present");
authorize(user, "read", collection);
await Event.create({ const key = getAWSKeyForFileOp(team.id, collection.name);
name: "collections.export",
collectionId: collection.id, let exportData;
teamId: user.teamId, exportData = await FileOperation.create({
actorId: user.id, type: "export",
data: { title: collection.title }, state: "creating",
ip: ctx.request.ip, key,
url: null,
size: 0,
collectionId: id,
userId: user.id,
teamId: team.id,
}); });
ctx.attachment(`${collection.name}.zip`); exportCollections(user.teamId, user.id, user.email, exportData.id, id);
ctx.set("Content-Type", "application/force-download");
ctx.body = fs.createReadStream(filePath); exportData.user = user;
exportData.collection = collection;
ctx.body = {
success: true,
data: { fileOperation: presentFileOperation(exportData) },
};
}); });
router.post("collections.export_all", auth(), async (ctx) => { router.post("collections.export_all", auth(), async (ctx) => {
const { download = false } = ctx.body;
const user = ctx.state.user; const user = ctx.state.user;
const team = await Team.findByPk(user.teamId); const team = await Team.findByPk(user.teamId);
authorize(user, "export", team); authorize(user, "export", team);
await Event.create({ const key = getAWSKeyForFileOp(team.id, team.name);
name: "collections.export",
teamId: user.teamId, let exportData;
actorId: user.id, exportData = await FileOperation.create({
ip: ctx.request.ip, type: "export",
state: "creating",
key,
url: null,
size: 0,
collectionId: null,
userId: user.id,
teamId: team.id,
}); });
if (download) { // async operation to upload zip archive to cloud and email user with link
const collections = await Collection.findAll({ exportCollections(user.teamId, user.id, user.email, exportData.id);
where: { teamId: team.id },
order: [["name", "ASC"]],
});
const filePath = await archiveCollections(collections);
ctx.attachment(`${team.name}.zip`); exportData.user = user;
ctx.set("Content-Type", "application/force-download"); exportData.collection = null;
ctx.body = fs.createReadStream(filePath);
} else {
// async operation to create zip archive and email user
exportCollections(user.teamId, user.email);
ctx.body = { ctx.body = {
success: true, success: true,
}; data: { fileOperation: presentFileOperation(exportData) },
} };
}); });
router.post("collections.update", auth(), async (ctx) => { router.post("collections.update", auth(), async (ctx) => {

View File

@ -264,53 +264,53 @@ describe("#collections.move", () => {
describe("#collections.export", () => { describe("#collections.export", () => {
it("should not allow export of private collection not a member", async () => { it("should not allow export of private collection not a member", async () => {
const { user } = await seed(); const { admin } = await seed();
const collection = await buildCollection({ const collection = await buildCollection({
permission: null, permission: null,
teamId: user.teamId, teamId: admin.teamId,
}); });
const res = await server.post("/api/collections.export", { const res = await server.post("/api/collections.export", {
body: { token: user.getJwtToken(), id: collection.id }, body: { token: admin.getJwtToken(), id: collection.id },
}); });
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("should allow export of private collection when the actor is a member", async () => { it("should allow export of private collection when the actor is a member", async () => {
const { user, collection } = await seed(); const { admin, collection } = await seed();
collection.permission = null; collection.permission = null;
await collection.save(); await collection.save();
await CollectionUser.create({ await CollectionUser.create({
createdById: user.id, createdById: admin.id,
collectionId: collection.id, collectionId: collection.id,
userId: user.id, userId: admin.id,
permission: "read_write", permission: "read_write",
}); });
const res = await server.post("/api/collections.export", { const res = await server.post("/api/collections.export", {
body: { token: user.getJwtToken(), id: collection.id }, body: { token: admin.getJwtToken(), id: collection.id },
}); });
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
}); });
it("should allow export of private collection when the actor is a group member", async () => { it("should allow export of private collection when the actor is a group member", async () => {
const user = await buildUser(); const admin = await buildAdmin();
const collection = await buildCollection({ const collection = await buildCollection({
permission: null, permission: null,
teamId: user.teamId, teamId: admin.teamId,
}); });
const group = await buildGroup({ teamId: user.teamId }); const group = await buildGroup({ teamId: admin.teamId });
await group.addUser(user, { through: { createdById: user.id } }); await group.addUser(admin, { through: { createdById: admin.id } });
await collection.addGroup(group, { await collection.addGroup(group, {
through: { permission: "read_write", createdById: user.id }, through: { permission: "read_write", createdById: admin.id },
}); });
const res = await server.post("/api/collections.export", { const res = await server.post("/api/collections.export", {
body: { token: user.getJwtToken(), id: collection.id }, body: { token: admin.getJwtToken(), id: collection.id },
}); });
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
@ -324,13 +324,29 @@ describe("#collections.export", () => {
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
it("should return success", async () => { it("should return unauthorized if user is not admin", async () => {
const { user, collection } = await seed(); const { user, collection } = await seed();
const res = await server.post("/api/collections.export", { const res = await server.post("/api/collections.export", {
body: { token: user.getJwtToken(), id: collection.id }, body: { token: user.getJwtToken(), id: collection.id },
}); });
expect(res.status).toEqual(200); expect(res.status).toEqual(403);
});
it("should return file operation associated with export", async () => {
const admin = await buildAdmin();
const collection = await buildCollection({
teamId: admin.teamId,
});
const res = await server.post("/api/collections.export", {
body: { token: admin.getJwtToken(), id: collection.id },
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.fileOperation.id).toBeTruthy();
expect(body.data.fileOperation.state).toBe("creating");
}); });
}); });
@ -359,18 +375,6 @@ describe("#collections.export_all", () => {
expect(res.status).toEqual(200); expect(res.status).toEqual(200);
}); });
it("should allow downloading directly", async () => {
const { admin } = await seed();
const res = await server.post("/api/collections.export_all", {
body: { token: admin.getJwtToken(), download: true },
});
expect(res.status).toEqual(200);
expect(res.headers.get("content-type")).toEqual(
"application/force-download"
);
});
}); });
describe("#collections.add_user", () => { describe("#collections.add_user", () => {
@ -1026,7 +1030,6 @@ describe("#collections.create", () => {
expect(body.data.sort.direction).toBe("asc"); expect(body.data.sort.direction).toBe("asc");
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy(); expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
}); });
it("should error when index is invalid", async () => { it("should error when index is invalid", async () => {
@ -1060,7 +1063,6 @@ describe("#collections.create", () => {
expect(body.data.permission).toEqual(null); expect(body.data.permission).toEqual(null);
expect(body.policies.length).toBe(1); expect(body.policies.length).toBe(1);
expect(body.policies[0].abilities.read).toBeTruthy(); expect(body.policies[0].abilities.read).toBeTruthy();
expect(body.policies[0].abilities.export).toBeTruthy();
}); });
it("if index collision, should updated index of other collection", async () => { it("if index collision, should updated index of other collection", async () => {

View File

@ -0,0 +1,99 @@
// @flow
import Router from "koa-router";
import { NotFoundError, ValidationError } from "../errors";
import auth from "../middlewares/authentication";
import { FileOperation, Team } from "../models";
import policy from "../policies";
import { presentFileOperation } from "../presenters";
import { getSignedUrl } from "../utils/s3";
import pagination from "./middlewares/pagination";
const { authorize } = policy;
const router = new Router();
router.post("fileOperations.info", 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 fileOperation = await FileOperation.findByPk(id);
authorize(user, fileOperation.type, team);
if (!fileOperation) {
throw new NotFoundError();
}
ctx.body = {
data: presentFileOperation(fileOperation),
};
});
router.post("fileOperations.list", auth(), pagination(), async (ctx) => {
let { sort = "createdAt", direction, type } = ctx.body;
ctx.assertPresent(type, "type is required");
ctx.assertIn(
type,
["import", "export"],
"type must be one of 'import' or 'export'"
);
if (direction !== "ASC") direction = "DESC";
const user = ctx.state.user;
const where = {
teamId: user.teamId,
type,
};
const team = await Team.findByPk(user.teamId);
authorize(user, type, team);
const [exports, total] = await Promise.all([
await FileOperation.findAll({
where,
order: [[sort, direction]],
offset: ctx.state.pagination.offset,
limit: ctx.state.pagination.limit,
}),
await FileOperation.count({
where,
}),
]);
ctx.body = {
pagination: {
...ctx.state.pagination,
total,
},
data: exports.map(presentFileOperation),
};
});
router.post("fileOperations.redirect", 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.unscoped().findByPk(id);
if (!fileOp) {
throw new NotFoundError();
}
authorize(user, fileOp.type, team);
if (fileOp.state !== "complete") {
throw new ValidationError("file operation is not complete yet");
}
const accessUrl = await getSignedUrl(fileOp.key);
ctx.redirect(accessUrl);
});
export default router;

View File

@ -0,0 +1,283 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import { Collection, User } from "../models";
import webService from "../services/web";
import {
buildAdmin,
buildCollection,
buildFileOperation,
buildTeam,
buildUser,
} from "../test/factories";
import { flushdb } from "../test/support";
const app = webService();
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("#fileOperations.info", () => {
it("should return fileOperation", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
id: exportData.id,
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBe(exportData.id);
expect(body.data.state).toBe(exportData.state);
});
it("should require user to be an admin", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
id: exportData.id,
token: user.getJwtToken(),
type: "export",
},
});
expect(res.status).toEqual(403);
});
});
describe("#fileOperations.list", () => {
it("should return fileOperations list", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
});
it("should return exports with collection data", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.collection.id).toBe(collection.id);
});
it("should return exports with collection data even if collection is deleted", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
await collection.destroy();
const isCollectionPresent = await Collection.findByPk(collection.id);
expect(isCollectionPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.collection.id).toBe(collection.id);
});
it("should return exports with user data even if user is deleted", async () => {
const team = await buildTeam();
const admin2 = await buildAdmin({ teamId: team.id });
const admin = await buildAdmin({ teamId: team.id });
const collection = await buildCollection({
userId: admin.id,
teamId: team.id,
});
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
collectionId: collection.id,
});
await admin.destroy();
const isAdminPresent = await User.findByPk(admin.id);
expect(isAdminPresent).toBe(null);
const res = await server.post("/api/fileOperations.list", {
body: {
token: admin2.getJwtToken(),
type: "export",
},
});
const body = await res.json();
const data = body.data[0];
expect(res.status).toEqual(200);
expect(body.data.length).toBe(1);
expect(data.id).toBe(exportData.id);
expect(data.key).toBe(undefined);
expect(data.state).toBe(exportData.state);
expect(data.user.id).toBe(admin.id);
});
it("should require authorization", async () => {
const user = await buildUser();
const res = await server.post("/api/fileOperations.list", {
body: { token: user.getJwtToken(), type: "export" },
});
expect(res.status).toEqual(403);
});
});
describe("#fileOperations.redirect", () => {
it("should not redirect when file operation is not complete", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.redirect", {
body: {
token: admin.getJwtToken(),
id: exportData.id,
},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.message).toEqual("file operation is not complete yet");
});
});
describe("#fileOperations.info", () => {
it("should return 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,
});
const res = await server.post("/api/fileOperations.info", {
body: {
token: admin.getJwtToken(),
id: exportData.id,
},
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.id).toBe(exportData.id);
expect(body.data.user.id).toBe(admin.id);
});
it("should require authorization", async () => {
const team = await buildTeam();
const admin = await buildAdmin({ teamId: team.id });
const user = await buildUser({ teamId: team.id });
const exportData = await buildFileOperation({
type: "export",
teamId: team.id,
userId: admin.id,
});
const res = await server.post("/api/fileOperations.info", {
body: {
token: user.getJwtToken(),
id: exportData.id,
},
});
expect(res.status).toBe(403);
});
});

View File

@ -14,6 +14,7 @@ import authenticationProviders from "./authenticationProviders";
import collections from "./collections"; import collections from "./collections";
import documents from "./documents"; import documents from "./documents";
import events from "./events"; import events from "./events";
import fileOperationsRoute from "./fileOperations";
import groups from "./groups"; import groups from "./groups";
import hooks from "./hooks"; import hooks from "./hooks";
import integrations from "./integrations"; import integrations from "./integrations";
@ -62,6 +63,7 @@ router.use("/", notificationSettings.routes());
router.use("/", attachments.routes()); router.use("/", attachments.routes());
router.use("/", utils.routes()); router.use("/", utils.routes());
router.use("/", groups.routes()); router.use("/", groups.routes());
router.use("/", fileOperationsRoute.routes());
router.post("*", (ctx) => { router.post("*", (ctx) => {
ctx.throw(new NotFoundError("Endpoint not found")); ctx.throw(new NotFoundError("Endpoint not found"));

View File

@ -4,7 +4,7 @@ import debug from "debug";
import Router from "koa-router"; import Router from "koa-router";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter"; import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import { AuthenticationError } from "../errors"; import { AuthenticationError } from "../errors";
import { Document } from "../models"; import { Document, FileOperation } from "../models";
import { Op } from "../sequelize"; import { Op } from "../sequelize";
const router = new Router(); const router = new Router();
@ -34,6 +34,26 @@ router.post("utils.gc", async (ctx) => {
log(`Destroyed ${countDeletedDocument} documents`); log(`Destroyed ${countDeletedDocument} documents`);
log(`Expiring all the collection export older than 30 days…`);
const exports = await FileOperation.unscoped().findAll({
where: {
type: "export",
createdAt: {
[Op.lt]: subDays(new Date(), 30),
},
state: {
[Op.ne]: "expired",
},
},
});
await Promise.all(
exports.map(async (e) => {
await e.expire();
})
);
ctx.body = { ctx.body = {
success: true, success: true,
}; };

View File

@ -1,9 +1,10 @@
/* eslint-disable flowtype/require-valid-file-annotation */ /* eslint-disable flowtype/require-valid-file-annotation */
import { subDays } from "date-fns"; import { subDays } from "date-fns";
import TestServer from "fetch-test-server"; import TestServer from "fetch-test-server";
import { Document } from "../models"; import { Document, FileOperation } from "../models";
import { Op } from "../sequelize";
import webService from "../services/web"; import webService from "../services/web";
import { buildDocument } from "../test/factories"; import { buildDocument, buildFileOperation } from "../test/factories";
import { flushdb } from "../test/support"; import { flushdb } from "../test/support";
const app = webService(); const app = webService();
@ -83,6 +84,68 @@ describe("#utils.gc", () => {
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0); expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
}); });
it("should expire exports older than 30 days ago", async () => {
await buildFileOperation({
type: "export",
state: "complete",
createdAt: subDays(new Date(), 30),
});
await buildFileOperation({
type: "export",
state: "complete",
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
const data = await FileOperation.count({
where: {
type: "export",
state: {
[Op.eq]: "expired",
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(1);
});
it("should not expire exports made less than 30 days ago", async () => {
await buildFileOperation({
type: "export",
state: "complete",
createdAt: subDays(new Date(), 29),
});
await buildFileOperation({
type: "export",
state: "complete",
});
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
const data = await FileOperation.count({
where: {
type: "export",
state: {
[Op.eq]: "expired",
},
},
});
expect(res.status).toEqual(200);
expect(data).toEqual(0);
});
it("should require authentication", async () => { it("should require authentication", async () => {
const res = await server.post("/api/utils.gc"); const res = await server.post("/api/utils.gc");
expect(res.status).toEqual(401); expect(res.status).toEqual(401);

View File

@ -1,36 +0,0 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
export const exportEmailText = `
Your Data Export
Your requested data export is attached as a zip file to this email.
`;
export const ExportEmail = () => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Your requested data export is attached as a zip file to this email.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${process.env.URL}/home`}>Go to dashboard</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -0,0 +1,47 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
export const exportEmailFailureText = `
Your Data Export
Sorry, your requested data export has failed, please visit the admin
section to try again if the problem persists please contact support.
`;
export const ExportFailureEmail = ({ teamUrl }: { teamUrl: string }) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Sorry, your requested data export has failed, please visit the{" "}
<a
href={`${teamUrl}/settings/import-export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
. to try again if the problem persists please contact support.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/settings/import-export`}>
Go to export
</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -0,0 +1,53 @@
// @flow
import * as React from "react";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
export const exportEmailSuccessText = `
Your Data Export
Your requested data export is complete, the exported files are also available in the admin section.
`;
export const ExportSuccessEmail = ({
id,
teamUrl,
}: {
id: string,
teamUrl: string,
}) => {
return (
<EmailTemplate>
<Header />
<Body>
<Heading>Your Data Export</Heading>
<p>
Your requested data export is complete, the exported files are also
available in the{" "}
<a
href={`${teamUrl}/settings/import-export`}
rel="noreferrer"
target="_blank"
>
admin section
</a>
.
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/api/fileOperations.redirect?id=${id}`}>
Download
</Button>
</p>
</Body>
<Footer />
</EmailTemplate>
);
};

View File

@ -1,8 +1,10 @@
// @flow // @flow
import fs from "fs";
import debug from "debug"; import debug from "debug";
import mailer from "./mailer"; import mailer from "./mailer";
import { Collection, Team } from "./models"; import { FileOperation, Collection, Team, Event, User } from "./models";
import { createQueue } from "./utils/queue"; import { createQueue } from "./utils/queue";
import { uploadToS3FromBuffer } from "./utils/s3";
const log = debug("exporter"); const log = debug("exporter");
const exporterQueue = createQueue("exporter"); const exporterQueue = createQueue("exporter");
@ -15,27 +17,137 @@ const queueOptions = {
}, },
}; };
async function exportAndEmailCollections(teamId: string, email: string) { async function fileOperationsUpdate(teamId, userId, exportData) {
await Event.add({
name: "fileOperations.update",
teamId: teamId,
actorId: userId,
data: {
type: exportData.type,
id: exportData.id,
state: exportData.state,
size: exportData.size,
collectionId: exportData.collectionId,
createdAt: exportData.createdAt,
},
});
}
type exportAndEmailCollectionsType = {|
teamId: string,
userId: string,
email: string,
fileOperationId: string,
collectionId?: string,
|};
// TODO: Refactor to use command pattern
async function exportAndEmailCollections({
teamId,
userId,
email,
collectionId,
fileOperationId,
}: exportAndEmailCollectionsType) {
log("Archiving team", teamId); log("Archiving team", teamId);
const { archiveCollections } = require("./utils/zip"); const { archiveCollections } = require("./utils/zip");
const team = await Team.findByPk(teamId); const team = await Team.findByPk(teamId);
const collections = await Collection.findAll({ const user = await User.findByPk(userId);
where: { teamId },
order: [["name", "ASC"]], let collections;
}); if (!collectionId) {
const collectionIds = await user.collectionIds();
collections = await Promise.all(
collectionIds.map(
async (collectionId) => await Collection.findByPk(collectionId)
)
);
} else {
collections = [await Collection.findByPk(collectionId)];
}
let exportData;
let state;
let key;
exportData = await FileOperation.findByPk(fileOperationId);
state = exportData.state;
key = exportData.key;
await fileOperationsUpdate(teamId, userId, exportData);
const filePath = await archiveCollections(collections); const filePath = await archiveCollections(collections);
log("Archive path", filePath); log("Archive path", filePath);
mailer.export({ let url;
to: email, try {
attachments: [ const readBuffer = await fs.promises.readFile(filePath);
{ state = "uploading";
filename: `${team.name} Export.zip`, exportData.state = state;
path: filePath, const stat = await fs.promises.stat(filePath);
}, exportData.size = stat.size;
],
}); await exportData.save();
await fileOperationsUpdate(teamId, userId, exportData);
url = await uploadToS3FromBuffer(
readBuffer,
"application/zip",
key,
"private"
);
state = "complete";
} catch (e) {
log("Failed to export data", e);
state = "error";
url = null;
} finally {
exportData.state = state;
exportData.url = url;
await exportData.save();
await fileOperationsUpdate(teamId, userId, exportData);
if (collectionId) {
await Event.create({
name: "collections.export",
collectionId,
teamId: teamId,
actorId: userId,
data: { name: collections[0].name, exportId: exportData.id },
});
} else {
const collectionsExported = collections.map((c) => ({
name: c.name,
id: c.id,
}));
await Event.create({
name: "collections.export_all",
teamId: teamId,
actorId: userId,
data: {
exportId: exportData.id,
collections: collectionsExported,
},
});
}
if (state === "error") {
mailer.exportFailure({
to: email,
teamUrl: team.url,
});
} else {
mailer.exportSuccess({
to: email,
id: exportData.id,
teamUrl: team.url,
});
}
}
} }
exporterQueue.process(async function exportProcessor(job) { exporterQueue.process(async function exportProcessor(job) {
@ -43,17 +155,33 @@ exporterQueue.process(async function exportProcessor(job) {
switch (job.data.type) { switch (job.data.type) {
case "export-collections": case "export-collections":
return await exportAndEmailCollections(job.data.teamId, job.data.email); const { teamId, userId, email, collectionId, fileOperationId } = job.data;
return await exportAndEmailCollections({
teamId,
userId,
email,
fileOperationId,
collectionId,
});
default: default:
} }
}); });
export const exportCollections = (teamId: string, email: string) => { export const exportCollections = (
teamId: string,
userId: string,
email: string,
fileOperationId: string,
collectionId?: string
) => {
exporterQueue.add( exporterQueue.add(
{ {
type: "export-collections", type: "export-collections",
teamId, teamId,
userId,
email, email,
fileOperationId,
collectionId,
}, },
queueOptions queueOptions
); );

View File

@ -14,7 +14,15 @@ import {
DocumentNotificationEmail, DocumentNotificationEmail,
documentNotificationEmailText, documentNotificationEmailText,
} from "./emails/DocumentNotificationEmail"; } from "./emails/DocumentNotificationEmail";
import { ExportEmail, exportEmailText } from "./emails/ExportEmail"; import {
ExportFailureEmail,
exportEmailFailureText,
} from "./emails/ExportFailureEmail";
import {
ExportSuccessEmail,
exportEmailSuccessText,
} from "./emails/ExportSuccessEmail";
import { import {
type Props as InviteEmailT, type Props as InviteEmailT,
InviteEmail, InviteEmail,
@ -155,14 +163,34 @@ export class Mailer {
}); });
}; };
export = async (opts: { to: string, attachments: Object[] }) => { exportSuccess = async (opts: {
to: string,
attachments?: Object[],
id: string,
teamUrl: string,
}) => {
this.sendMail({ this.sendMail({
to: opts.to, to: opts.to,
attachments: opts.attachments, attachments: opts.attachments,
title: "Your requested export", title: "Your requested export",
previewText: "Here's your request data export from Outline", previewText: "Here's your request data export from Outline",
html: <ExportEmail />, html: <ExportSuccessEmail id={opts.id} teamUrl={opts.teamUrl} />,
text: exportEmailText, text: exportEmailSuccessText,
});
};
exportFailure = async (opts: {
to: string,
attachments?: Object[],
teamUrl: string,
}) => {
this.sendMail({
to: opts.to,
attachments: opts.attachments,
title: "Your requested export",
previewText: "Sorry, your requested data export has failed",
html: <ExportFailureEmail teamUrl={opts.teamUrl} />,
text: exportEmailFailureText,
}); });
}; };

View File

@ -0,0 +1,65 @@
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable("file_operations",{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
state: {
type: Sequelize.ENUM("creating", "uploading", "complete", "error","expired"),
allowNull: false,
},
type: {
type: Sequelize.ENUM("import", "export"),
allowNull: false,
},
key: {
type: Sequelize.STRING,
},
url: {
type: Sequelize.STRING,
},
size: {
type: Sequelize.BIGINT,
allowNull: false,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users"
}
},
collectionId: {
type: Sequelize.UUID,
references: {
model: "collections"
}
},
teamId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "teams"
}
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
})
await queryInterface.addIndex('file_operations', ["type", "state"])
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeIndex('file_operations', ["type", "state"]);
await queryInterface.dropTable('file_operations');
}
};

View File

@ -83,6 +83,7 @@ Event.AUDIT_EVENTS = [
"collections.add_group", "collections.add_group",
"collections.remove_group", "collections.remove_group",
"collections.delete", "collections.delete",
"collections.export_all",
"documents.create", "documents.create",
"documents.publish", "documents.publish",
"documents.update", "documents.update",

View File

@ -0,0 +1,72 @@
// @flow
import { DataTypes, sequelize } from "../sequelize";
import { deleteFromS3 } from "../utils/s3";
const FileOperation = sequelize.define("file_operations", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
},
type: {
type: DataTypes.ENUM("import", "export"),
allowNull: false,
},
state: {
type: DataTypes.ENUM(
"creating",
"uploading",
"complete",
"error",
"expired"
),
allowNull: false,
},
key: {
type: DataTypes.STRING,
},
url: {
type: DataTypes.STRING,
},
size: {
type: DataTypes.BIGINT,
allowNull: false,
},
});
FileOperation.prototype.expire = async function () {
this.state = "expired";
await deleteFromS3(this.key);
this.save();
};
FileOperation.associate = (models) => {
FileOperation.belongsTo(models.User, {
as: "user",
foreignKey: "userId",
});
FileOperation.belongsTo(models.Collection, {
as: "collection",
foreignKey: "collectionId",
});
FileOperation.belongsTo(models.Team, {
as: "team",
foreignKey: "teamId",
});
FileOperation.addScope("defaultScope", {
include: [
{
model: models.User,
as: "user",
paranoid: false,
},
{
model: models.Collection,
as: "collection",
paranoid: false,
},
],
});
};
export default FileOperation;

View File

@ -8,6 +8,7 @@ import CollectionGroup from "./CollectionGroup";
import CollectionUser from "./CollectionUser"; import CollectionUser from "./CollectionUser";
import Document from "./Document"; import Document from "./Document";
import Event from "./Event"; import Event from "./Event";
import FileOperation from "./FileOperation";
import Group from "./Group"; import Group from "./Group";
import GroupUser from "./GroupUser"; import GroupUser from "./GroupUser";
import Integration from "./Integration"; import Integration from "./Integration";
@ -47,6 +48,7 @@ const models = {
User, User,
UserAuthentication, UserAuthentication,
View, View,
FileOperation,
}; };
// based on https://github.com/sequelize/express-example/blob/master/models/index.js // based on https://github.com/sequelize/express-example/blob/master/models/index.js
@ -80,4 +82,5 @@ export {
User, User,
UserAuthentication, UserAuthentication,
View, View,
FileOperation,
}; };

View File

@ -47,7 +47,7 @@ allow(User, "read", Collection, (user, collection) => {
return true; return true;
}); });
allow(User, ["share", "export"], Collection, (user, collection) => { allow(User, "share", Collection, (user, collection) => {
if (user.isViewer) return false; if (user.isViewer) return false;
if (!collection || user.teamId !== collection.teamId) return false; if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false; if (!collection.sharing) return false;

View File

@ -16,7 +16,6 @@ describe("read_write permission", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
}); });
@ -43,7 +42,6 @@ describe("read_write permission", () => {
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
}); });
@ -59,7 +57,6 @@ describe("read permission", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(false);
expect(abilities.update).toEqual(false); expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false); expect(abilities.share).toEqual(false);
}); });
@ -86,7 +83,6 @@ describe("read permission", () => {
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
}); });
@ -102,7 +98,6 @@ describe("no permission", () => {
}); });
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(false); expect(abilities.read).toEqual(false);
expect(abilities.export).toEqual(false);
expect(abilities.update).toEqual(false); expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false); expect(abilities.share).toEqual(false);
}); });
@ -129,7 +124,6 @@ describe("no permission", () => {
const abilities = serialize(user, collection); const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true); expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(true); expect(abilities.update).toEqual(true);
expect(abilities.share).toEqual(true); expect(abilities.share).toEqual(true);
}); });

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { Attachment, Document } from "../models"; import { Attachment, Document } from "../models";
import parseAttachmentIds from "../utils/parseAttachmentIds"; import parseAttachmentIds from "../utils/parseAttachmentIds";
import { getSignedImageUrl } from "../utils/s3"; import { getSignedUrl } from "../utils/s3";
import presentUser from "./user"; import presentUser from "./user";
type Options = { type Options = {
@ -16,7 +16,7 @@ async function replaceImageAttachments(text: string) {
attachmentIds.map(async (id) => { attachmentIds.map(async (id) => {
const attachment = await Attachment.findByPk(id); const attachment = await Attachment.findByPk(id);
if (attachment) { if (attachment) {
const accessUrl = await getSignedImageUrl(attachment.key); const accessUrl = await getSignedUrl(attachment.key);
text = text.replace(attachment.redirectUrl, accessUrl); text = text.replace(attachment.redirectUrl, accessUrl);
} }
}) })

View File

@ -0,0 +1,15 @@
// @flow
import { FileOperation } from "../models";
import { presentCollection, presentUser } from ".";
export default function present(data: FileOperation) {
return {
id: data.id,
type: data.type,
state: data.state,
collection: data.collection ? presentCollection(data.collection) : null,
size: data.size,
user: presentUser(data.user),
createdAt: data.createdAt,
};
}

View File

@ -5,6 +5,7 @@ import presentCollection from "./collection";
import presentCollectionGroupMembership from "./collectionGroupMembership"; import presentCollectionGroupMembership from "./collectionGroupMembership";
import presentDocument from "./document"; import presentDocument from "./document";
import presentEvent from "./event"; import presentEvent from "./event";
import presentFileOperation from "./fileOperation";
import presentGroup from "./group"; import presentGroup from "./group";
import presentGroupMembership from "./groupMembership"; import presentGroupMembership from "./groupMembership";
import presentIntegration from "./integration"; import presentIntegration from "./integration";
@ -20,6 +21,7 @@ import presentView from "./view";
export { export {
presentApiKey, presentApiKey,
presentFileOperation,
presentAuthenticationProvider, presentAuthenticationProvider,
presentUser, presentUser,
presentView, presentView,

View File

@ -322,6 +322,13 @@ export default class WebsocketsProcessor {
} }
return; return;
} }
case "fileOperations.update": {
return socketio
.to(`user-${event.actorId}`)
.emit("fileOperations.update", event.data);
}
case "groups.create": case "groups.create":
case "groups.update": { case "groups.update": {
const group = await Group.findByPk(event.modelId, { const group = await Group.findByPk(event.modelId, {

View File

@ -13,6 +13,7 @@ import {
IntegrationAuthentication, IntegrationAuthentication,
Integration, Integration,
AuthenticationProvider, AuthenticationProvider,
FileOperation,
} from "../models"; } from "../models";
let count = 1; let count = 1;
@ -255,6 +256,31 @@ export async function buildDocument(overrides: Object = {}) {
}); });
} }
export async function buildFileOperation(overrides: Object = {}) {
if (!overrides.teamId) {
const team = await buildTeam();
overrides.teamId = team.id;
}
if (!overrides.userId) {
const user = await buildAdmin({ teamId: overrides.teamId });
overrides.userId = user.id;
}
if (!overrides.collectionId) {
const collection = await buildCollection(overrides);
overrides.collectionId = collection.id;
}
return FileOperation.create({
state: "creating",
size: 0,
key: "key/to/aws/file.zip",
url: "https://www.urltos3file.com/file.zip",
...overrides,
});
}
export async function buildAttachment(overrides: Object = {}) { export async function buildAttachment(overrides: Object = {}) {
if (!overrides.teamId) { if (!overrides.teamId) {
const team = await buildTeam(); const team = await buildTeam();

View File

@ -116,6 +116,30 @@ export type CollectionImportEvent = {
ip: string, ip: string,
}; };
export type CollectionExportAll = {
name: "collections.export_all",
teamId: string,
actorId: string,
data: {
exportId: string,
collections: [{ name: string, id: string }],
},
};
export type FileOperationEvent = {
name: "fileOperations.update",
teamId: string,
actorId: string,
data: {
type: string,
state: string,
id: string,
size: number,
createdAt: string,
collectionId: string,
},
};
export type CollectionEvent = export type CollectionEvent =
| { | {
name: | "collections.create" // eslint-disable-line name: | "collections.create" // eslint-disable-line
@ -192,6 +216,8 @@ export type Event =
| DocumentEvent | DocumentEvent
| CollectionEvent | CollectionEvent
| CollectionImportEvent | CollectionImportEvent
| CollectionExportAll
| FileOperationEvent
| IntegrationEvent | IntegrationEvent
| GroupEvent | GroupEvent
| RevisionEvent | RevisionEvent

View File

@ -4,6 +4,7 @@ import * as Sentry from "@sentry/node";
import AWS from "aws-sdk"; import AWS from "aws-sdk";
import { addHours, format } from "date-fns"; import { addHours, format } from "date-fns";
import fetch from "fetch-with-proxy"; import fetch from "fetch-with-proxy";
import { v4 as uuidv4 } from "uuid";
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
@ -167,7 +168,7 @@ export const deleteFromS3 = (key: string) => {
.promise(); .promise();
}; };
export const getSignedImageUrl = async (key: string) => { export const getSignedUrl = async (key: string) => {
const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/); const isDocker = process.env.AWS_S3_UPLOAD_BUCKET_URL.match(/http:\/\/s3:/);
const params = { const params = {
@ -181,6 +182,12 @@ export const getSignedImageUrl = async (key: string) => {
: s3.getSignedUrl("getObject", params); : s3.getSignedUrl("getObject", params);
}; };
// function assumes that acl is private
export const getAWSKeyForFileOp = (teamId: string, name: string) => {
const bucket = "uploads";
return `${bucket}/${teamId}/${uuidv4()}/${name}-export.zip`;
};
export const getFileByKey = async (key: string) => { export const getFileByKey = async (key: string) => {
const params = { const params = {
Bucket: AWS_S3_UPLOAD_BUCKET_NAME, Bucket: AWS_S3_UPLOAD_BUCKET_NAME,

View File

@ -69,17 +69,6 @@ async function archiveToPath(zip) {
}); });
} }
export async function archiveCollection(collection: Collection) {
const zip = new JSZip();
if (collection.documentStructure) {
const folder = zip.folder(collection.name);
await addToArchive(folder, collection.documentStructure);
}
return archiveToPath(zip);
}
export async function archiveCollections(collections: Collection[]) { export async function archiveCollections(collections: Collection[]) {
const zip = new JSZip(); const zip = new JSZip();

View File

@ -279,7 +279,8 @@
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.", "Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Saving": "Saving", "Saving": "Saving",
"Save": "Save", "Save": "Save",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be downloaded as a zip of folders with files in Markdown format.", "Export started, you will receive an email when its complete.": "Export started, you will receive an email when its complete.",
"Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip.": "Exporting the collection <em>{{collectionName}}</em> may take a few seconds. Your documents will be a zip of folders with files in Markdown format. Please visit the Export section on settings to get the zip.",
"Exporting": "Exporting", "Exporting": "Exporting",
"Export Collection": "Export Collection", "Export Collection": "Export Collection",
"Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.", "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.": "Collections are for grouping your documents. They work best when organized around a topic or internal team — Product or Engineering for example.",
@ -469,6 +470,11 @@
"No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>", "No documents found for your search filters. <1></1>": "No documents found for your search filters. <1></1>",
"Create a new document?": "Create a new document?", "Create a new document?": "Create a new document?",
"Clear filters": "Clear filters", "Clear filters": "Clear filters",
"Processing": "Processing",
"Expired": "Expired",
"Error": "Error",
"All collections": "All collections",
"{{userName}} requested": "{{userName}} requested",
"Last active": "Last active", "Last active": "Last active",
"Role": "Role", "Role": "Role",
"Viewer": "Viewer", "Viewer": "Viewer",
@ -501,10 +507,11 @@
"Uploading": "Uploading", "Uploading": "Uploading",
"Confirm & Import": "Confirm & Import", "Confirm & Import": "Confirm & Import",
"Choose File": "Choose File", "Choose File": "Choose File",
"A full export might take some time, consider exporting a single document or collection if possible. Well put together a zip of all your documents in Markdown format and email it to <em>{{ userEmail }}</em>.": "A full export might take some time, consider exporting a single document or collection if possible. Well put together a zip of all your documents in Markdown format and email it to <em>{{ userEmail }}</em>.", "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 we will email a link to <em>{{ userEmail }}</em> when it's 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 we will email a link to <em>{{ userEmail }}</em> when it's complete.",
"Export Requested": "Export Requested", "Export Requested": "Export Requested",
"Requesting Export": "Requesting Export", "Requesting Export": "Requesting Export",
"Export Data": "Export Data", "Export Data": "Export Data",
"Recent exports": "Recent exports",
"Document published": "Document published", "Document published": "Document published",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published", "Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated", "Document updated": "Document updated",