feat: Return attachments when exporting an individual file (#5778)

This commit is contained in:
Tom Moor
2023-09-06 20:53:30 -04:00
committed by GitHub
parent d1de5871de
commit 127115272a
7 changed files with 116 additions and 40 deletions

View File

@@ -1,5 +1,6 @@
import path from "path";
import JSZip from "jszip";
import escapeRegExp from "lodash/escapeRegExp";
import { FileOperationFormat, NavigationNode } from "@shared/types";
import Logger from "@server/logging/Logger";
import { Collection } from "@server/models";
@@ -73,14 +74,17 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
date: attachment.updatedAt,
createFolders: true,
});
text = text.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
encodeURI(attachment.key)
);
} catch (err) {
Logger.error(
`Failed to add attachment to archive: ${attachment.key}`,
err
);
}
text = text.replace(attachment.redirectUrl, encodeURI(attachment.key));
})
);

View File

@@ -1,6 +1,9 @@
import path from "path";
import fs from "fs-extra";
import invariant from "invariant";
import JSZip from "jszip";
import Router from "koa-router";
import escapeRegExp from "lodash/escapeRegExp";
import mime from "mime-types";
import { Op, ScopeOptions, WhereOptions } from "sequelize";
import { TeamPreference } from "@shared/types";
@@ -20,11 +23,13 @@ import {
ValidationError,
IncorrectEditionError,
} from "@server/errors";
import Logger from "@server/logging/Logger";
import auth from "@server/middlewares/authentication";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { transaction } from "@server/middlewares/transaction";
import validate from "@server/middlewares/validate";
import {
Attachment,
Backlink,
Collection,
Document,
@@ -46,7 +51,9 @@ import {
} from "@server/presenters";
import { APIContext } from "@server/types";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import ZipHelper from "@server/utils/ZipHelper";
import { getFileFromRequest } from "@server/utils/koa";
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
import { getTeamFromContext } from "@server/utils/passport";
import slugify from "@server/utils/slugify";
import { assertPresent } from "@server/validation";
@@ -516,8 +523,8 @@ router.post(
includeState: !accept?.includes("text/markdown"),
});
let contentType;
let content;
let contentType: string;
let content: string;
if (accept?.includes("text/html")) {
contentType = "text/html";
@@ -537,26 +544,70 @@ router.post(
content = DocumentHelper.toMarkdown(document);
}
if (contentType !== "application/json") {
// Override the extension for Markdown as it's incorrect in the mime-types
// library until a new release > 2.1.35
const extension =
contentType === "text/markdown" ? "md" : mime.extension(contentType);
if (contentType === "application/json") {
ctx.body = {
data: content,
};
return;
}
// Override the extension for Markdown as it's incorrect in the mime-types
// library until a new release > 2.1.35
const extension =
contentType === "text/markdown" ? "md" : mime.extension(contentType);
const fileName = slugify(document.titleWithDefault);
const attachmentIds = parseAttachmentIds(document.text);
const attachments = attachmentIds.length
? await Attachment.findAll({
where: {
teamId: document.teamId,
id: attachmentIds,
},
})
: [];
if (attachments.length === 0) {
ctx.set("Content-Type", contentType);
ctx.set(
"Content-Disposition",
`attachment; filename="${slugify(
document.titleWithDefault
)}.${extension}"`
);
ctx.attachment(`${fileName}.${extension}`);
ctx.body = content;
return;
}
ctx.body = {
data: content,
};
const zip = new JSZip();
await Promise.all(
attachments.map(async (attachment) => {
try {
const location = path.join(
"attachments",
`${attachment.id}.${mime.extension(attachment.contentType)}`
);
zip.file(location, attachment.buffer, {
date: attachment.updatedAt,
createFolders: true,
});
content = content.replace(
new RegExp(escapeRegExp(attachment.redirectUrl), "g"),
location
);
} catch (err) {
Logger.error(
`Failed to add attachment to archive: ${attachment.id}`,
err
);
}
})
);
zip.file(`${fileName}.${extension}`, content, {
date: document.updatedAt,
});
ctx.set("Content-Type", "application/zip");
ctx.attachment(`${fileName}.zip`);
ctx.body = zip.generateNodeStream(ZipHelper.defaultStreamOptions);
}
);

View File

@@ -1,5 +1,6 @@
import stream from "stream";
import { Context, Next } from "koa";
import { Readable } from "readable-stream";
export default function apiWrapper() {
return async function apiWrapperMiddleware(ctx: Context, next: Next) {
@@ -8,6 +9,7 @@ export default function apiWrapper() {
if (
typeof ctx.body === "object" &&
!(ctx.body instanceof Readable) &&
!(ctx.body instanceof stream.Readable) &&
!(ctx.body instanceof Buffer)
) {

View File

@@ -101,13 +101,9 @@ router.post(
const content = await DocumentHelper.diff(before, revision);
if (accept?.includes("text/html")) {
const name = `${slugify(document.titleWithDefault)}-${revision.id}.html`;
ctx.set("Content-Type", "text/html");
ctx.set(
"Content-Disposition",
`attachment; filename="${slugify(document.titleWithDefault)}-${
revision.id
}.html"`
);
ctx.attachment(name);
ctx.body = content;
return;
}

View File

@@ -22,6 +22,16 @@ export type FileTreeNode = {
@trace()
export default class ZipHelper {
public static defaultStreamOptions: JSZip.JSZipGeneratorOptions<"nodebuffer"> =
{
type: "nodebuffer",
streamFiles: true,
compression: "DEFLATE",
compressionOptions: {
level: 5,
},
};
/**
* Converts the flat structure returned by JSZIP into a nested file structure
* for easier processing.
@@ -84,7 +94,10 @@ export default class ZipHelper {
* @param zip JSZip object
* @returns pathname of the temporary file where the zip was written to disk
*/
public static async toTmpFile(zip: JSZip): Promise<string> {
public static async toTmpFile(
zip: JSZip,
options?: JSZip.JSZipGeneratorOptions<"nodebuffer">
): Promise<string> {
Logger.debug("utils", "Creating tmp file…");
return new Promise((resolve, reject) => {
tmp.file(
@@ -105,17 +118,18 @@ export default class ZipHelper {
zip
.generateNodeStream(
{
type: "nodebuffer",
streamFiles: true,
...this.defaultStreamOptions,
...options,
},
(metadata) => {
const percent = Math.round(metadata.percent);
if (percent !== previousMetadata.percent) {
if (metadata.currentFile !== previousMetadata.currentFile) {
const percent = Math.round(metadata.percent);
const memory = process.memoryUsage();
previousMetadata = {
currentFile: metadata.currentFile,
percent,
};
const memory = process.memoryUsage();
Logger.debug(
"utils",
`Writing zip file progress… ${percent}%`,