Explorar el Código

Merge pull request #9886 from weseek/fix/uploading-profile-image-acceptable-file-types

fix: Profile image upload functionality and accepted file types
mergify[bot] hace 11 meses
padre
commit
1432df747e

+ 6 - 1
apps/app/src/client/components/Me/ProfileImageSettings.tsx

@@ -147,7 +147,12 @@ const ProfileImageSettings = (): JSX.Element => {
               {t('Upload new image')}
               {t('Upload new image')}
             </label>
             </label>
             <div className="col-md-6 col-lg-8">
             <div className="col-md-6 col-lg-8">
-              <input type="file" onChange={selectFileHandler} name="profileImage" accept="image/*" />
+              <input
+                type="file"
+                onChange={selectFileHandler}
+                name="profileImage"
+                accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/avif,image/heic,image/heif,image/tiff,image/svg+xml"
+              />
             </div>
             </div>
           </div>
           </div>
         </div>
         </div>

+ 7 - 4
apps/app/src/server/routes/attachment/api.js

@@ -4,6 +4,8 @@ import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import { Attachment } from '../../models/attachment';
 import { Attachment } from '../../models/attachment';
+
+import { validateImageContentType } from './image-content-type-validator';
 /* eslint-disable no-use-before-define */
 /* eslint-disable no-use-before-define */
 
 
 
 
@@ -246,10 +248,11 @@ export const routesFactory = (crowi) => {
 
 
     const file = req.file;
     const file = req.file;
 
 
-    // check type
-    const acceptableFileType = /image\/.+/;
-    if (!file.mimetype.match(acceptableFileType)) {
-      return res.json(ApiResponse.error('File type error. Only image files is allowed to set as user picture.'));
+    // Validate file type
+    const { isValid, error } = validateImageContentType(file.mimetype);
+
+    if (!isValid) {
+      return res.json(ApiResponse.error(error));
     }
     }
 
 
     let attachment;
     let attachment;

+ 65 - 0
apps/app/src/server/routes/attachment/image-content-type-validator.spec.ts

@@ -0,0 +1,65 @@
+import { describe, test, expect } from 'vitest';
+
+import { validateImageContentType, type SupportedImageMimeType } from './image-content-type-validator';
+
+describe('validateImageContentType', () => {
+  describe('valid cases', () => {
+    // Test supported MIME types
+    const supportedTypes: SupportedImageMimeType[] = [
+      'image/png',
+      'image/jpeg',
+      'image/jpg',
+      'image/gif',
+      'image/webp',
+      'image/avif',
+      'image/heic',
+      'image/heif',
+      'image/tiff',
+      'image/svg+xml',
+    ];
+
+    test.each(supportedTypes)('should accept %s', (mimeType) => {
+      const result = validateImageContentType(mimeType);
+      expect(result.isValid).toBe(true);
+      expect(result.contentType).toBe(mimeType);
+      expect(result.error).toBeUndefined();
+    });
+
+    test('should accept MIME type with surrounding whitespace', () => {
+      const result = validateImageContentType('  image/png  ');
+      expect(result.isValid).toBe(true);
+      expect(result.contentType).toBe('image/png');
+      expect(result.error).toBeUndefined();
+    });
+  });
+
+  describe('invalid cases', () => {
+    // Test invalid input types
+    test.each([
+      ['undefined', undefined],
+      ['null', null],
+      ['number', 42],
+      ['object', {}],
+    ])('should reject %s', (_, input) => {
+      const result = validateImageContentType(input as unknown as string);
+      expect(result.isValid).toBe(false);
+      expect(result.contentType).toBeNull();
+      expect(result.error).toBe('Invalid MIME type format');
+    });
+
+    // Test invalid MIME types
+    test.each([
+      ['empty string', ''],
+      ['whitespace only', '   '],
+      ['non-image type', 'text/plain'],
+      ['unknown image type', 'image/unknown'],
+      ['multiple MIME types', 'text/plain, image/png'],
+      ['multiple image types', 'image/png, image/jpeg'],
+      ['MIME type with comma', 'image/png,'],
+    ])('should reject %s', (_, input) => {
+      const result = validateImageContentType(input);
+      expect(result.isValid).toBe(false);
+      expect(result.error).toContain('Invalid file type');
+    });
+  });
+});

+ 56 - 0
apps/app/src/server/routes/attachment/image-content-type-validator.ts

@@ -0,0 +1,56 @@
+/**
+ * Define supported image MIME types
+ */
+export const SUPPORTED_IMAGE_MIME_TYPES = [
+  'image/png', // Universal web format
+  'image/jpeg', // Universal web format
+  'image/jpg', // Universal web format
+  'image/gif', // Universal web format
+  'image/webp', // Modern efficient format
+  'image/avif', // Next-gen format
+  'image/heic', // iOS format
+  'image/heif', // iOS format
+  'image/tiff', // High quality format
+  'image/svg+xml', // Vector format
+] as const;
+
+// Create a type for supported MIME types
+export type SupportedImageMimeType = typeof SUPPORTED_IMAGE_MIME_TYPES[number];
+
+export interface ImageContentTypeValidatorResult {
+  isValid: boolean;
+  contentType: string | null;
+  error?: string;
+}
+
+/**
+ * Validate and extract content type from MIME type string
+ * @param mimeType MIME type string
+ * @returns Validation result containing isValid flag and extracted content type
+ */
+export const validateImageContentType = (mimeType: string): ImageContentTypeValidatorResult => {
+  if (typeof mimeType !== 'string') {
+    return {
+      isValid: false,
+      contentType: null,
+      error: 'Invalid MIME type format',
+    };
+  }
+
+  const trimmedType = mimeType.trim();
+  const isValid = SUPPORTED_IMAGE_MIME_TYPES.includes(trimmedType as SupportedImageMimeType);
+
+  if (!isValid) {
+    const supportedFormats = 'PNG, JPEG, GIF, WebP, AVIF, HEIC/HEIF, TIFF, SVG';
+    return {
+      isValid: false,
+      contentType: trimmedType,
+      error: `Invalid file type. Supported formats: ${supportedFormats}`,
+    };
+  }
+
+  return {
+    isValid: true,
+    contentType: trimmedType,
+  };
+};