feat: Return attachments when exporting an individual file (#5778)
This commit is contained in:
@@ -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));
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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)
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}%`,
|
||||
|
||||
Reference in New Issue
Block a user