diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 5b15dbe70..baf7a30c2 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -29,6 +29,7 @@ import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete"; import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog"; import { createAction } from "~/actions"; import { DocumentSection } from "~/actions/sections"; +import env from "~/env"; import history from "~/utils/history"; import { documentInsightsUrl, @@ -206,6 +207,33 @@ export const downloadDocumentAsHTML = createAction({ }, }); +export const downloadDocumentAsPDF = createAction({ + name: ({ t }) => t("PDF"), + section: DocumentSection, + keywords: "export", + icon: , + iconInContextMenu: false, + visible: ({ activeDocumentId, stores }) => + !!activeDocumentId && + stores.policies.abilities(activeDocumentId).download && + env.PDF_EXPORT_ENABLED, + perform: ({ activeDocumentId, t, stores }) => { + if (!activeDocumentId) { + return; + } + + const id = stores.toasts.showToast(`${t("Exporting")}…`, { + type: "loading", + timeout: 30 * 1000, + }); + + const document = stores.documents.get(activeDocumentId); + document + ?.download("application/pdf") + .finally(() => id && stores.toasts.hideToast(id)); + }, +}); + export const downloadDocumentAsMarkdown = createAction({ name: ({ t }) => t("Markdown"), section: DocumentSection, @@ -230,7 +258,11 @@ export const downloadDocument = createAction({ section: DocumentSection, icon: , keywords: "export", - children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown], + children: [ + downloadDocumentAsHTML, + downloadDocumentAsPDF, + downloadDocumentAsMarkdown, + ], }); export const duplicateDocument = createAction({ diff --git a/app/components/Spinner.tsx b/app/components/Spinner.tsx index bc2809f2e..ef59b2a7c 100644 --- a/app/components/Spinner.tsx +++ b/app/components/Spinner.tsx @@ -1,7 +1,11 @@ import * as React from "react"; import styled from "styled-components"; -export default function Spinner(props: React.HTMLAttributes) { +type Props = React.HTMLAttributes & { + color?: string; +}; + +export default function Spinner({ color, ...props }: Props) { return ( ) { {...props} > ` @keyframes dash { 0% { stroke-dashoffset: 47; @@ -51,7 +56,7 @@ const Circle = styled.circle` } } - stroke: ${(props) => props.theme.textSecondary}; + stroke: ${(props) => props.$color || props.theme.textSecondary}; stroke-dasharray: 46; stroke-dashoffset: 0; transform-origin: center; diff --git a/app/components/Toast.tsx b/app/components/Toast.tsx index 1ad4410a6..779978c43 100644 --- a/app/components/Toast.tsx +++ b/app/components/Toast.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import styled, { css } from "styled-components"; import { fadeAndScaleIn, pulse } from "~/styles/animations"; import { Toast as TToast } from "~/types"; +import Spinner from "./Spinner"; type Props = { onRequestClose: () => void; @@ -56,6 +57,7 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) { onMouseLeave={handleResume} > + {type === "loading" && } {type === "info" && } {type === "success" && } {type === "warning" || diff --git a/app/models/Document.ts b/app/models/Document.ts index cf8b42beb..0dd53d754 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -419,7 +419,9 @@ export default class Document extends ParanoidModel { }; } - download = async (contentType: "text/html" | "text/markdown") => { + download = async ( + contentType: "text/html" | "text/markdown" | "application/pdf" + ) => { await client.post( `/documents.export`, { diff --git a/app/types.ts b/app/types.ts index 76c5a7c35..8da5ce6ca 100644 --- a/app/types.ts +++ b/app/types.ts @@ -124,7 +124,7 @@ export type Toast = { id: string; createdAt: string; message: string; - type: "warning" | "error" | "info" | "success"; + type: "warning" | "error" | "info" | "success" | "loading"; timeout?: number; reoccurring?: number; action?: { @@ -176,7 +176,7 @@ export type SearchResult = { }; export type ToastOptions = { - type: "warning" | "error" | "info" | "success"; + type: "warning" | "error" | "info" | "success" | "loading"; timeout?: number; action?: { text: string; diff --git a/server/errors.ts b/server/errors.ts index fc5b6c0f0..61ec10a1b 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -103,6 +103,14 @@ export function ValidationError(message = "Validation failed") { }); } +export function IncorrectEditionError( + message = "Functionality not available in this edition" +) { + return httpErrors(402, message, { + id: "incorrect_edition", + }); +} + export function EditorUpdateError( message = "The client editor is out of date and must be reloaded" ) { diff --git a/server/models/helpers/DocumentHelper.test.ts b/server/models/helpers/DocumentHelper.test.ts index 4fe08379d..dda1f2ce2 100644 --- a/server/models/helpers/DocumentHelper.test.ts +++ b/server/models/helpers/DocumentHelper.test.ts @@ -2,7 +2,7 @@ import Revision from "@server/models/Revision"; import DocumentHelper from "./DocumentHelper"; describe("toEmailDiff", () => { - test("toEmailDiff", () => { + test("toEmailDiff", async () => { const before = new Revision({ title: "Title", text: ` @@ -58,7 +58,7 @@ same on both sides same on both sides`, }); - const html = DocumentHelper.toEmailDiff(before, after); + const html = await DocumentHelper.toEmailDiff(before, after); // marks breaks in diff expect(html).toContain("diff-context-break"); diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index fbebef9bb..d8af4f1d0 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -37,6 +37,8 @@ type HTMLOptions = { includeStyles?: boolean; /** Whether to include styles to center diff (defaults to true) */ centered?: boolean; + /** Whether to replace attachment urls with pre-signed versions (defaults to false) */ + signedUrls?: boolean; }; export default class DocumentHelper { @@ -81,7 +83,7 @@ export default class DocumentHelper { * @param options Options for the HTML output * @returns The document title and content as a HTML string */ - static toHTML(document: Document | Revision, options?: HTMLOptions) { + static async toHTML(document: Document | Revision, options?: HTMLOptions) { const node = DocumentHelper.toProsemirror(document); const sheet = new ServerStyleSheet(); let html, styleTags; @@ -153,7 +155,16 @@ export default class DocumentHelper { target ); - return dom.serialize(); + let output = dom.serialize(); + + if (options?.signedUrls && document instanceof Document) { + output = await DocumentHelper.attachmentsToSignedUrls( + output, + document.teamId + ); + } + + return output; } /** @@ -164,17 +175,17 @@ export default class DocumentHelper { * @param options Options passed to HTML generation * @returns The diff as a HTML string */ - static diff( + static async diff( before: Document | Revision | null, after: Revision, options?: HTMLOptions ) { if (!before) { - return DocumentHelper.toHTML(after, options); + return await DocumentHelper.toHTML(after, options); } - const beforeHTML = DocumentHelper.toHTML(before, options); - const afterHTML = DocumentHelper.toHTML(after, options); + const beforeHTML = await DocumentHelper.toHTML(before, options); + const afterHTML = await DocumentHelper.toHTML(after, options); const beforeDOM = new JSDOM(beforeHTML); const afterDOM = new JSDOM(afterHTML); @@ -205,7 +216,7 @@ export default class DocumentHelper { * @param options Options passed to HTML generation * @returns The diff as a HTML string */ - static toEmailDiff( + static async toEmailDiff( before: Document | Revision | null, after: Revision, options?: HTMLOptions @@ -214,7 +225,7 @@ export default class DocumentHelper { return ""; } - const html = DocumentHelper.diff(before, after, options); + const html = await DocumentHelper.diff(before, after, options); const dom = new JSDOM(html); const doc = dom.window.document; diff --git a/server/policies/team.ts b/server/policies/team.ts index 4567bc2b5..d5b07a289 100644 --- a/server/policies/team.ts +++ b/server/policies/team.ts @@ -1,4 +1,5 @@ import env from "@server/env"; +import { IncorrectEditionError } from "@server/errors"; import { Team, User } from "@server/models"; import { allow } from "./cancan"; @@ -13,7 +14,7 @@ allow(User, "share", Team, (user, team) => { allow(User, "createTeam", Team, () => { if (env.DEPLOYMENT !== "hosted") { - throw new Error("createTeam only available on cloud"); + throw IncorrectEditionError("createTeam only available on cloud"); } }); diff --git a/server/presenters/env.ts b/server/presenters/env.ts index 2b7440b42..e50d4a909 100644 --- a/server/presenters/env.ts +++ b/server/presenters/env.ts @@ -20,6 +20,7 @@ export default function present(env: Environment): PublicEnv { SLACK_APP_ID: env.SLACK_APP_ID, MAXIMUM_IMPORT_SIZE: env.MAXIMUM_IMPORT_SIZE, SUBDOMAINS_ENABLED: env.SUBDOMAINS_ENABLED, + PDF_EXPORT_ENABLED: false, DEFAULT_LANGUAGE: env.DEFAULT_LANGUAGE, EMAIL_ENABLED: !!env.SMTP_HOST || env.ENVIRONMENT === "development", GOOGLE_ANALYTICS_ID: env.GOOGLE_ANALYTICS_ID, diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index a3175a8d8..0773acdd2 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -122,7 +122,7 @@ export default class NotificationsProcessor extends BaseProcessor { // generate the diff html for the email const before = await revision.previous(); - let content = DocumentHelper.toEmailDiff(before, revision, { + let content = await DocumentHelper.toEmailDiff(before, revision, { includeTitle: false, centered: false, }); diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 9e5626ca6..d7993bca2 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -7,6 +7,7 @@ import { Op, ScopeOptions, WhereOptions } from "sequelize"; import { TeamPreference } from "@shared/types"; import { subtractDate } from "@shared/utils/date"; import { bytesToHumanReadable } from "@shared/utils/files"; +import { RateLimiterStrategy } from "@server/RateLimiter"; import documentCreator from "@server/commands/documentCreator"; import documentImporter from "@server/commands/documentImporter"; import documentLoader from "@server/commands/documentLoader"; @@ -19,8 +20,10 @@ import { InvalidRequestError, AuthenticationError, ValidationError, + IncorrectEditionError, } from "@server/errors"; import auth from "@server/middlewares/authentication"; +import { rateLimiter } from "@server/middlewares/rateLimiter"; import validate from "@server/middlewares/validate"; import { Backlink, @@ -433,6 +436,7 @@ router.post( router.post( "documents.export", + rateLimiter(RateLimiterStrategy.FivePerMinute), auth({ optional: true, }), @@ -447,7 +451,7 @@ router.post( shareId, user, // We need the collaborative state to generate HTML. - includeState: accept === "text/html", + includeState: !accept?.includes("text/markdown"), }); let contentType; @@ -455,7 +459,11 @@ router.post( if (accept?.includes("text/html")) { contentType = "text/html"; - content = DocumentHelper.toHTML(document); + content = await DocumentHelper.toHTML(document); + } else if (accept?.includes("application/pdf")) { + throw IncorrectEditionError( + "PDF export is not available in the community edition" + ); } else if (accept?.includes("text/markdown")) { contentType = "text/markdown"; content = DocumentHelper.toMarkdown(document); diff --git a/server/routes/api/middlewares/apiWrapper.ts b/server/routes/api/middlewares/apiWrapper.ts index f717ca1eb..ea76bf6ce 100644 --- a/server/routes/api/middlewares/apiWrapper.ts +++ b/server/routes/api/middlewares/apiWrapper.ts @@ -8,7 +8,8 @@ export default function apiWrapper() { if ( typeof ctx.body === "object" && - !(ctx.body instanceof stream.Readable) + !(ctx.body instanceof stream.Readable) && + !(ctx.body instanceof Buffer) ) { ctx.body = { ...ctx.body, diff --git a/server/routes/api/revisions.ts b/server/routes/api/revisions.ts index 86dc42283..fff1acabc 100644 --- a/server/routes/api/revisions.ts +++ b/server/routes/api/revisions.ts @@ -30,7 +30,7 @@ router.post("revisions.info", auth(), async (ctx) => { ctx.body = { data: await presentRevision( revision, - DocumentHelper.diff(before, revision, { + await DocumentHelper.diff(before, revision, { includeTitle: false, includeStyles: false, }) @@ -73,7 +73,7 @@ router.post("revisions.diff", auth(), async (ctx) => { } const accept = ctx.request.headers["accept"]; - const content = DocumentHelper.diff(before, revision); + const content = await DocumentHelper.diff(before, revision); if (accept?.includes("text/html")) { ctx.set("Content-Type", "text/html"); diff --git a/server/routes/api/team.test.ts b/server/routes/api/team.test.ts index 7cbceb3fd..9de935e24 100644 --- a/server/routes/api/team.test.ts +++ b/server/routes/api/team.test.ts @@ -32,7 +32,7 @@ describe("teams.create", () => { name: "new workspace", }, }); - expect(res.status).toEqual(500); + expect(res.status).toEqual(402); }); }); diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index ae49cc813..0faf7ad4a 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -18,6 +18,8 @@ "Unsubscribe": "Unsubscribe", "Unsubscribed from document notifications": "Unsubscribed from document notifications", "HTML": "HTML", + "PDF": "PDF", + "Exporting": "Exporting", "Markdown": "Markdown", "Download": "Download", "Download document": "Download document", @@ -371,7 +373,6 @@ "Export started. If you have notifications enabled, you will receive an email when it's complete.": "Export started. If you have notifications enabled, you will receive an email when it's complete.", "Exporting the collection {{collectionName}} may take some time.": "Exporting the collection {{collectionName}} may take some time.", "Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.": "Your documents will be a zip of folders with files in Markdown format. Please visit the Export section in Settings to get the zip.", - "Exporting": "Exporting", "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.", "This is the default level of access, you can give individual users or groups more access once the collection is created.": "This is the default level of access, you can give individual users or groups more access once the collection is created.", "Public document sharing": "Public document sharing", diff --git a/shared/types.ts b/shared/types.ts index c9ee1bf1f..8f42cadf6 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -22,6 +22,7 @@ export type PublicEnv = { MAXIMUM_IMPORT_SIZE: number; SUBDOMAINS_ENABLED: boolean; EMAIL_ENABLED: boolean; + PDF_EXPORT_ENABLED: boolean; DEFAULT_LANGUAGE: string; GOOGLE_ANALYTICS_ID: string | undefined; RELEASE: string | undefined;