From 90c877efd4b8dc1e541eb275f2258bc70afccdf2 Mon Sep 17 00:00:00 2001 From: pranav-afk Date: Wed, 15 Jul 2026 15:41:22 +0530 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFfix:=20resolve=20empty=20attachment=20?= =?UTF-8?q?MIME=20types=20from=20filename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browsers on macOS often send an empty MIME type for md, csv, and txt uploads, so the API rejected allowlisted files as Invalid file type. Guess content type from the filename when the client omits it, add text/csv and tsv to the allow list, and mirror the extension fallback in the upload client helper. Fixes #9026 --- apps/api/plane/api/views/asset.py | 3 +- apps/api/plane/api/views/issue.py | 3 +- apps/api/plane/app/views/issue/attachment.py | 3 +- apps/api/plane/settings/common.py | 2 + .../plane/tests/unit/utils/test_attachment.py | 33 +++++++++++++ apps/api/plane/utils/attachment.py | 46 +++++++++++++++++++ packages/services/src/file/helper.ts | 40 +++++++++++++++- 7 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 apps/api/plane/tests/unit/utils/test_attachment.py create mode 100644 apps/api/plane/utils/attachment.py diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py index abfa6bdc0d8..0d0bfc967d9 100644 --- a/apps/api/plane/api/views/asset.py +++ b/apps/api/plane/api/views/asset.py @@ -18,6 +18,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.settings.storage import S3Storage from plane.utils.path_validator import sanitize_filename +from plane.utils.attachment import resolve_attachment_content_type from plane.db.models import FileAsset, User, Workspace from plane.app.permissions import WorkspaceUserPermission from plane.api.views.base import BaseAPIView @@ -516,7 +517,7 @@ def post(self, request, slug): Supports various file types and includes external source tracking for integrations. """ name = sanitize_filename(request.data.get("name")) - type = request.data.get("type") + type = resolve_attachment_content_type(name, request.data.get("type")) size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) project_id = request.data.get("project_id") external_id = request.data.get("external_id") diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 9224ffb4db4..7fb7e63f6a4 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -80,6 +80,7 @@ ) from plane.settings.storage import S3Storage from plane.utils.path_validator import sanitize_filename +from plane.utils.attachment import resolve_attachment_content_type from plane.utils.order_queryset import ( ACTIVITY_ORDER_BY_ALLOWLIST, ISSUE_ORDER_BY_ALLOWLIST, @@ -1884,7 +1885,7 @@ def post(self, request, slug, project_id, issue_id): ) name = sanitize_filename(request.data.get("name")) - type = request.data.get("type", False) + type = resolve_attachment_content_type(name, request.data.get("type", False)) size = request.data.get("size") external_id = request.data.get("external_id") external_source = request.data.get("external_source") diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index c4e7f0b0c89..fd2856d2f65 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -27,6 +27,7 @@ from plane.utils.path_validator import sanitize_filename from plane.bgtasks.storage_metadata_task import get_asset_object_metadata from plane.utils.host import base_host +from plane.utils.attachment import resolve_attachment_content_type class IssueAttachmentEndpoint(BaseAPIView): @@ -99,7 +100,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def post(self, request, slug, project_id, issue_id): name = sanitize_filename(request.data.get("name")) or "unnamed" - type = request.data.get("type", False) + type = resolve_attachment_content_type(name, request.data.get("type", False)) size = int(request.data.get("size", settings.FILE_SIZE_LIMIT)) if not type or type not in settings.ATTACHMENT_MIME_TYPES: diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 25a212e7639..3e112651ed9 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -473,6 +473,8 @@ def _retention_days(env_var, default): "application/vnd.openxmlformats-officedocument.presentationml.presentation", "text/plain", "text/markdown", + "text/csv", + "text/tab-separated-values", "application/rtf", "application/vnd.oasis.opendocument.spreadsheet", "application/vnd.oasis.opendocument.text", diff --git a/apps/api/plane/tests/unit/utils/test_attachment.py b/apps/api/plane/tests/unit/utils/test_attachment.py new file mode 100644 index 00000000000..2c4fae39c49 --- /dev/null +++ b/apps/api/plane/tests/unit/utils/test_attachment.py @@ -0,0 +1,33 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Unit tests for attachment MIME resolution helpers.""" + +import pytest + +from plane.utils.attachment import resolve_attachment_content_type + + +@pytest.mark.unit +class TestResolveAttachmentContentType: + def test_prefers_provided_content_type(self): + assert resolve_attachment_content_type("note.md", "text/plain") == "text/plain" + assert resolve_attachment_content_type("note.md", " text/markdown ") == "text/markdown" + + def test_falls_back_for_empty_type_on_markdown(self): + assert resolve_attachment_content_type("readme.md", "") == "text/markdown" + assert resolve_attachment_content_type("readme.md", None) == "text/markdown" + assert resolve_attachment_content_type("readme.md", False) == "text/markdown" + + def test_falls_back_for_empty_type_on_txt(self): + assert resolve_attachment_content_type("notes.txt", "") == "text/plain" + + def test_falls_back_for_empty_type_on_csv(self): + # Platform mimetypes may return text/csv or application/vnd.ms-excel + resolved = resolve_attachment_content_type("data.csv", "") + assert resolved in {"text/csv", "application/vnd.ms-excel"} + + def test_returns_none_without_name_or_type(self): + assert resolve_attachment_content_type(None, "") is None + assert resolve_attachment_content_type("", None) is None diff --git a/apps/api/plane/utils/attachment.py b/apps/api/plane/utils/attachment.py new file mode 100644 index 00000000000..93df90d97fb --- /dev/null +++ b/apps/api/plane/utils/attachment.py @@ -0,0 +1,46 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Helpers for work item / generic attachment uploads.""" + +from __future__ import annotations + +import mimetypes +import os + +# System mimetypes databases often omit or mis-map common text extensions +# (e.g. .md → None, .csv → text/csv vs application/vnd.ms-excel). Prefer an +# allowlisted type when guessing from the filename alone. +_EXTENSION_MIME_FALLBACKS: dict[str, str] = { + ".md": "text/markdown", + ".markdown": "text/markdown", + ".mdown": "text/markdown", + ".mkd": "text/markdown", + ".txt": "text/plain", + ".text": "text/plain", + ".log": "text/plain", + ".csv": "text/csv", + ".tsv": "text/tab-separated-values", +} + + +def resolve_attachment_content_type(name: str | None, content_type: str | None | bool) -> str | None: + """Return a usable MIME type for an attachment upload. + + Browsers (notably on macOS) often send an empty ``File.type`` for text + extensions such as ``.md``, ``.csv``, and ``.txt``. Fall back to guessing + from the filename so allowlisted types are not rejected incorrectly. + """ + if content_type and isinstance(content_type, str) and content_type.strip(): + return content_type.strip() + + if not name or not isinstance(name, str): + return None + + guessed, _ = mimetypes.guess_type(name) + if guessed: + return guessed + + extension = os.path.splitext(name)[1].lower() + return _EXTENSION_MIME_FALLBACKS.get(extension) diff --git a/packages/services/src/file/helper.ts b/packages/services/src/file/helper.ts index b8e96283986..b3a96d26db2 100644 --- a/packages/services/src/file/helper.ts +++ b/packages/services/src/file/helper.ts @@ -81,6 +81,34 @@ const detectMimeTypeFromSignature = async (file: File): Promise => { } }; +/** + * Extension → MIME map for text-like files that lack a binary signature. + * Browsers (especially on macOS) often leave File.type empty for these. + */ +const EXTENSION_MIME_FALLBACKS: Record = { + md: "text/markdown", + markdown: "text/markdown", + mdown: "text/markdown", + mkd: "text/markdown", + txt: "text/plain", + text: "text/plain", + log: "text/plain", + csv: "text/csv", + tsv: "text/tab-separated-values", +}; + +/** + * @description Guess MIME type from filename extension when browser/signature detection fails + * @param {string} filename + * @returns {string} guessed MIME type or empty string + */ +const guessMimeTypeFromFilename = (filename: string): string => { + const parts = filename.toLowerCase().split("."); + if (parts.length < 2) return ""; + const extension = parts[parts.length - 1] || ""; + return EXTENSION_MIME_FALLBACKS[extension] || ""; +}; + /** * @description Validate and detect the MIME type of a file using signature detection * Also performs basic security checks on filename @@ -103,7 +131,17 @@ const validateAndDetectFileType = async (file: File): Promise => { console.warn("Error detecting file type from signature:", _error); } - // fallback for unknown files + // Browser-provided type (empty for many text files on macOS) + if (file.type && file.type.trim()) { + return file.type.trim(); + } + + // Extension fallback for text-like uploads with no magic bytes + const extensionType = guessMimeTypeFromFilename(file.name); + if (extensionType) { + return extensionType; + } + return ""; };