Change media upload limits and remove client-side resizing (#23726)
This commit is contained in:
		@@ -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,
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								dist/nginx.conf
									
									
									
									
										vendored
									
									
								
							@@ -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;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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",
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user