🐛 [in brackets] back: immediately start streaming downloading of zip #783 (#788)

When downloading a folder with a great many entries below, time to first
byte can cause a tcp disconnection for timeout by a reverse proxy, this
starts the download asap, and ignores unreadable entries because of AV
or rights. It tries to generate a valid zip even if some entries are
missing for those reasons
This commit is contained in:
ericlinagora
2025-01-24 16:23:01 +01:00
committed by GitHub
3 changed files with 67 additions and 33 deletions
@@ -1505,10 +1505,17 @@ export class DocumentsService {
if (token) throw new CrudException("Invalid token", 401); if (token) throw new CrudException("Invalid token", 401);
} }
}; };
/**
*
* @param id DriveItemID to download. If it's a folder, a zip file will be returned
* @param versionId Optional specific version to download
* @param {Function} beginArchiveTransmit If a folder,
* called when the zip file can begin streaming, otherwise not called at all.
*/
download = async ( download = async (
id: string, id: string,
versionId: string | null, versionId: string | null,
beginArchiveTransmit: (readable: archiver.Archiver) => void,
context: DriveExecutionContext, context: DriveExecutionContext,
): Promise<{ ): Promise<{
archive?: archiver.Archiver; archive?: archiver.Archiver;
@@ -1522,7 +1529,7 @@ export class DocumentsService {
const item = await this.get(id, null, context); const item = await this.get(id, null, context);
if (item.item.is_directory) { if (item.item.is_directory) {
return { archive: await this.createZip([id], context) }; return { archive: await this.createZip([id], beginArchiveTransmit, context) };
} }
let version = item.item.last_version_cache; let version = item.item.last_version_cache;
@@ -1541,11 +1548,13 @@ export class DocumentsService {
* Creates a zip archive containing the drive items. * Creates a zip archive containing the drive items.
* *
* @param {string[]} ids - the drive item list * @param {string[]} ids - the drive item list
* @param {Function} beginArchiveTransmit - Called when the zip file can begin streaming
* @param {DriveExecutionContext} context - the execution context * @param {DriveExecutionContext} context - the execution context
* @returns {Promise<archiver.Archiver>} the created archive. * @returns {Promise<archiver.Archiver>} the created archive.
*/ */
createZip = async ( createZip = async (
ids: string[] = [], ids: string[] = [],
beginArchiveTransmit: (archive: archiver.Archiver) => void,
context: DriveExecutionContext, context: DriveExecutionContext,
): Promise<archiver.Archiver> => { ): Promise<archiver.Archiver> => {
if (!context) { if (!context) {
@@ -1561,19 +1570,31 @@ export class DocumentsService {
this.logger.error("error while creating ZIP file: ", error); this.logger.error("error while creating ZIP file: ", error);
}); });
let didBeginTransmission = false;
for (const id of ids) { for (const id of ids) {
if (!(await checkAccess(id, null, "read", this.repository, context))) { if (!(await checkAccess(id, null, "read", this.repository, context))) {
this.logger.warn(`not enough permissions to download ${id}, skipping`); this.logger.warn({ id }, `not enough permissions to download ${id}, skipping`);
return; continue;
} }
try { try {
await addDriveItemToArchive(id, null, archive, this.repository, context); await addDriveItemToArchive(
} catch (error) { id,
console.error(error); null,
this.logger.warn("failed to add item to archive", error); archive,
archive => {
if (didBeginTransmission) return;
didBeginTransmission = true;
beginArchiveTransmit(archive);
},
this.repository,
context,
);
} catch (err) {
this.logger.warn({ err, id }, "failed to add item to archive");
} }
} }
if (!didBeginTransmission) beginArchiveTransmit(archive);
//TODO[ASH] why do we need this call?? //TODO[ASH] why do we need this call??
archive.finalize(); archive.finalize();
@@ -345,6 +345,9 @@ export const addDriveItemToArchive = async (
id: string, id: string,
entity: DriveFile | null, entity: DriveFile | null,
archive: archiver.Archiver, archive: archiver.Archiver,
beginArchiveTransmit:
| "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED"
| ((archive: archiver.Archiver) => void),
repository: Repository<DriveFile>, repository: Repository<DriveFile>,
context: CompanyExecutionContext, context: CompanyExecutionContext,
prefix?: string, prefix?: string,
@@ -368,6 +371,10 @@ export const addDriveItemToArchive = async (
} }
archive.append(file.file, { name: item.name, prefix: prefix ?? "" }); archive.append(file.file, { name: item.name, prefix: prefix ?? "" });
if (beginArchiveTransmit !== "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED") {
beginArchiveTransmit(archive);
beginArchiveTransmit = "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED";
}
return; return;
} else { } else {
let nextPage = ""; let nextPage = "";
@@ -384,6 +391,7 @@ export const addDriveItemToArchive = async (
child.id, child.id,
child, child,
archive, archive,
beginArchiveTransmit,
repository, repository,
context, context,
`${prefix || ""}${item.name}/`, `${prefix || ""}${item.name}/`,
@@ -551,21 +551,21 @@ export class DocumentsController {
const archiveOrFile = await globalResolver.services.documents.documents.download( const archiveOrFile = await globalResolver.services.documents.documents.download(
id, id,
versionId, versionId,
archive => {
archive.on("finish", () => {
response.status(200);
});
archive.on("error", () => {
response.internalServerError();
});
archive.pipe(response.raw);
},
context, context,
); );
if (archiveOrFile.archive) { if (archiveOrFile.archive) {
const archive = archiveOrFile.archive;
archive.on("finish", () => {
response.status(200);
});
archive.on("error", () => {
response.internalServerError();
});
archive.pipe(response.raw);
return response; return response;
} else if (archiveOrFile.file) { } else if (archiveOrFile.file) {
const data = archiveOrFile.file; const data = archiveOrFile.file;
@@ -623,22 +623,27 @@ export class DocumentsController {
} }
try { try {
const archive = await globalResolver.services.documents.documents.createZip(ids, context); await globalResolver.services.documents.documents.createZip(
reply.raw.setHeader( ids,
"content-disposition", archive => {
formatAttachmentContentDispositionHeader("twake_drive.zip"), reply.raw.setHeader(
"content-disposition",
formatAttachmentContentDispositionHeader("twake_drive.zip"),
);
archive.on("finish", () => {
reply.status(200);
});
archive.on("error", () => {
reply.internalServerError();
});
archive.pipe(reply.raw);
},
context,
); );
archive.on("finish", () => {
reply.status(200);
});
archive.on("error", () => {
reply.internalServerError();
});
archive.pipe(reply.raw);
return reply; return reply;
} catch (error) { } catch (error) {
logger.error({ error: `${error}` }, "failed to send zip file"); logger.error({ error: `${error}` }, "failed to send zip file");