From 9bda93374093c738f1007922b2e8df58043c718f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 25 Mar 2023 10:00:03 +0100 Subject: [PATCH] Change media upload limits and remove client-side resizing (#23726) --- app/javascript/mastodon/actions/compose.js | 73 ++++--- app/javascript/mastodon/utils/resize_image.js | 189 ------------------ app/models/concerns/attachmentable.rb | 2 +- app/models/media_attachment.rb | 10 +- app/models/preview_card.rb | 4 +- dist/nginx.conf | 2 +- package.json | 1 - .../settings/profiles_controller_spec.rb | 8 - yarn.lock | 5 - 9 files changed, 43 insertions(+), 251 deletions(-) delete mode 100644 app/javascript/mastodon/utils/resize_image.js diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 3756a975b..961503287 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -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; - - 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 - - if (status === 200) { - dispatch(uploadComposeSuccess(data, f)); - } else if (status === 202) { - dispatch(uploadComposeProcessing()); - - 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))); - }; - - poll(); - } - }); + const data = new FormData(); + data.append('file', file); + + 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, file)); + } else if (status === 202) { + dispatch(uploadComposeProcessing()); + + let tryCount = 1; + + 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(); + } }).catch(error => dispatch(uploadComposeFail(error))); } }; diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js deleted file mode 100644 index fb8c3c11e..000000000 --- a/app/javascript/mastodon/utils/resize_image.js +++ /dev/null @@ -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)); -}); diff --git a/app/models/concerns/attachmentable.rb b/app/models/concerns/attachmentable.rb index 01fae4236..d44c22438 100644 --- a/app/models/concerns/attachmentable.rb +++ b/app/models/concerns/attachmentable.rb @@ -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 diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index 08abd4e43..e51e13b95 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -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, diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 6bce16562..a738940be 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -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, diff --git a/dist/nginx.conf b/dist/nginx.conf index 5bc960e25..bed4bd3db 100644 --- a/dist/nginx.conf +++ b/dist/nginx.conf @@ -39,7 +39,7 @@ server { keepalive_timeout 70; sendfile on; - client_max_body_size 80m; + client_max_body_size 99m; root /home/mastodon/live/public; diff --git a/package.json b/package.json index 045db8d14..90d76001b 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "emoji-mart": "npm:emoji-mart-lazyload@latest", "es6-symbol": "^3.1.3", "escape-html": "^1.0.3", - "exif-js": "^2.3.0", "express": "^4.18.2", "file-loader": "^6.2.0", "font-awesome": "^4.7.0", diff --git a/spec/controllers/settings/profiles_controller_spec.rb b/spec/controllers/settings/profiles_controller_spec.rb index e45596b1a..563e60271 100644 --- a/spec/controllers/settings/profiles_controller_spec.rb +++ b/spec/controllers/settings/profiles_controller_spec.rb @@ -44,12 +44,4 @@ RSpec.describe Settings::ProfilesController, type: :controller do expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_async).with(account.id) 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 diff --git a/yarn.lock b/yarn.lock index d2be342c9..0dc40b469 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4821,11 +4821,6 @@ execa@^5.0.0: signal-exit "^3.0.3" 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: version "0.1.2" resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"