From 6c2844bebd38a86afa183f33ac7056b7b801c090 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:54:30 -0500 Subject: [PATCH] feat(attachments): add drag and drop for task attachments (#1805) Co-authored-by: Mariano Fuentes --- .../src/attachments/attachments.service.ts | 63 ++++ .../src/attachments/upload-attachment.dto.ts | 34 +- apps/api/src/tasks/attachments.service.ts | 63 ++++ .../src/tasks/dto/upload-attachment.dto.ts | 34 +- .../tasks/[taskId]/components/TaskBody.tsx | 296 ++++++++++++------ apps/app/tsconfig.json | 2 +- packages/docs/openapi.json | 14 +- 7 files changed, 361 insertions(+), 145 deletions(-) diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index cf93e12ca..8dcf4b0ee 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -51,6 +51,69 @@ export class AttachmentsService { userId?: string, ): Promise { try { + // Blocked file extensions for security + const BLOCKED_EXTENSIONS = [ + 'exe', + 'bat', + 'cmd', + 'com', + 'scr', + 'msi', // Windows executables + 'js', + 'vbs', + 'vbe', + 'wsf', + 'wsh', + 'ps1', // Scripts + 'sh', + 'bash', + 'zsh', // Shell scripts + 'dll', + 'sys', + 'drv', // System files + 'app', + 'deb', + 'rpm', // Application packages + 'jar', // Java archives (can execute) + 'pif', + 'lnk', + 'cpl', // Shortcuts and control panel + 'hta', + 'reg', // HTML apps and registry + ]; + + // Blocked MIME types for security + const BLOCKED_MIME_TYPES = [ + 'application/x-msdownload', // .exe + 'application/x-msdos-program', + 'application/x-executable', + 'application/x-sh', // Shell scripts + 'application/x-bat', // Batch files + 'text/x-sh', + 'text/x-python', + 'text/x-perl', + 'text/x-ruby', + 'application/x-httpd-php', // PHP files + 'application/x-javascript', // Executable JS (not JSON) + 'application/javascript', + 'text/javascript', + ]; + + // Validate file extension + const fileExt = uploadDto.fileName.split('.').pop()?.toLowerCase(); + if (fileExt && BLOCKED_EXTENSIONS.includes(fileExt)) { + throw new BadRequestException( + `File extension '.${fileExt}' is not allowed for security reasons`, + ); + } + + // Validate MIME type + if (BLOCKED_MIME_TYPES.includes(uploadDto.fileType.toLowerCase())) { + throw new BadRequestException( + `File type '${uploadDto.fileType}' is not allowed for security reasons`, + ); + } + // Validate file size const fileBuffer = Buffer.from(uploadDto.fileData, 'base64'); if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { diff --git a/apps/api/src/attachments/upload-attachment.dto.ts b/apps/api/src/attachments/upload-attachment.dto.ts index 8173ac9bf..c91dc176c 100644 --- a/apps/api/src/attachments/upload-attachment.dto.ts +++ b/apps/api/src/attachments/upload-attachment.dto.ts @@ -2,24 +2,28 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBase64, - IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, + Matches, } from 'class-validator'; -const ALLOWED_FILE_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'application/pdf', - 'text/plain', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +// Block dangerous MIME types that could execute code +const BLOCKED_MIME_TYPES = [ + 'application/x-msdownload', // .exe + 'application/x-msdos-program', + 'application/x-executable', + 'application/x-sh', // Shell scripts + 'application/x-bat', // Batch files + 'text/x-sh', + 'text/x-python', + 'text/x-perl', + 'text/x-ruby', + 'application/x-httpd-php', // PHP files + 'application/x-javascript', // Executable JS (not JSON) + 'application/javascript', + 'text/javascript', ]; export class UploadAttachmentDto { @@ -37,11 +41,11 @@ export class UploadAttachmentDto { @ApiProperty({ description: 'MIME type of the file', example: 'application/pdf', - enum: ALLOWED_FILE_TYPES, }) @IsString() - @IsIn(ALLOWED_FILE_TYPES, { - message: `File type must be one of: ${ALLOWED_FILE_TYPES.join(', ')}`, + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, { + message: 'Invalid MIME type format', }) fileType: string; diff --git a/apps/api/src/tasks/attachments.service.ts b/apps/api/src/tasks/attachments.service.ts index 876f9bce9..b425fe976 100644 --- a/apps/api/src/tasks/attachments.service.ts +++ b/apps/api/src/tasks/attachments.service.ts @@ -47,6 +47,69 @@ export class AttachmentsService { userId?: string, ): Promise { try { + // Blocked file extensions for security + const BLOCKED_EXTENSIONS = [ + 'exe', + 'bat', + 'cmd', + 'com', + 'scr', + 'msi', // Windows executables + 'js', + 'vbs', + 'vbe', + 'wsf', + 'wsh', + 'ps1', // Scripts + 'sh', + 'bash', + 'zsh', // Shell scripts + 'dll', + 'sys', + 'drv', // System files + 'app', + 'deb', + 'rpm', // Application packages + 'jar', // Java archives (can execute) + 'pif', + 'lnk', + 'cpl', // Shortcuts and control panel + 'hta', + 'reg', // HTML apps and registry + ]; + + // Blocked MIME types for security + const BLOCKED_MIME_TYPES = [ + 'application/x-msdownload', // .exe + 'application/x-msdos-program', + 'application/x-executable', + 'application/x-sh', // Shell scripts + 'application/x-bat', // Batch files + 'text/x-sh', + 'text/x-python', + 'text/x-perl', + 'text/x-ruby', + 'application/x-httpd-php', // PHP files + 'application/x-javascript', // Executable JS (not JSON) + 'application/javascript', + 'text/javascript', + ]; + + // Validate file extension + const fileExt = uploadDto.fileName.split('.').pop()?.toLowerCase(); + if (fileExt && BLOCKED_EXTENSIONS.includes(fileExt)) { + throw new BadRequestException( + `File extension '.${fileExt}' is not allowed for security reasons`, + ); + } + + // Validate MIME type + if (BLOCKED_MIME_TYPES.includes(uploadDto.fileType.toLowerCase())) { + throw new BadRequestException( + `File type '${uploadDto.fileType}' is not allowed for security reasons`, + ); + } + // Validate file size const fileBuffer = Buffer.from(uploadDto.fileData, 'base64'); if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { diff --git a/apps/api/src/tasks/dto/upload-attachment.dto.ts b/apps/api/src/tasks/dto/upload-attachment.dto.ts index 8173ac9bf..c91dc176c 100644 --- a/apps/api/src/tasks/dto/upload-attachment.dto.ts +++ b/apps/api/src/tasks/dto/upload-attachment.dto.ts @@ -2,24 +2,28 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBase64, - IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, + Matches, } from 'class-validator'; -const ALLOWED_FILE_TYPES = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'application/pdf', - 'text/plain', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-excel', - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +// Block dangerous MIME types that could execute code +const BLOCKED_MIME_TYPES = [ + 'application/x-msdownload', // .exe + 'application/x-msdos-program', + 'application/x-executable', + 'application/x-sh', // Shell scripts + 'application/x-bat', // Batch files + 'text/x-sh', + 'text/x-python', + 'text/x-perl', + 'text/x-ruby', + 'application/x-httpd-php', // PHP files + 'application/x-javascript', // Executable JS (not JSON) + 'application/javascript', + 'text/javascript', ]; export class UploadAttachmentDto { @@ -37,11 +41,11 @@ export class UploadAttachmentDto { @ApiProperty({ description: 'MIME type of the file', example: 'application/pdf', - enum: ALLOWED_FILE_TYPES, }) @IsString() - @IsIn(ALLOWED_FILE_TYPES, { - message: `File type must be one of: ${ALLOWED_FILE_TYPES.join(', ')}`, + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, { + message: 'Invalid MIME type format', }) fileType: string; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx index 320b37ef3..a7d112efb 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskBody.tsx @@ -1,8 +1,8 @@ 'use client'; import { useTaskAttachmentActions, useTaskAttachments } from '@/hooks/use-tasks-api'; -import type { AttachmentEntityType } from '@db'; -import { FileIcon, FileText, ImageIcon, Loader2, Plus, X } from 'lucide-react'; +import { Button } from '@comp/ui/button'; +import { FileIcon, FileText, ImageIcon, Loader2, Upload, X } from 'lucide-react'; import type React from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; @@ -36,6 +36,7 @@ export function TaskBody({ const textareaRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [busyAttachmentId, setBusyAttachmentId] = useState(null); + const [isDragging, setIsDragging] = useState(false); // Auto-resize function for textarea const autoResizeTextarea = useCallback(() => { @@ -77,15 +78,54 @@ export function TaskBody({ } }; - // Handle multiple file uploads using API - const handleFileSelectMultiple = useCallback( - async (event: React.ChangeEvent) => { - const files = event.target.files; + // Process files (used by both file input and drag & drop) + const processFiles = useCallback( + async (files: FileList | File[]) => { if (!files || files.length === 0) return; setIsUploading(true); + // Blocked file extensions for security + const BLOCKED_EXTENSIONS = [ + 'exe', + 'bat', + 'cmd', + 'com', + 'scr', + 'msi', // Windows executables + 'js', + 'vbs', + 'vbe', + 'wsf', + 'wsh', + 'ps1', // Scripts + 'sh', + 'bash', + 'zsh', // Shell scripts + 'dll', + 'sys', + 'drv', // System files + 'app', + 'deb', + 'rpm', // Application packages + 'jar', // Java archives (can execute) + 'pif', + 'lnk', + 'cpl', // Shortcuts and control panel + 'hta', + 'reg', // HTML apps and registry + ]; + const uploadPromises = Array.from(files).map((file) => { return new Promise((resolve) => { + // Check file extension + const fileExt = file.name.split('.').pop()?.toLowerCase(); + if (fileExt && BLOCKED_EXTENSIONS.includes(fileExt)) { + toast.error( + `File "${file.name}" has a blocked extension (.${fileExt}) for security reasons.`, + ); + return resolve(null); + } + const MAX_FILE_SIZE_MB = 10; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; if (file.size > MAX_FILE_SIZE_BYTES) { @@ -121,10 +161,59 @@ export function TaskBody({ [uploadAttachment, refreshAttachments], ); + // Handle multiple file uploads using API + const handleFileSelectMultiple = useCallback( + async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + await processFiles(files); + }, + [processFiles], + ); + const triggerFileInput = () => { fileInputRef.current?.click(); }; + // Drag and drop handlers + const handleDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { + setIsDragging(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only set dragging to false if we're leaving the drop zone itself + if (e.currentTarget === e.target) { + setIsDragging(false); + } + }, []); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + if (isUploading || busyAttachmentId) return; + + const files = e.dataTransfer.files; + if (files && files.length > 0) { + await processFiles(files); + } + }, + [isUploading, busyAttachmentId, processFiles], + ); + const handleDownloadClick = async (attachmentId: string) => { setBusyAttachmentId(attachmentId); try { @@ -194,111 +283,116 @@ export function TaskBody({ )} - {!attachmentsLoading && attachmentsData !== undefined && attachments.length > 0 ? ( -
-
- {attachments.map((attachment) => { - const isBusy = busyAttachmentId === attachment.id; - // Use attachment directly since it already has the correct structure - const attachmentForItem = { - ...attachment, - // Ensure proper date objects and types - createdAt: new Date(attachment.createdAt), - updatedAt: new Date(attachment.updatedAt), - entityType: attachment.entityType as AttachmentEntityType, - }; - const fileExt = attachment.name.split('.').pop()?.toLowerCase() || ''; - const isPDF = fileExt === 'pdf'; - const isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt); - const isDoc = ['doc', 'docx'].includes(fileExt); - - const getFileTypeStyles = () => { - if (isPDF) - return 'bg-primary/10 border-primary/20 hover:bg-primary/20 hover:border-primary/30'; - if (isImage) - return 'bg-primary/10 border-primary/20 hover:bg-primary/20 hover:border-primary/30'; - if (isDoc) - return 'bg-primary/10 border-primary/20 hover:bg-primary/20 hover:border-primary/30'; - return 'bg-muted/50 border-border hover:bg-muted/70'; - }; - - const getFileIconColor = () => { - if (isPDF || isImage || isDoc) return 'text-primary'; - return 'text-muted-foreground'; - }; - - return ( -
- {isPDF ? ( - - ) : isImage ? ( - - ) : isDoc ? ( - - ) : ( - - )} - - -
- ); - })} - {/* Add button inline with attachments */} - -
-
- ) : ( - !attachmentsLoading && - attachmentsData !== undefined && - attachments.length === 0 && ( - + + + ); + })} + + )} + + {/* Drag and drop zone - always visible */} + - ) + + )} diff --git a/apps/app/tsconfig.json b/apps/app/tsconfig.json index 13886308c..28d862825 100644 --- a/apps/app/tsconfig.json +++ b/apps/app/tsconfig.json @@ -15,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b9176dec5..e09c94a31 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -9848,19 +9848,7 @@ "fileType": { "type": "string", "description": "MIME type of the file", - "example": "application/pdf", - "enum": [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "application/pdf", - "text/plain", - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" - ] + "example": "application/pdf" }, "fileData": { "type": "string",