From 48d688c0a5c7fd8cabf028775aec16cb2b8c59c6 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 11 Nov 2023 10:52:29 -0500 Subject: [PATCH] Store source metadata for imported documents (#6136) --- app/scenes/Document/components/DataLoader.tsx | 15 ++++++- app/scenes/Error402.tsx | 26 +++++++++++ app/utils/ApiClient.ts | 5 +++ app/utils/errors.ts | 2 + server/commands/documentCreator.ts | 44 +++++++++++-------- server/commands/documentLoader.ts | 9 ++++ server/errors.ts | 6 +++ .../20231111023920-add-source-metadata.js | 14 ++++++ server/models/Document.ts | 26 ++++++++++- server/queues/tasks/ImportJSONTask.ts | 11 ++--- server/queues/tasks/ImportMarkdownZipTask.ts | 1 + server/queues/tasks/ImportNotionTask.ts | 29 ++++++------ server/queues/tasks/ImportTask.ts | 14 ++++-- server/routes/api/documents/documents.ts | 12 +++-- shared/i18n/locales/en_US/translation.json | 1 + shared/types.ts | 11 +++++ 16 files changed, 178 insertions(+), 48 deletions(-) create mode 100644 app/scenes/Error402.tsx create mode 100644 server/migrations/20231111023920-add-source-metadata.js diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index d583df41d..4b12f600a 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -5,6 +5,7 @@ import { NavigationNode, TeamPreference } from "@shared/types"; import { RevisionHelper } from "@shared/utils/RevisionHelper"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; +import Error402 from "~/scenes/Error402"; import Error404 from "~/scenes/Error404"; import ErrorOffline from "~/scenes/ErrorOffline"; import useCurrentTeam from "~/hooks/useCurrentTeam"; @@ -12,7 +13,11 @@ import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import Logger from "~/utils/Logger"; -import { NotFoundError, OfflineError } from "~/utils/errors"; +import { + NotFoundError, + OfflineError, + PaymentRequiredError, +} from "~/utils/errors"; import history from "~/utils/history"; import { matchDocumentEdit, settingsPath } from "~/utils/routeHelpers"; import Loading from "./Loading"; @@ -195,7 +200,13 @@ function DataLoader({ match, children }: Props) { }, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]); if (error) { - return error instanceof OfflineError ? : ; + return error instanceof OfflineError ? ( + + ) : error instanceof PaymentRequiredError ? ( + + ) : ( + + ); } if (!document || (revisionId && !revision)) { diff --git a/app/scenes/Error402.tsx b/app/scenes/Error402.tsx new file mode 100644 index 000000000..4709400a9 --- /dev/null +++ b/app/scenes/Error402.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import Empty from "~/components/Empty"; +import Notice from "~/components/Notice"; +import Scene from "~/components/Scene"; + +const Error402 = () => { + const location = useLocation<{ title?: string }>(); + const { t } = useTranslation(); + const title = location.state?.title ?? t("Payment Required"); + + return ( + +

{title}

+ + + This document cannot be viewed with the current edition. Please + upgrade to a paid license to restore access. + + +
+ ); +}; + +export default Error402; diff --git a/app/utils/ApiClient.ts b/app/utils/ApiClient.ts index d7867f6c4..0275487e1 100644 --- a/app/utils/ApiClient.ts +++ b/app/utils/ApiClient.ts @@ -12,6 +12,7 @@ import { NetworkError, NotFoundError, OfflineError, + PaymentRequiredError, RateLimitExceededError, RequestError, ServiceUnavailableError, @@ -160,6 +161,10 @@ class ApiClient { throw new BadRequestError(error.message); } + if (response.status === 402) { + throw new PaymentRequiredError(error.message); + } + if (response.status === 403) { if (error.error === "user_suspended") { await stores.auth.logout(false, false); diff --git a/app/utils/errors.ts b/app/utils/errors.ts index 67f4b1763..421d6ad3f 100644 --- a/app/utils/errors.ts +++ b/app/utils/errors.ts @@ -8,6 +8,8 @@ export class NetworkError extends ExtendableError {} export class NotFoundError extends ExtendableError {} +export class PaymentRequiredError extends ExtendableError {} + export class OfflineError extends ExtendableError {} export class ServiceUnavailableError extends ExtendableError {} diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index 022d10b30..0b73ceded 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -1,27 +1,32 @@ import { Transaction } from "sequelize"; +import { Optional } from "utility-types"; import { Document, Event, User } from "@server/models"; import DocumentHelper from "@server/models/helpers/DocumentHelper"; -type Props = { - id?: string; - urlId?: string; - title: string; - emoji?: string | null; - text?: string; +type Props = Optional< + Pick< + Document, + | "id" + | "urlId" + | "title" + | "text" + | "emoji" + | "collectionId" + | "parentDocumentId" + | "importId" + | "template" + | "fullWidth" + | "sourceMetadata" + | "editorVersion" + | "publishedAt" + | "createdAt" + | "updatedAt" + > +> & { state?: Buffer; publish?: boolean; - collectionId?: string | null; - parentDocumentId?: string | null; - importId?: string; - publishedAt?: Date; - template?: boolean; templateDocument?: Document | null; - fullWidth?: boolean; - createdAt?: Date; - updatedAt?: Date; user: User; - editorVersion?: string; - source?: "import"; ip?: string; transaction?: Transaction; }; @@ -46,7 +51,7 @@ export default async function documentCreator({ user, editorVersion, publishedAt, - source, + sourceMetadata, ip, transaction, }: Props): Promise { @@ -82,6 +87,7 @@ export default async function documentCreator({ templateId, publishedAt, importId, + sourceMetadata, fullWidth: templateDocument ? templateDocument.fullWidth : fullWidth, emoji: templateDocument ? templateDocument.emoji : emoji, title: DocumentHelper.replaceTemplateVariables( @@ -112,7 +118,7 @@ export default async function documentCreator({ teamId: document.teamId, actorId: user.id, data: { - source, + source: importId ? "import" : undefined, title: document.title, templateId, }, @@ -137,7 +143,7 @@ export default async function documentCreator({ teamId: document.teamId, actorId: user.id, data: { - source, + source: importId ? "import" : undefined, title: document.title, }, ip, diff --git a/server/commands/documentLoader.ts b/server/commands/documentLoader.ts index ab6173f55..e64d7243a 100644 --- a/server/commands/documentLoader.ts +++ b/server/commands/documentLoader.ts @@ -7,6 +7,7 @@ import { InvalidRequestError, AuthorizationError, AuthenticationError, + PaymentRequiredError, } from "@server/errors"; import { Collection, Document, Share, User, Team } from "@server/models"; import { authorize, can } from "@server/policies"; @@ -119,6 +120,10 @@ export default async function loadDocument({ throw NotFoundError("Document could not be found for shareId"); } + if (document.isTrialImport) { + throw PaymentRequiredError(); + } + // If the user has access to read the document, we can just update // the last access date and return the document without additional checks. const canReadDocument = user && can(user, "read", document); @@ -202,6 +207,10 @@ export default async function loadDocument({ user && authorize(user, "read", document); } + if (document.isTrialImport) { + throw PaymentRequiredError(); + } + collection = document.collection; } diff --git a/server/errors.ts b/server/errors.ts index a772f6a2e..3e5ef33ae 100644 --- a/server/errors.ts +++ b/server/errors.ts @@ -87,6 +87,12 @@ export function InvalidRequestError(message = "Request invalid") { }); } +export function PaymentRequiredError(message = "Payment required") { + return httpErrors(402, message, { + id: "payment_required", + }); +} + export function NotFoundError(message = "Resource not found") { return httpErrors(404, message, { id: "not_found", diff --git a/server/migrations/20231111023920-add-source-metadata.js b/server/migrations/20231111023920-add-source-metadata.js new file mode 100644 index 000000000..2e27d932f --- /dev/null +++ b/server/migrations/20231111023920-add-source-metadata.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + async up (queryInterface, Sequelize) { + await queryInterface.addColumn("documents", "sourceMetadata", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + async down (queryInterface) { + await queryInterface.removeColumn("documents", "sourceMetadata"); + } +}; diff --git a/server/models/Document.ts b/server/models/Document.ts index df664d7f9..3c79f2817 100644 --- a/server/models/Document.ts +++ b/server/models/Document.ts @@ -31,9 +31,10 @@ import { Length as SimpleLength, IsNumeric, IsDate, + AllowNull, } from "sequelize-typescript"; import isUUID from "validator/lib/isUUID"; -import type { NavigationNode } from "@shared/types"; +import type { NavigationNode, SourceMetadata } from "@shared/types"; import getTasks from "@shared/utils/getTasks"; import slugify from "@shared/utils/slugify"; import { SLUG_URL_REGEX } from "@shared/utils/urlHelpers"; @@ -79,6 +80,11 @@ type AdditionalFindOptions = { publishedAt: { [Op.ne]: null, }, + sourceMetadata: { + trial: { + [Op.is]: null, + }, + }, }, })) @Scopes(() => ({ @@ -399,6 +405,10 @@ class Document extends ParanoidModel { @Column(DataType.UUID) importId: string | null; + @AllowNull + @Column(DataType.JSONB) + sourceMetadata: SourceMetadata | null; + @BelongsTo(() => Document, "parentDocumentId") parentDocument: Document | null; @@ -545,10 +555,24 @@ class Document extends ParanoidModel { return !this.publishedAt; } + /** + * Returns the title of the document or a default if the document is untitled. + * + * @returns boolean + */ get titleWithDefault(): string { return this.title || "Untitled"; } + /** + * Whether this document was imported during a trial period. + * + * @returns boolean + */ + get isTrialImport() { + return !!(this.importId && this.sourceMetadata?.trial); + } + /** * Get a list of users that have collaborated on this document * diff --git a/server/queues/tasks/ImportJSONTask.ts b/server/queues/tasks/ImportJSONTask.ts index fb4c6869e..4bde7a874 100644 --- a/server/queues/tasks/ImportJSONTask.ts +++ b/server/queues/tasks/ImportJSONTask.ts @@ -75,11 +75,12 @@ export default class ImportJSONTask extends ImportTask { updatedAt: node.updatedAt ? new Date(node.updatedAt) : undefined, publishedAt: node.publishedAt ? new Date(node.publishedAt) : null, collectionId, - sourceId: node.id, + externalId: node.id, + mimeType: "application/json", parentDocumentId: node.parentDocumentId ? find( output.documents, - (d) => d.sourceId === node.parentDocumentId + (d) => d.externalId === node.parentDocumentId )?.id : null, id, @@ -101,7 +102,7 @@ export default class ImportJSONTask extends ImportTask { buffer: () => zipObject.async("nodebuffer"), mimeType, path: node.key, - sourceId: node.id, + externalId: node.id, }); }); } @@ -132,7 +133,7 @@ export default class ImportJSONTask extends ImportTask { ) : item.collection.description, id: collectionId, - sourceId: item.collection.id, + externalId: item.collection.id, }); if (Object.values(item.documents).length) { @@ -149,7 +150,7 @@ export default class ImportJSONTask extends ImportTask { for (const document of output.documents) { for (const attachment of output.attachments) { const encodedPath = encodeURI( - `/api/attachments.redirect?id=${attachment.sourceId}` + `/api/attachments.redirect?id=${attachment.externalId}` ); document.text = document.text.replace( diff --git a/server/queues/tasks/ImportMarkdownZipTask.ts b/server/queues/tasks/ImportMarkdownZipTask.ts index b9a0e1272..be7ab567e 100644 --- a/server/queues/tasks/ImportMarkdownZipTask.ts +++ b/server/queues/tasks/ImportMarkdownZipTask.ts @@ -139,6 +139,7 @@ export default class ImportMarkdownZipTask extends ImportTask { collectionId, parentDocumentId, path: child.path, + mimeType: "text/markdown", }); await parseNodeChildren(child.children, collectionId, id); diff --git a/server/queues/tasks/ImportNotionTask.ts b/server/queues/tasks/ImportNotionTask.ts index 8bd28991b..f9c1eb985 100644 --- a/server/queues/tasks/ImportNotionTask.ts +++ b/server/queues/tasks/ImportNotionTask.ts @@ -62,7 +62,7 @@ export default class ImportNotionTask extends ImportTask { const id = uuidv4(); const match = child.title.match(this.NotionUUIDRegex); const name = child.title.replace(this.NotionUUIDRegex, ""); - const sourceId = match ? match[0].trim() : undefined; + const externalId = match ? match[0].trim() : undefined; // If it's not a text file we're going to treat it as an attachment. const mimeType = mime.lookup(child.name); @@ -79,7 +79,7 @@ export default class ImportNotionTask extends ImportTask { path: child.path, mimeType, buffer: () => zipObject.async("nodebuffer"), - sourceId, + externalId, }); return; } @@ -95,12 +95,12 @@ export default class ImportNotionTask extends ImportTask { }); const existingDocumentIndex = output.documents.findIndex( - (doc) => doc.sourceId === sourceId + (doc) => doc.externalId === externalId ); const existingDocument = output.documents[existingDocumentIndex]; - // If there is an existing document with the same sourceId that means + // If there is an existing document with the same externalId that means // we've already parsed either a folder or a file referencing the same // document, as such we should merge. if (existingDocument) { @@ -122,7 +122,8 @@ export default class ImportNotionTask extends ImportTask { collectionId, parentDocumentId, path: child.path, - sourceId, + mimeType: mimeType || "text/markdown", + externalId, }); await parseNodeChildren(child.children, collectionId, id); } @@ -168,13 +169,13 @@ export default class ImportNotionTask extends ImportTask { // instead of a relative or absolute URL within the original zip file. for (const link of internalLinksInText) { const doc = output.documents.find( - (doc) => doc.sourceId === link.sourceId + (doc) => doc.externalId === link.externalId ); if (!doc) { Logger.info( "task", - `Could not find referenced document with sourceId ${link.sourceId}` + `Could not find referenced document with externalId ${link.externalId}` ); } else { text = text.replace(link.href, `<<${doc.id}>>`); @@ -188,11 +189,11 @@ export default class ImportNotionTask extends ImportTask { for (const node of tree) { const match = node.title.match(this.NotionUUIDRegex); const name = node.title.replace(this.NotionUUIDRegex, ""); - const sourceId = match ? match[0].trim() : undefined; + const externalId = match ? match[0].trim() : undefined; const mimeType = mime.lookup(node.name); const existingCollectionIndex = output.collections.findIndex( - (collection) => collection.sourceId === sourceId + (collection) => collection.externalId === externalId ); const existingCollection = output.collections[existingCollectionIndex]; const collectionId = existingCollection?.id || uuidv4(); @@ -232,7 +233,7 @@ export default class ImportNotionTask extends ImportTask { id: collectionId, name, description, - sourceId, + externalId, }); } } @@ -254,19 +255,19 @@ export default class ImportNotionTask extends ImportTask { /** * Extracts internal links from a markdown document, taking into account the - * sourceId of the document, which is part of the link title. + * externalId of the document, which is part of the link title. * * @param text The markdown text to parse * @returns An array of internal links */ private parseInternalLinks( text: string - ): { title: string; href: string; sourceId: string }[] { + ): { title: string; href: string; externalId: string }[] { return compact( [...text.matchAll(this.NotionLinkRegex)].map((match) => ({ title: match[1], href: match[2], - sourceId: match[3], + externalId: match[3], })) ); } @@ -294,7 +295,7 @@ export default class ImportNotionTask extends ImportTask { /** * Regex to find markdown links containing ID's that look like UUID's with the - * "-"'s removed, Notion's sourceId format. + * "-"'s removed, Notion's externalId format. */ private NotionLinkRegex = /\[([^[]+)]\((.*?([0-9a-fA-F]{32})\..*?)\)/g; diff --git a/server/queues/tasks/ImportTask.ts b/server/queues/tasks/ImportTask.ts index 8abe60324..94e2bfddf 100644 --- a/server/queues/tasks/ImportTask.ts +++ b/server/queues/tasks/ImportTask.ts @@ -1,3 +1,4 @@ +import path from "path"; import truncate from "lodash/truncate"; import { AttachmentPreset, @@ -49,7 +50,7 @@ export type StructuredImportData = { */ description?: string | Record | null; /** Optional id from import source, useful for mapping */ - sourceId?: string; + externalId?: string; }[]; documents: { id: string; @@ -75,8 +76,9 @@ export type StructuredImportData = { createdById?: string; createdByEmail?: string | null; path: string; + mimeType: string; /** Optional id from import source, useful for mapping */ - sourceId?: string; + externalId?: string; }[]; attachments: { id: string; @@ -85,7 +87,7 @@ export type StructuredImportData = { mimeType: string; buffer: () => Promise; /** Optional id from import source, useful for mapping */ - sourceId?: string; + externalId?: string; }[]; }; @@ -428,7 +430,11 @@ export default abstract class ImportTask extends BaseTask { const document = await documentCreator({ ...options, - source: "import", + sourceMetadata: { + fileName: path.basename(item.path), + mimeType: item.mimeType, + externalId: item.externalId, + }, id: item.id, title: item.title, text, diff --git a/server/routes/api/documents/documents.ts b/server/routes/api/documents/documents.ts index 4f74d144e..32947cd80 100644 --- a/server/routes/api/documents/documents.ts +++ b/server/routes/api/documents/documents.ts @@ -1326,17 +1326,23 @@ router.post( } const content = await fs.readFile(file.filepath); + const fileName = file.originalFilename ?? file.newFilename; + const mimeType = file.mimetype ?? ""; + const { text, state, title, emoji } = await documentImporter({ user, - fileName: file.originalFilename ?? file.newFilename, - mimeType: file.mimetype ?? "", + fileName, + mimeType, content, ip: ctx.request.ip, transaction, }); const document = await documentCreator({ - source: "import", + sourceMetadata: { + fileName, + mimeType, + }, title, emoji, text, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b0224afb8..8b4fe439d 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -600,6 +600,7 @@ "Search documents": "Search documents", "No documents found for your filters.": "No documents found for your filters.", "You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.", + "Payment Required": "Payment Required", "Not Found": "Not Found", "We were unable to find the page you’re looking for. Go to the <2>homepage?": "We were unable to find the page you’re looking for. Go to the <2>homepage?", "Offline": "Offline", diff --git a/shared/types.ts b/shared/types.ts index b5234cbeb..41ac60735 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -123,6 +123,17 @@ export enum UserPreference { export type UserPreferences = { [key in UserPreference]?: boolean }; +export type SourceMetadata = { + /** The original source file name. */ + fileName?: string; + /** The original source mime type. */ + mimeType?: string; + /** An ID in the external source. */ + externalId?: string; + /** Whether the item was created through a trial license. */ + trial?: boolean; +}; + export type CustomTheme = { accent: string; accentText: string;