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 ""; };