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 api from 'mastodon/api'; | ||||||
| import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; | import { search as emojiSearch } from 'mastodon/features/emoji/emoji_mart_search_light'; | ||||||
| import { tagHistory } from 'mastodon/settings'; | import { tagHistory } from 'mastodon/settings'; | ||||||
| import resizeImage from 'mastodon/utils/resize_image'; |  | ||||||
| import { showAlert, showAlertForError } from './alerts'; | import { showAlert, showAlertForError } from './alerts'; | ||||||
| import { useEmoji } from './emojis'; | import { useEmoji } from './emojis'; | ||||||
| import { importFetchedAccounts, importFetchedStatus } from './importer'; | import { importFetchedAccounts, importFetchedStatus } from './importer'; | ||||||
|  | @ -274,46 +273,42 @@ export function uploadCompose(files) { | ||||||
| 
 | 
 | ||||||
|     dispatch(uploadComposeRequest()); |     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; |       if (media.size + i > 3) break; | ||||||
| 
 | 
 | ||||||
|       resizeImage(f).then(file => { |       const data = new FormData(); | ||||||
|         const data = new FormData(); |       data.append('file', file); | ||||||
|         data.append('file', file); |  | ||||||
|         // Account for disparity in size of original image and resized data
 |  | ||||||
|         total += file.size - f.size; |  | ||||||
| 
 | 
 | ||||||
|         return api(getState).post('/api/v2/media', data, { |       api(getState).post('/api/v2/media', data, { | ||||||
|           onUploadProgress: function({ loaded }){ |         onUploadProgress: function({ loaded }){ | ||||||
|             progress[i] = loaded; |           progress[i] = loaded; | ||||||
|             dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); |           dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total)); | ||||||
|           }, |         }, | ||||||
|         }).then(({ status, data }) => { |       }).then(({ status, data }) => { | ||||||
|           // If server-side processing of the media attachment has not completed yet,
 |         // 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
 |         // poll the server until it is, before showing the media attachment as uploaded
 | ||||||
| 
 | 
 | ||||||
|           if (status === 200) { |         if (status === 200) { | ||||||
|             dispatch(uploadComposeSuccess(data, f)); |           dispatch(uploadComposeSuccess(data, file)); | ||||||
|           } else if (status === 202) { |         } else if (status === 202) { | ||||||
|             dispatch(uploadComposeProcessing()); |           dispatch(uploadComposeProcessing()); | ||||||
| 
 | 
 | ||||||
|             let tryCount = 1; |           let tryCount = 1; | ||||||
| 
 | 
 | ||||||
|             const poll = () => { |           const poll = () => { | ||||||
|               api(getState).get(`/api/v1/media/${data.id}`).then(response => { |             api(getState).get(`/api/v1/media/${data.id}`).then(response => { | ||||||
|                 if (response.status === 200) { |               if (response.status === 200) { | ||||||
|                   dispatch(uploadComposeSuccess(response.data, f)); |                 dispatch(uploadComposeSuccess(response.data, file)); | ||||||
|                 } else if (response.status === 206) { |               } else if (response.status === 206) { | ||||||
|                   const retryAfter = (Math.log2(tryCount) || 1) * 1000; |                 const retryAfter = (Math.log2(tryCount) || 1) * 1000; | ||||||
|                   tryCount += 1; |                 tryCount += 1; | ||||||
|                   setTimeout(() => poll(), retryAfter); |                 setTimeout(() => poll(), retryAfter); | ||||||
|                 } |               } | ||||||
|               }).catch(error => dispatch(uploadComposeFail(error))); |             }).catch(error => dispatch(uploadComposeFail(error))); | ||||||
|             }; |           }; | ||||||
| 
 | 
 | ||||||
|             poll(); |           poll(); | ||||||
|           } |         } | ||||||
|         }); |  | ||||||
|       }).catch(error => dispatch(uploadComposeFail(error))); |       }).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 | module Attachmentable | ||||||
|   extend ActiveSupport::Concern |   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 |   GIF_MATRIX_LIMIT = 921_600    # 1280x720px | ||||||
| 
 | 
 | ||||||
|   # For some file extensions, there exist different content |   # For some file extensions, there exist different content | ||||||
|  |  | ||||||
|  | @ -39,11 +39,11 @@ class MediaAttachment < ApplicationRecord | ||||||
| 
 | 
 | ||||||
|   MAX_DESCRIPTION_LENGTH = 1_500 |   MAX_DESCRIPTION_LENGTH = 1_500 | ||||||
| 
 | 
 | ||||||
|   IMAGE_LIMIT = 10.megabytes |   IMAGE_LIMIT = 16.megabytes | ||||||
|   VIDEO_LIMIT = 40.megabytes |   VIDEO_LIMIT = 99.megabytes | ||||||
| 
 | 
 | ||||||
|   MAX_VIDEO_MATRIX_LIMIT = 2_304_000 # 1920x1200px |   MAX_VIDEO_MATRIX_LIMIT = 8_294_400 # 3840x2160px | ||||||
|   MAX_VIDEO_FRAME_RATE   = 60 |   MAX_VIDEO_FRAME_RATE   = 120 | ||||||
| 
 | 
 | ||||||
|   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze |   IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif .webp .heic .heif .avif).freeze | ||||||
|   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze |   VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze | ||||||
|  | @ -69,7 +69,7 @@ class MediaAttachment < ApplicationRecord | ||||||
| 
 | 
 | ||||||
|   IMAGE_STYLES = { |   IMAGE_STYLES = { | ||||||
|     original: { |     original: { | ||||||
|       pixels: 2_073_600, # 1920x1080px |       pixels: 8_294_400, # 3840x2160px | ||||||
|       file_geometry_parser: FastGeometryParser, |       file_geometry_parser: FastGeometryParser, | ||||||
|     }.freeze, |     }.freeze, | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -36,7 +36,7 @@ class PreviewCard < ApplicationRecord | ||||||
|   include Attachmentable |   include Attachmentable | ||||||
| 
 | 
 | ||||||
|   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze |   IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze | ||||||
|   LIMIT = 1.megabytes |   LIMIT = 2.megabytes | ||||||
| 
 | 
 | ||||||
|   BLURHASH_OPTIONS = { |   BLURHASH_OPTIONS = { | ||||||
|     x_comp: 4, |     x_comp: 4, | ||||||
|  | @ -121,7 +121,7 @@ class PreviewCard < ApplicationRecord | ||||||
|     def image_styles(file) |     def image_styles(file) | ||||||
|       styles = { |       styles = { | ||||||
|         original: { |         original: { | ||||||
|           geometry: '400x400>', |           pixels: 230_400, # 640x360px | ||||||
|           file_geometry_parser: FastGeometryParser, |           file_geometry_parser: FastGeometryParser, | ||||||
|           convert_options: '-coalesce', |           convert_options: '-coalesce', | ||||||
|           blurhash: BLURHASH_OPTIONS, |           blurhash: BLURHASH_OPTIONS, | ||||||
|  |  | ||||||
							
								
								
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							|  | @ -39,7 +39,7 @@ server { | ||||||
| 
 | 
 | ||||||
|   keepalive_timeout    70; |   keepalive_timeout    70; | ||||||
|   sendfile             on; |   sendfile             on; | ||||||
|   client_max_body_size 80m; |   client_max_body_size 99m; | ||||||
| 
 | 
 | ||||||
|   root /home/mastodon/live/public; |   root /home/mastodon/live/public; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -56,7 +56,6 @@ | ||||||
|     "emoji-mart": "npm:emoji-mart-lazyload@latest", |     "emoji-mart": "npm:emoji-mart-lazyload@latest", | ||||||
|     "es6-symbol": "^3.1.3", |     "es6-symbol": "^3.1.3", | ||||||
|     "escape-html": "^1.0.3", |     "escape-html": "^1.0.3", | ||||||
|     "exif-js": "^2.3.0", |  | ||||||
|     "express": "^4.18.2", |     "express": "^4.18.2", | ||||||
|     "file-loader": "^6.2.0", |     "file-loader": "^6.2.0", | ||||||
|     "font-awesome": "^4.7.0", |     "font-awesome": "^4.7.0", | ||||||
|  |  | ||||||
|  | @ -44,12 +44,4 @@ RSpec.describe Settings::ProfilesController, type: :controller do | ||||||
|       expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) |       expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   describe 'PUT #update with oversized image' do |  | ||||||
|     it 'gives the user an error message' do |  | ||||||
|       allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_async) |  | ||||||
|       put :update, params: { account: { avatar: fixture_file_upload('4096x4097.png', 'image/png') } } |  | ||||||
|       expect(response.body).to include('images are not supported') |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -4821,11 +4821,6 @@ execa@^5.0.0: | ||||||
|     signal-exit "^3.0.3" |     signal-exit "^3.0.3" | ||||||
|     strip-final-newline "^2.0.0" |     strip-final-newline "^2.0.0" | ||||||
| 
 | 
 | ||||||
| exif-js@^2.3.0: |  | ||||||
|   version "2.3.0" |  | ||||||
|   resolved "https://registry.yarnpkg.com/exif-js/-/exif-js-2.3.0.tgz#9d10819bf571f873813e7640241255ab9ce1a814" |  | ||||||
|   integrity sha1-nRCBm/Vx+HOBPnZAJBJVq5zhqBQ= |  | ||||||
| 
 |  | ||||||
| exit@^0.1.2: | exit@^0.1.2: | ||||||
|   version "0.1.2" |   version "0.1.2" | ||||||
|   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" |   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue