Change media upload limits and remove client-side resizing (#23726)
This commit is contained in:
		
							parent
							
								
									ef127c964a
								
							
						
					
					
						commit
						9bda933740
					
				
					 9 changed files with 38 additions and 246 deletions
				
			
		|  | @ -4,7 +4,6 @@ import { defineMessages } from 'react-intl'; | |||
| import api from 'mastodon/api'; | ||||
| import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; | ||||
| import { tagHistory } from 'mastodon/settings'; | ||||
| import resizeImage from 'mastodon/utils/resize_image'; | ||||
| import { showAlert, showAlertForError } from './alerts'; | ||||
| import { useEmoji } from './emojis'; | ||||
| import { importFetchedAccounts, importFetchedStatus } from './importer'; | ||||
|  | @ -274,46 +273,42 @@ export function uploadCompose(files) { | |||
| 
 | ||||
|     dispatch(uploadComposeRequest()); | ||||
| 
 | ||||
|     for (const [i, f] of Array.from(files).entries()) { | ||||
|     for (const [i, file] of Array.from(files).entries()) { | ||||
|       if (media.size + i > 3) break; | ||||
| 
 | ||||
|       resizeImage(f).then(file => { | ||||
|         const data = new FormData(); | ||||
|         data.append('file', file); | ||||
|         // Account for disparity in size of original image and resized data
 | ||||
|         total += file.size - f.size; | ||||
|       const data = new FormData(); | ||||
|       data.append('file', file); | ||||
| 
 | ||||
|         return api(getState).post('/api/v2/media', data, { | ||||
|           onUploadProgress: function({ loaded }){ | ||||
|             progress[i] = loaded; | ||||
|             dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); | ||||
|           }, | ||||
|         }).then(({ status, data }) => { | ||||
|           // If server-side processing of the media attachment has not completed yet,
 | ||||
|           // poll the server until it is, before showing the media attachment as uploaded
 | ||||
|       api(getState).post('/api/v2/media', data, { | ||||
|         onUploadProgress: function({ loaded }){ | ||||
|           progress[i] = loaded; | ||||
|           dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); | ||||
|         }, | ||||
|       }).then(({ status, data }) => { | ||||
|         // If server-side processing of the media attachment has not completed yet,
 | ||||
|         // poll the server until it is, before showing the media attachment as uploaded
 | ||||
| 
 | ||||
|           if (status === 200) { | ||||
|             dispatch(uploadComposeSuccess(data, f)); | ||||
|           } else if (status === 202) { | ||||
|             dispatch(uploadComposeProcessing()); | ||||
|         if (status === 200) { | ||||
|           dispatch(uploadComposeSuccess(data, file)); | ||||
|         } else if (status === 202) { | ||||
|           dispatch(uploadComposeProcessing()); | ||||
| 
 | ||||
|             let tryCount = 1; | ||||
|           let tryCount = 1; | ||||
| 
 | ||||
|             const poll = () => { | ||||
|               api(getState).get(`/api/v1/media/${data.id}`).then(response => { | ||||
|                 if (response.status === 200) { | ||||
|                   dispatch(uploadComposeSuccess(response.data, f)); | ||||
|                 } else if (response.status === 206) { | ||||
|                   const retryAfter = (Math.log2(tryCount) || 1) * 1000; | ||||
|                   tryCount += 1; | ||||
|                   setTimeout(() => poll(), retryAfter); | ||||
|                 } | ||||
|               }).catch(error => dispatch(uploadComposeFail(error))); | ||||
|             }; | ||||
|           const poll = () => { | ||||
|             api(getState).get(`/api/v1/media/${data.id}`).then(response => { | ||||
|               if (response.status === 200) { | ||||
|                 dispatch(uploadComposeSuccess(response.data, file)); | ||||
|               } else if (response.status === 206) { | ||||
|                 const retryAfter = (Math.log2(tryCount) || 1) * 1000; | ||||
|                 tryCount += 1; | ||||
|                 setTimeout(() => poll(), retryAfter); | ||||
|               } | ||||
|             }).catch(error => dispatch(uploadComposeFail(error))); | ||||
|           }; | ||||
| 
 | ||||
|             poll(); | ||||
|           } | ||||
|         }); | ||||
|           poll(); | ||||
|         } | ||||
|       }).catch(error => dispatch(uploadComposeFail(error))); | ||||
|     } | ||||
|   }; | ||||
|  |  | |||
|  | @ -1,189 +0,0 @@ | |||
| import EXIF from 'exif-js'; | ||||
| 
 | ||||
| const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px
 | ||||
| 
 | ||||
| const _browser_quirks = {}; | ||||
| 
 | ||||
| // Some browsers will automatically draw images respecting their EXIF orientation
 | ||||
| // while others won't, and the safest way to detect that is to examine how it
 | ||||
| // is done on a known image.
 | ||||
| // See https://github.com/w3c/csswg-drafts/issues/4666
 | ||||
| // and https://github.com/blueimp/JavaScript-Load-Image/commit/1e4df707821a0afcc11ea0720ee403b8759f3881
 | ||||
| const dropOrientationIfNeeded = (orientation) => new Promise(resolve => { | ||||
|   switch (_browser_quirks['image-orientation-automatic']) { | ||||
|   case true: | ||||
|     resolve(1); | ||||
|     break; | ||||
|   case false: | ||||
|     resolve(orientation); | ||||
|     break; | ||||
|   default: | ||||
|     // black 2x1 JPEG, with the following meta information set:
 | ||||
|     // - EXIF Orientation: 6 (Rotated 90° CCW)
 | ||||
|     const testImageURL = | ||||
|       'data:image/jpeg;base64,/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAA' + | ||||
|       'AAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA' + | ||||
|       'QEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE' + | ||||
|       'BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAf/AABEIAAEAAgMBEQACEQEDEQH/x' + | ||||
|       'ABKAAEAAAAAAAAAAAAAAAAAAAALEAEAAAAAAAAAAAAAAAAAAAAAAQEAAAAAAAAAAAAAAAA' + | ||||
|       'AAAAAEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8H//2Q=='; | ||||
|     const img = new Image(); | ||||
|     img.onload = () => { | ||||
|       const automatic = (img.width === 1 && img.height === 2); | ||||
|       _browser_quirks['image-orientation-automatic'] = automatic; | ||||
|       resolve(automatic ? 1 : orientation); | ||||
|     }; | ||||
|     img.onerror = () => { | ||||
|       _browser_quirks['image-orientation-automatic'] = false; | ||||
|       resolve(orientation); | ||||
|     }; | ||||
|     img.src = testImageURL; | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // Some browsers don't allow reading from a canvas and instead return all-white
 | ||||
| // or randomized data. Use a pre-defined image to check if reading the canvas
 | ||||
| // works.
 | ||||
| const checkCanvasReliability = () => new Promise((resolve, reject) => { | ||||
|   switch(_browser_quirks['canvas-read-unreliable']) { | ||||
|   case true: | ||||
|     reject('Canvas reading unreliable'); | ||||
|     break; | ||||
|   case false: | ||||
|     resolve(); | ||||
|     break; | ||||
|   default: | ||||
|     // 2×2 GIF with white, red, green and blue pixels
 | ||||
|     const testImageURL = | ||||
|       'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7'; | ||||
|     const refData = | ||||
|       [255, 255, 255, 255,  255, 0, 0, 255,  0, 255, 0, 255,  0, 0, 255, 255]; | ||||
|     const img = new Image(); | ||||
|     img.onload = () => { | ||||
|       const canvas  = document.createElement('canvas'); | ||||
|       const context = canvas.getContext('2d'); | ||||
|       context.drawImage(img, 0, 0, 2, 2); | ||||
|       const imageData = context.getImageData(0, 0, 2, 2); | ||||
|       if (imageData.data.every((x, i) => refData[i] === x)) { | ||||
|         _browser_quirks['canvas-read-unreliable'] = false; | ||||
|         resolve(); | ||||
|       } else { | ||||
|         _browser_quirks['canvas-read-unreliable'] = true; | ||||
|         reject('Canvas reading unreliable'); | ||||
|       } | ||||
|     }; | ||||
|     img.onerror = () => { | ||||
|       _browser_quirks['canvas-read-unreliable'] = true; | ||||
|       reject('Failed to load test image'); | ||||
|     }; | ||||
|     img.src = testImageURL; | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| const getImageUrl = inputFile => new Promise((resolve, reject) => { | ||||
|   if (window.URL && URL.createObjectURL) { | ||||
|     try { | ||||
|       resolve(URL.createObjectURL(inputFile)); | ||||
|     } catch (error) { | ||||
|       reject(error); | ||||
|     } | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const reader = new FileReader(); | ||||
|   reader.onerror = (...args) => reject(...args); | ||||
|   reader.onload  = ({ target }) => resolve(target.result); | ||||
| 
 | ||||
|   reader.readAsDataURL(inputFile); | ||||
| }); | ||||
| 
 | ||||
| const loadImage = inputFile => new Promise((resolve, reject) => { | ||||
|   getImageUrl(inputFile).then(url => { | ||||
|     const img = new Image(); | ||||
| 
 | ||||
|     img.onerror = (...args) => reject(...args); | ||||
|     img.onload  = () => resolve(img); | ||||
| 
 | ||||
|     img.src = url; | ||||
|   }).catch(reject); | ||||
| }); | ||||
| 
 | ||||
| const getOrientation = (img, type = 'image/png') => new Promise(resolve => { | ||||
|   if (!['image/jpeg', 'image/webp'].includes(type)) { | ||||
|     resolve(1); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   EXIF.getData(img, () => { | ||||
|     const orientation = EXIF.getTag(img, 'Orientation'); | ||||
|     if (orientation !== 1) { | ||||
|       dropOrientationIfNeeded(orientation).then(resolve).catch(() => resolve(orientation)); | ||||
|     } else { | ||||
|       resolve(orientation); | ||||
|     } | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| const processImage = (img, { width, height, orientation, type = 'image/png' }) => new Promise(resolve => { | ||||
|   const canvas  = document.createElement('canvas'); | ||||
| 
 | ||||
|   if (4 < orientation && orientation < 9) { | ||||
|     canvas.width  = height; | ||||
|     canvas.height = width; | ||||
|   } else { | ||||
|     canvas.width  = width; | ||||
|     canvas.height = height; | ||||
|   } | ||||
| 
 | ||||
|   const context = canvas.getContext('2d'); | ||||
| 
 | ||||
|   switch (orientation) { | ||||
|   case 2: context.transform(-1, 0, 0, 1, width, 0); break; | ||||
|   case 3: context.transform(-1, 0, 0, -1, width, height); break; | ||||
|   case 4: context.transform(1, 0, 0, -1, 0, height); break; | ||||
|   case 5: context.transform(0, 1, 1, 0, 0, 0); break; | ||||
|   case 6: context.transform(0, 1, -1, 0, height, 0); break; | ||||
|   case 7: context.transform(0, -1, -1, 0, height, width); break; | ||||
|   case 8: context.transform(0, -1, 1, 0, 0, width); break; | ||||
|   } | ||||
| 
 | ||||
|   context.drawImage(img, 0, 0, width, height); | ||||
| 
 | ||||
|   canvas.toBlob(resolve, type); | ||||
| }); | ||||
| 
 | ||||
| const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) => { | ||||
|   const { width, height } = img; | ||||
| 
 | ||||
|   const newWidth  = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height))); | ||||
|   const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width))); | ||||
| 
 | ||||
|   checkCanvasReliability() | ||||
|     .then(getOrientation(img, type)) | ||||
|     .then(orientation => processImage(img, { | ||||
|       width: newWidth, | ||||
|       height: newHeight, | ||||
|       orientation, | ||||
|       type, | ||||
|     })) | ||||
|     .then(resolve) | ||||
|     .catch(reject); | ||||
| }); | ||||
| 
 | ||||
| export default inputFile => new Promise((resolve) => { | ||||
|   if (!inputFile.type.match(/image.*/) || inputFile.type === 'image/gif') { | ||||
|     resolve(inputFile); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   loadImage(inputFile).then(img => { | ||||
|     if (img.width * img.height < MAX_IMAGE_PIXELS) { | ||||
|       resolve(inputFile); | ||||
|       return; | ||||
|     } | ||||
| 
 | ||||
|     resizeImage(img, inputFile.type) | ||||
|       .then(resolve) | ||||
|       .catch(() => resolve(inputFile)); | ||||
|   }).catch(() => resolve(inputFile)); | ||||
| }); | ||||
|  | @ -5,7 +5,7 @@ require 'mime/types/columnar' | |||
| module Attachmentable | ||||
|   extend ActiveSupport::Concern | ||||
| 
 | ||||
|   MAX_MATRIX_LIMIT = 16_777_216 # 4096x4096px or approx. 16MB | ||||
|   MAX_MATRIX_LIMIT = 33_177_600 # 7680x4320px or approx. 847MB in RAM | ||||
|   GIF_MATRIX_LIMIT = 921_600    # 1280x720px | ||||
| 
 | ||||
|   # For some file extensions, there exist different content | ||||
|  |  | |||
|  | @ -39,11 +39,11 @@ class MediaAttachment < ApplicationRecord | |||
| 
 | ||||
|   MAX_DESCRIPTION_LENGTH = 1_500 | ||||
| 
 | ||||
|   IMAGE_LIMIT = 10.megabytes | ||||
|   VIDEO_LIMIT = 40.megabytes | ||||
|   IMAGE_LIMIT = 16.megabytes | ||||
|   VIDEO_LIMIT = 99.megabytes | ||||
| 
 | ||||
|   MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px | ||||
|   MAX_VIDEO_FRAME_RATE   = 60 | ||||
|   MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px | ||||
|   MAX_VIDEO_FRAME_RATE   = 120 | ||||
| 
 | ||||
|   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze | ||||
|   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze | ||||
|  | @ -69,7 +69,7 @@ class MediaAttachment < ApplicationRecord | |||
| 
 | ||||
|   IMAGE_STYLES = { | ||||
|     original: { | ||||
|       pixels: 2_073_600, # 1920x1080px | ||||
|       pixels: 8_294_400, # 3840x2160px | ||||
|       file_geometry_parser: FastGeometryParser, | ||||
|     }.freeze, | ||||
| 
 | ||||
|  |  | |||
|  | @ -36,7 +36,7 @@ class PreviewCard < ApplicationRecord | |||
|   include Attachmentable | ||||
| 
 | ||||
|   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze | ||||
|   LIMIT = 1.megabytes | ||||
|   LIMIT = 2.megabytes | ||||
| 
 | ||||
|   BLURHASH_OPTIONS = { | ||||
|     x_comp: 4, | ||||
|  | @ -121,7 +121,7 @@ class PreviewCard < ApplicationRecord | |||
|     def image_styles(file) | ||||
|       styles = { | ||||
|         original: { | ||||
|           geometry: '400x400>', | ||||
|           pixels: 230_400, # 640x360px | ||||
|           file_geometry_parser: FastGeometryParser, | ||||
|           convert_options: '-coalesce', | ||||
|           blurhash: BLURHASH_OPTIONS, | ||||
|  |  | |||
		Reference in a new issue