Skip to content

Commit f0ce98a

Browse files
authored
📁 #2652 added download drive item as a zip archive (#2679)
* added download drive item as a zip archive * added docs * tweaks
1 parent 73ba1ac commit f0ce98a

File tree

8 files changed

+209
-22
lines changed

8 files changed

+209
-22
lines changed

Documentation/docs/internal-documentation/backend-services/documents/rest-apis.md

+18
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,21 @@ Used to create a drive item version
221221
### Success Response
222222

223223
**Code** : `200 OK`
224+
225+
## zip download
226+
227+
Used to create a zip archive containing the requested drive items ( files and folders ).
228+
229+
**URL** : `/internal/services/documents/v1/companies/:company_id/item/:id/download/zip`
230+
231+
**Method** : `POST`
232+
233+
**Auth required** : Yes
234+
235+
**Data constraints**
236+
237+
```javascript
238+
{
239+
items: string[]
240+
}
241+
```

twake/backend/node/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"@babel/preset-typescript": "^7.10.4",
5454
"@types/amqp-connection-manager": "^2.0.10",
5555
"@types/analytics-node": "^3.1.5",
56+
"@types/archiver": "^5.3.1",
5657
"@types/bcrypt": "^5.0.0",
5758
"@types/busboy": "^0.2.3",
5859
"@types/chai": "^4.2.12",
@@ -118,6 +119,7 @@
118119
"amqp-connection-manager": "^3.7.0",
119120
"amqplib": "^0.8.0",
120121
"analytics-node": "^5.0.0",
122+
"archiver": "^5.3.1",
121123
"axios": "^0.21.3",
122124
"bcrypt": "^5.0.1",
123125
"cassandra-driver": "^4.6.0",

twake/backend/node/src/services/documents/services/index.ts

+54-5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { DriveFile, TYPE } from "../entities/drive-file";
99
import { FileVersion, TYPE as FileVersionType } from "../entities/file-version";
1010
import { CompanyExecutionContext, DriveItemDetails, RootType, TrashType } from "../types";
1111
import {
12+
addDriveItemToArchive,
1213
calculateItemSize,
1314
checkAccess,
1415
getDefaultDriveItem,
@@ -17,6 +18,8 @@ import {
1718
updateItemSize,
1819
} from "../utils";
1920

21+
import archiver from "archiver";
22+
2023
export class DocumentsService {
2124
version: "1";
2225
repository: Repository<DriveFile>;
@@ -74,7 +77,7 @@ export class DocumentsService {
7477

7578
//Check access to entity
7679
try {
77-
const hasAccess = checkAccess(id, entity, "read", this.repository, context);
80+
const hasAccess = await checkAccess(id, entity, "read", this.repository, context);
7881
if (!hasAccess) {
7982
this.logger.error("user does not have access drive item ", id);
8083
throw Error("user does not have access to this item");
@@ -161,7 +164,13 @@ export class DocumentsService {
161164
const driveItem = getDefaultDriveItem(content, context);
162165
const driveItemVersion = getDefaultDriveItemVersion(version, context);
163166

164-
const hasAccess = checkAccess(driveItem.parent_id, null, "write", this.repository, context);
167+
const hasAccess = await checkAccess(
168+
driveItem.parent_id,
169+
null,
170+
"write",
171+
this.repository,
172+
context,
173+
);
165174
if (!hasAccess) {
166175
this.logger.error("user does not have access drive item parent", driveItem.parent_id);
167176
throw Error("user does not have access to this item parent");
@@ -174,6 +183,7 @@ export class DocumentsService {
174183
driveItem.extension = file.metadata.name.split(".").pop();
175184
driveItemVersion.filename = driveItemVersion.filename || file.metadata.name;
176185
driveItemVersion.file_size = file.upload_data.size;
186+
driveItemVersion.file_id = file.id;
177187
}
178188

179189
await this.fileVersionRepository.save(driveItemVersion);
@@ -209,7 +219,7 @@ export class DocumentsService {
209219
}
210220

211221
try {
212-
const hasAccess = checkAccess(id, null, "write", this.repository, context);
222+
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
213223

214224
if (!hasAccess) {
215225
this.logger.error("user does not have access drive item ", id);
@@ -296,7 +306,7 @@ export class DocumentsService {
296306
}
297307

298308
try {
299-
if (!checkAccess(item.id, item, "write", this.repository, context)) {
309+
if (!(await checkAccess(item.id, item, "write", this.repository, context))) {
300310
this.logger.error("user does not have access drive item ", id);
301311
throw Error("user does not have access to this item");
302312
}
@@ -372,7 +382,7 @@ export class DocumentsService {
372382
}
373383

374384
try {
375-
const hasAccess = checkAccess(id, null, "write", this.repository, context);
385+
const hasAccess = await checkAccess(id, null, "write", this.repository, context);
376386
if (!hasAccess) {
377387
this.logger.error("user does not have access drive item ", id);
378388
throw Error("user does not have access to this item");
@@ -408,4 +418,43 @@ export class DocumentsService {
408418
throw new CrudException("Failed to create Drive item version", 500);
409419
}
410420
};
421+
422+
/**
423+
* Creates a zip archive containing the drive items.
424+
*
425+
* @param {string[]} ids - the drive item list
426+
* @param {CompanyExecutionContext} context - the execution context
427+
* @returns {Promise<archiver.Archiver>} the created archive.
428+
*/
429+
createZip = async (
430+
ids: string[] = [],
431+
context: CompanyExecutionContext,
432+
): Promise<archiver.Archiver> => {
433+
if (!context) {
434+
this.logger.error("invalid execution context");
435+
return null;
436+
}
437+
438+
const archive = archiver("zip", {
439+
zlib: { level: 9 },
440+
});
441+
442+
await Promise.all(
443+
ids.map(async id => {
444+
if (!(await checkAccess(id, null, "read", this.repository, context))) {
445+
this.logger.warn(`not enough permissions to download ${id}, skipping`);
446+
return;
447+
}
448+
449+
try {
450+
await addDriveItemToArchive(id, null, archive, this.repository, context);
451+
} catch (error) {
452+
this.logger.warn("failed to add item to archive", error);
453+
throw new Error("Failed to add item to archive");
454+
}
455+
}),
456+
);
457+
458+
return archive;
459+
};
411460
}

twake/backend/node/src/services/documents/types.ts

+12
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ export interface CompanyExecutionContext extends ExecutionContext {
66
company: { id: string };
77
}
88

9+
export type RequestParams = {
10+
company_id: string;
11+
};
12+
13+
export type ItemRequestParams = RequestParams & {
14+
id: string;
15+
};
16+
917
export type DriveItemDetails = {
1018
path: DriveFile[];
1119
item?: DriveFile;
@@ -18,3 +26,7 @@ export type publicAccessLevel = "write" | "read" | "none";
1826

1927
export type RootType = "root";
2028
export type TrashType = "trash";
29+
30+
export type DownloadZipBodyRequest = {
31+
items: string[];
32+
};

twake/backend/node/src/services/documents/utils.ts

+55-5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import crypto from "crypto";
1111
import { FileVersion } from "./entities/file-version";
1212
import globalResolver from "../global-resolver";
1313
import Repository from "../../core/platform/services/database/services/orm/repository/repository";
14+
import archiver from "archiver";
1415

1516
/**
1617
* Returns the default DriveFile object using existing data
@@ -23,28 +24,31 @@ export const getDefaultDriveItem = (
2324
item: Partial<DriveFile>,
2425
context: CompanyExecutionContext,
2526
): DriveFile => {
26-
const defaultDriveItem = merge(new DriveFile(), {
27+
const defaultDriveItem = merge<DriveFile, Partial<DriveFile>>(new DriveFile(), {
2728
company_id: context.company.id,
2829
added: item.added || new Date().getTime().toString(),
2930
creator: item.creator || context.user.id,
3031
is_directory: item.is_directory || false,
3132
is_in_trash: false,
3233
last_user: item.last_user || context.user.id,
3334
last_modified: new Date().getTime().toString(),
34-
parent_id: item.parent_id || "",
35+
parent_id: item.parent_id || "root",
3536
root_group_folder: item.root_group_folder || "",
3637
attachements: item.attachements || [],
3738
content_keywords: item.content_keywords || "",
3839
description: item.description || "",
3940
access_info: item.access_info || {
40-
authorized_entities: [
41+
entities: [
4142
{
4243
id: context.user.id,
4344
type: "user",
45+
level: "write",
4446
},
4547
],
46-
unauthorized_entities: [],
47-
public_access_token: generateAccessToken(),
48+
public: {
49+
level: "read",
50+
token: generateAccessToken(),
51+
},
4852
},
4953
detached_file: item.detached_file || false,
5054
extension: item.extension || "",
@@ -366,3 +370,49 @@ export const checkAccess = async (
366370
throw Error(error);
367371
}
368372
};
373+
374+
/**
375+
* Adds drive items to an archive recursively
376+
*
377+
* @param {string} id - the drive item id
378+
* @param {DriveFile | null } entity - the drive item entity
379+
* @param {archiver.Archiver} archive - the archive
380+
* @param {Repository<DriveFile>} repository - the repository
381+
* @param {CompanyExecutionContext} context - the execution context
382+
* @param {string} prefix - folder prefix
383+
* @returns {Promise<void>}
384+
*/
385+
export const addDriveItemToArchive = async (
386+
id: string,
387+
entity: DriveFile | null,
388+
archive: archiver.Archiver,
389+
repository: Repository<DriveFile>,
390+
context: CompanyExecutionContext,
391+
prefix?: string,
392+
): Promise<void> => {
393+
const item = entity || (await repository.findOne({ id, company_id: context.company.id }));
394+
395+
if (!item) {
396+
throw Error("item not found");
397+
}
398+
399+
if (!item.is_directory) {
400+
const file_id = item.last_version_cache.file_id;
401+
const file = await globalResolver.services.files.download(file_id, context);
402+
403+
if (!file) {
404+
throw Error("file not found");
405+
}
406+
407+
archive.append(file.file, { name: file.name, prefix: prefix ?? "" });
408+
} else {
409+
const items = await repository.find({
410+
parent_id: item.id,
411+
company_id: context.company.id,
412+
});
413+
414+
items.getEntities().forEach(child => {
415+
addDriveItemToArchive(child.id, child, archive, repository, context, `${item.name}/`);
416+
});
417+
}
418+
};

twake/backend/node/src/services/documents/web/controllers/documents.ts

+49-11
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FastifyRequest } from "fastify";
1+
import { FastifyReply, FastifyRequest } from "fastify";
22
import { logger } from "../../../../core/platform/framework";
33
import { CrudException } from "../../../../core/platform/framework/api/crud-service";
44
import { File } from "../../../../services/files/entities/file";
@@ -7,15 +7,13 @@ import globalResolver from "../../../../services/global-resolver";
77
import { PaginationQueryParameters } from "../../../../utils/types";
88
import { DriveFile } from "../../entities/drive-file";
99
import { FileVersion } from "../../entities/file-version";
10-
import { CompanyExecutionContext, DriveItemDetails } from "../../types";
11-
12-
type RequestParams = {
13-
company_id: string;
14-
};
15-
16-
type ItemRequestParams = RequestParams & {
17-
id: string;
18-
};
10+
import {
11+
CompanyExecutionContext,
12+
DownloadZipBodyRequest,
13+
DriveItemDetails,
14+
ItemRequestParams,
15+
RequestParams,
16+
} from "../../types";
1917

2018
export class DocumentsController {
2119
/**
@@ -104,9 +102,10 @@ export class DocumentsController {
104102
};
105103

106104
/**
105+
* Update drive item
107106
*
108107
* @param {FastifyRequest} request
109-
* @returns
108+
* @returns {Promise<DriveFile>}
110109
*/
111110
update = async (
112111
request: FastifyRequest<{ Params: ItemRequestParams; Body: Partial<DriveFile> }>,
@@ -118,6 +117,12 @@ export class DocumentsController {
118117
return await globalResolver.services.documents.update(id, update, context);
119118
};
120119

120+
/**
121+
* Create a drive file version.
122+
*
123+
* @param {FastifyRequest} request
124+
* @returns {Promise<FileVersion>}
125+
*/
121126
createVersion = async (
122127
request: FastifyRequest<{ Params: ItemRequestParams; Body: Partial<FileVersion> }>,
123128
): Promise<FileVersion> => {
@@ -127,6 +132,39 @@ export class DocumentsController {
127132

128133
return await globalResolver.services.documents.createVersion(id, version, context);
129134
};
135+
136+
/**
137+
* Downloads a zip archive containing the drive items.
138+
*
139+
* @param {FastifyRequest} request
140+
* @param {FastifyReply} reply
141+
*/
142+
downloadZip = async (
143+
request: FastifyRequest<{ Params: RequestParams; Body: DownloadZipBodyRequest }>,
144+
reply: FastifyReply,
145+
): Promise<void> => {
146+
const context = getCompanyExecutionContext(request);
147+
const ids = request.body.items;
148+
149+
try {
150+
const archive = await globalResolver.services.documents.createZip(ids, context);
151+
152+
archive.on("finish", () => {
153+
reply.status(200);
154+
});
155+
156+
archive.on("error", () => {
157+
reply.internalServerError();
158+
});
159+
160+
archive.pipe(reply.raw);
161+
162+
archive.finalize();
163+
} catch (error) {
164+
logger.error("failed to send zip file", error);
165+
throw new CrudException("Failed to create zip file", 500);
166+
}
167+
};
130168
}
131169

132170
/**

twake/backend/node/src/services/documents/web/routes.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { FastifyInstance, FastifyPluginCallback } from "fastify";
22
import { DocumentsController } from "./controllers";
3-
import { createDocumentSchema, createVersionSchema } from "./schemas";
3+
import { createDocumentSchema, createVersionSchema, downloadZipSchema } from "./schemas";
44

55
const serviceUrl = "/companies/:company_id/item";
66

@@ -44,6 +44,14 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, _options, next)
4444
handler: documentsController.createVersion.bind(documentsController),
4545
});
4646

47+
fastify.route({
48+
method: "POST",
49+
url: `${serviceUrl}/download/zip`,
50+
preValidation: [fastify.authenticate],
51+
schema: downloadZipSchema,
52+
handler: documentsController.downloadZip.bind(documentsController),
53+
});
54+
4755
return next();
4856
};
4957

0 commit comments

Comments
 (0)