diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 68ea06dbdc..25a212e763 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -473,7 +473,6 @@ ATTACHMENT_MIME_TYPES = [ "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", - "text/mdx", "application/rtf", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.text", @@ -541,6 +540,8 @@ ATTACHMENT_MIME_TYPES = [ "application/x-sql", # Gzip "application/x-gzip", + # Markdown + "text/markdown", ] # MIME types that browsers can execute as scripts when served inline. diff --git a/apps/web/core/components/icons/attachment/attachment-icon.test.tsx b/apps/web/core/components/icons/attachment/attachment-icon.test.tsx new file mode 100644 index 0000000000..ab83b0b186 --- /dev/null +++ b/apps/web/core/components/icons/attachment/attachment-icon.test.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { describe, expect, it } from "vitest"; + +import { DefaultIcon, TxtIcon } from "@/components/icons/attachment"; + +import { getFileIcon } from "./attachment-icon"; + +describe("getFileIcon", () => { + it.each(["md", "markdown", "mdx", "MD", "MARKDOWN", "MDX"])( + "uses the text icon for the %s extension", + (extension) => { + expect(getFileIcon(extension).type).toBe(TxtIcon); + } + ); + + it("uses the default icon for an unknown extension", () => { + expect(getFileIcon("unknown").type).toBe(DefaultIcon); + }); +}); diff --git a/apps/web/core/components/issues/attachment/attachment-detail.tsx b/apps/web/core/components/issues/attachment/attachment-detail.tsx index fb486b04b1..aa48959197 100644 --- a/apps/web/core/components/issues/attachment/attachment-detail.tsx +++ b/apps/web/core/components/issues/attachment/attachment-detail.tsx @@ -53,7 +53,7 @@ export const IssueAttachmentsDetail = observer(function IssueAttachmentsDetail(p // derived values const attachment = attachmentId ? getAttachmentById(attachmentId) : undefined; const fileName = getFileName(attachment?.attributes.name ?? ""); - const fileExtension = getFileExtension(attachment?.asset_url ?? ""); + const fileExtension = getFileExtension(attachment?.attributes.name ?? ""); const fileIcon = getFileIcon(fileExtension, 28); const fileURL = getFileURL(attachment?.asset_url ?? ""); // hooks diff --git a/apps/web/package.json b/apps/web/package.json index 9fbf4caf9a..9e7a89f9d5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "react-router dev --port 3000", "build": "react-router build", + "test": "vitest run", "preview": "react-router build && serve -s build/client -l 3000", "start": "serve -s build/client -l 3000", "clean": "rm -rf .turbo && rm -rf .next && rm -rf .react-router && rm -rf node_modules && rm -rf dist && rm -rf build", @@ -86,6 +87,7 @@ "dotenv": "catalog:", "typescript": "catalog:", "vite": "catalog:", - "vite-tsconfig-paths": "catalog:" + "vite-tsconfig-paths": "catalog:", + "vitest": "catalog:" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000000..e3137f0672 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + tsconfigPaths: true, + }, + test: { + environment: "node", + include: ["core/**/*.test.{ts,tsx}"], + }, +}); diff --git a/packages/editor/src/core/constants/config.ts b/packages/editor/src/core/constants/config.ts index 9e126fa2a7..64508e63e0 100644 --- a/packages/editor/src/core/constants/config.ts +++ b/packages/editor/src/core/constants/config.ts @@ -33,7 +33,6 @@ export const ACCEPTED_ATTACHMENT_MIME_TYPES = [ "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", - "text/mdx", "application/rtf", "audio/mpeg", "audio/wav", diff --git a/packages/services/package.json b/packages/services/package.json index 7f988cc048..b02e65fb41 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -14,6 +14,7 @@ "scripts": { "build": "tsdown", "dev": "tsdown --watch --no-clean", + "test": "vitest run", "check:lint": "oxlint --max-warnings=6 .", "check:types": "tsc --noEmit", "check:format": "oxfmt --check .", @@ -30,6 +31,7 @@ "devDependencies": { "@plane/typescript-config": "workspace:*", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/services/src/file/helper.test.ts b/packages/services/src/file/helper.test.ts new file mode 100644 index 0000000000..85265f057f --- /dev/null +++ b/packages/services/src/file/helper.test.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getFileMetaDataForUpload } from "./helper"; + +const createFile = (name: string, contents: BlobPart[] = ["# Markdown"]): File => + new File(contents, name, { type: "" }); + +describe("getFileMetaDataForUpload", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each(["notes.md", "notes.markdown", "notes.mdx", "NOTES.MD", "NOTES.MDX"])( + "detects %s as Markdown from its extension", + async (filename) => { + const metadata = await getFileMetaDataForUpload(createFile(filename)); + + expect(metadata.type).toBe("text/markdown"); + } + ); + + it.each([ + "", + ".notes.md", + "folder/notes.md", + "folder\\notes.mdx", + "notes.exe", + "notes.exe.md", + "notes.EXE.mdx", + "notes.exe.safe.md", + ])("rejects unsafe filename %s", async (filename) => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const metadata = await getFileMetaDataForUpload(createFile(filename)); + + expect(metadata.type).toBe(""); + }); + + it("returns an empty type for an unsupported extension without a detectable signature", async () => { + const metadata = await getFileMetaDataForUpload(createFile("notes.bin")); + + expect(metadata.type).toBe(""); + }); + + it("prefers a detected signature over the filename extension", async () => { + const pngHeader = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, + ]); + const metadata = await getFileMetaDataForUpload(createFile("image.md", [pngHeader])); + + expect(metadata.type).toBe("image/png"); + }); +}); diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index 19635f06f4..8d3b590480 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -17,7 +17,7 @@ import { DANGEROUS_EXTENSIONS } from "@plane/constants"; const EXTENSION_MIME_TYPE_MAP: Record = { md: "text/markdown", markdown: "text/markdown", - mdx: "text/mdx", + mdx: "text/markdown", }; /** @@ -54,12 +54,11 @@ const validateFilename = (filename: string): string | null => { const parts = filename.split("."); - // Check for double extensions with dangerous patterns - if (parts.length >= 3) { - const secondLastExt = parts[parts.length - 2]?.toLowerCase() || ""; - if (DANGEROUS_EXTENSIONS.includes(secondLastExt)) { - return "File has suspicious double extension"; - } + // Check for dangerous extensions anywhere before the final extension. + // This catches both double and longer disguised chains (e.g. file.exe.safe.md). + const intermediateExtensions = parts.slice(1, -1).map((part) => part.toLowerCase()); + if (intermediateExtensions.some((extension) => DANGEROUS_EXTENSIONS.includes(extension))) { + return "File has suspicious extension chain"; } // Check if the actual extension is dangerous diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1320f09737..a44b12236d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1219,6 +1219,9 @@ importers: vite-tsconfig-paths: specifier: 'catalog:' version: 5.1.4(typescript@5.8.3)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) packages/codemods: devDependencies: @@ -1664,6 +1667,9 @@ importers: typescript: specifier: 5.8.3 version: 5.8.3 + vitest: + specifier: 'catalog:' + version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@22.12.0)(@vitest/coverage-v8@4.1.8)(vite@8.0.16(@types/node@22.12.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.43.1)(tsx@4.20.6)(yaml@2.8.3)) packages/shared-state: dependencies: