Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/plane/api/views/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion apps/api/plane/app/views/issue/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions apps/api/plane/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions apps/api/plane/tests/unit/utils/test_attachment.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions apps/api/plane/utils/attachment.py
Original file line number Diff line number Diff line change
@@ -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)
40 changes: 39 additions & 1 deletion packages/services/src/file/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,34 @@ const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
}
};

/**
* 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<string, string> = {
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
Expand All @@ -103,7 +131,17 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
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 "";
};

Expand Down