about summary refs log tree commit diff
diff options
context:
space:
mode:
authorimncls <himasoto@gmail.com>2018-02-23 23:28:31 +0900
committerimncls <himasoto@gmail.com>2018-02-23 23:28:31 +0900
commitbb6988a7ac319c08776d8c07204232a67f992671 (patch)
tree4a53349374bc4a871a6ce74b786a8e747803155c
parent544543e40a723d9719f5571bd4c3455a6a69fccd (diff)
parente668180044560e28bdc5eef94744c210013efcda (diff)
Merge branch 'master' of https://github.com/tootsuite/mastodon
# Conflicts:
#	app/controllers/settings/exports_controller.rb
#	app/models/media_attachment.rb
#	app/models/status.rb
#	app/views/about/show.html.haml
#	docker_entrypoint.sh
#	spec/views/about/show.html.haml_spec.rb
-rw-r--r--.env.production.sample13
-rw-r--r--Dockerfile16
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock4
-rw-r--r--app/controllers/admin/settings_controller.rb2
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/media_controller.rb2
-rw-r--r--app/controllers/application_controller.rb2
-rw-r--r--app/controllers/auth/sessions_controller.rb9
-rw-r--r--app/controllers/settings/exports_controller.rb12
-rw-r--r--app/javascript/images/icon_file_download.svg4
-rw-r--r--app/javascript/images/mailer/icon_file_download.pngbin0 -> 271 bytes
-rw-r--r--app/javascript/images/reticle.pngbin0 -> 3053 bytes
-rw-r--r--app/javascript/mastodon/actions/compose.js4
-rw-r--r--app/javascript/mastodon/components/media_gallery.js71
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js20
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_container.js7
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.js122
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js2
-rw-r--r--app/javascript/mastodon/features/video/index.js6
-rw-r--r--app/javascript/mastodon/reducers/compose.js8
-rw-r--r--app/javascript/styles/mastodon/about.scss637
-rw-r--r--app/javascript/styles/mastodon/components.scss96
-rw-r--r--app/javascript/styles/mastodon/containers.scss2
-rw-r--r--app/lib/activitypub/activity/create.rb2
-rw-r--r--app/lib/activitypub/adapter.rb1
-rw-r--r--app/lib/fast_geometry_parser.rb11
-rw-r--r--app/mailers/user_mailer.rb12
-rw-r--r--app/models/backup.rb22
-rw-r--r--app/models/concerns/account_avatar.rb12
-rw-r--r--app/models/concerns/account_header.rb12
-rw-r--r--app/models/concerns/omniauthable.rb7
-rw-r--r--app/models/media_attachment.rb48
-rw-r--r--app/models/preview_card.rb13
-rw-r--r--app/models/site_upload.rb4
-rw-r--r--app/models/status.rb2
-rw-r--r--app/models/user.rb1
-rw-r--r--app/policies/application_policy.rb4
-rw-r--r--app/policies/backup_policy.rb9
-rw-r--r--app/presenters/account_relationships_presenter.rb2
-rw-r--r--app/presenters/instance_presenter.rb4
-rw-r--r--app/serializers/activitypub/collection_serializer.rb4
-rw-r--r--app/serializers/activitypub/image_serializer.rb9
-rw-r--r--app/services/backup_service.rb128
-rw-r--r--app/views/about/_forms.html.haml14
-rw-r--r--app/views/about/_links.html.haml2
-rw-r--r--app/views/about/_registration.html.haml2
-rw-r--r--app/views/about/more.html.haml16
-rw-r--r--app/views/about/show.html.haml116
-rw-r--r--app/views/about/terms.html.haml2
-rw-r--r--app/views/admin/settings/edit.html.haml1
-rw-r--r--app/views/layouts/auth.html.haml2
-rw-r--r--app/views/layouts/modal.html.haml2
-rw-r--r--app/views/layouts/public.html.haml2
-rw-r--r--app/views/settings/exports/show.html.haml23
-rw-r--r--app/views/user_mailer/backup_ready.html.haml59
-rw-r--r--app/views/user_mailer/backup_ready.text.erb7
-rw-r--r--app/workers/backup_worker.rb17
-rw-r--r--app/workers/scheduler/backup_cleanup_scheduler.rb16
-rw-r--r--config/application.rb1
-rw-r--r--config/initializers/chewy.rb1
-rw-r--r--config/initializers/omniauth.rb11
-rw-r--r--config/locales/ar.yml1
-rw-r--r--config/locales/ca.yml1
-rw-r--r--config/locales/de.yml1
-rw-r--r--config/locales/en.yml15
-rw-r--r--config/locales/es.yml1
-rw-r--r--config/locales/fa.yml1
-rw-r--r--config/locales/fi.yml1
-rw-r--r--config/locales/fr.yml1
-rw-r--r--config/locales/gl.yml1
-rw-r--r--config/locales/he.yml1
-rw-r--r--config/locales/hu.yml1
-rw-r--r--config/locales/ja.yml1
-rw-r--r--config/locales/ko.yml1
-rw-r--r--config/locales/nl.yml1
-rw-r--r--config/locales/no.yml1
-rw-r--r--config/locales/oc.yml1
-rw-r--r--config/locales/pl.yml15
-rw-r--r--config/locales/pt-BR.yml1
-rw-r--r--config/locales/pt.yml1
-rw-r--r--config/locales/ru.yml1
-rw-r--r--config/locales/sk.yml1
-rw-r--r--config/locales/sr-Latn.yml1
-rw-r--r--config/locales/sr.yml1
-rw-r--r--config/locales/sv.yml1
-rw-r--r--config/locales/zh-CN.yml1
-rw-r--r--config/routes.rb2
-rw-r--r--config/sidekiq.yml3
-rw-r--r--db/migrate/20180211015820_create_backups.rb11
-rw-r--r--db/schema.rb14
-rw-r--r--docker_entrypoint.sh14
-rw-r--r--lib/paperclip/gif_transcoder.rb2
-rw-r--r--lib/paperclip/lazy_thumbnail.rb24
-rw-r--r--lib/tasks/mastodon.rake2
-rw-r--r--spec/controllers/api/v1/accounts/relationships_controller_spec.rb22
-rw-r--r--spec/fabricators/backup_fabricator.rb3
-rw-r--r--spec/mailers/previews/user_mailer_preview.rb5
-rw-r--r--spec/models/backup_spec.rb5
-rw-r--r--spec/views/about/show.html.haml_spec.rb3
100 files changed, 1390 insertions, 417 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 54d62d672..d46768d09 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -144,14 +144,22 @@ STREAMING_CLUSTER_NUM=1
 # MAX_TOOT_CHARS=500
 
 # PAM authentication (optional)
+# PAM authentication uses for the email generation the "email" pam variable
+# and optional as fallback PAM_DEFAULT_SUFFIX
+# The pam environment variable "email" is provided by:
+# https://github.com/devkral/pam_email_extractor
 # PAM_ENABLED=true
-# Suffix for email address generation (nil by default)
+# Fallback Suffix for email address generation (nil by default)
 # PAM_DEFAULT_SUFFIX=pam
 # Name of the pam service (pam "auth" section is evaluated)
 # PAM_DEFAULT_SERVICE=rpam
 # Name of the pam service used for checking if an user can register (pam "account" section is evaluated)
 # PAM_CONTROLLED_SERVICE=rpam
 
+# Global OAuth settings (optional) :
+# If you have only one strategy, you may want to enable this
+# OAUTH_REDIRECT_AT_SIGN_IN=true
+
 # Optional CAS authentication (cf. omniauth-cas) :
 # CAS_ENABLED=true
 # CAS_URL=https://sso.myserver.com/
@@ -187,7 +195,10 @@ STREAMING_CLUSTER_NUM=1
 # SAML_PRIVATE_KEY=
 # SAML_SECURITY_WANT_ASSERTION_SIGNED=true
 # SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
+# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
 # SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
 # SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
 # SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42"
 # SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
+# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
+# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
diff --git a/Dockerfile b/Dockerfile
index 6d8465ddc..a50122057 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,8 +3,10 @@ FROM ruby:2.5.0-alpine3.7
 LABEL maintainer="https://github.com/tootsuite/mastodon" \
       description="A GNU Social-compatible microblogging server"
 
-ENV UID=991 GID=991 \
-    RAILS_SERVE_STATIC_FILES=true \
+ARG UID=991
+ARG GID=991
+
+ENV RAILS_SERVE_STATIC_FILES=true \
     RAILS_ENV=production NODE_ENV=production
 
 ARG YARN_VERSION=1.3.2
@@ -71,12 +73,12 @@ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-in
  && yarn --pure-lockfile \
  && yarn cache clean
 
-COPY . /mastodon
-
-COPY docker_entrypoint.sh /usr/local/bin/run
+RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon
 
-RUN chmod +x /usr/local/bin/run
+COPY --chown=mastodon:mastodon . /mastodon
 
 VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs
 
-ENTRYPOINT ["/usr/local/bin/run"]
+USER mastodon
+
+ENTRYPOINT ["/sbin/tini", "--"]
diff --git a/Gemfile b/Gemfile
index 33f9374cf..b6962861f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -41,6 +41,7 @@ gem 'omniauth', '~> 1.2'
 
 gem 'doorkeeper', '~> 4.2'
 gem 'fast_blank', '~> 1.0'
+gem 'fastimage'
 gem 'goldfinger', '~> 2.1'
 gem 'hiredis', '~> 0.6'
 gem 'redis-namespace', '~> 1.5'
@@ -117,6 +118,7 @@ group :development do
   gem 'bullet', '~> 5.5'
   gem 'letter_opener', '~> 1.4'
   gem 'letter_opener_web', '~> 1.3'
+  gem 'memory_profiler'
   gem 'rubocop', require: false
   gem 'brakeman', '~> 4.0', require: false
   gem 'bundler-audit', '~> 0.6', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index d4ebd0a40..1905cf3e1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -185,6 +185,7 @@ GEM
     faraday (0.14.0)
       multipart-post (>= 1.2, < 3)
     fast_blank (1.0.0)
+    fastimage (2.1.1)
     ffi (1.9.18)
     fog-core (1.45.0)
       builder
@@ -302,6 +303,7 @@ GEM
       mini_mime (>= 0.1.1)
     mario-redis-lock (1.2.0)
       redis (~> 3, >= 3.0.5)
+    memory_profiler (0.9.10)
     method_source (0.9.0)
     microformats (4.0.7)
       json
@@ -644,6 +646,7 @@ DEPENDENCIES
   fabrication (~> 2.18)
   faker (~> 1.7)
   fast_blank (~> 1.0)
+  fastimage
   fog-core (~> 1.45)
   fog-local (~> 0.4)
   fog-openstack (~> 0.1)
@@ -666,6 +669,7 @@ DEPENDENCIES
   link_header (~> 0.0)
   lograge (~> 0.7)
   mario-redis-lock (~> 1.2)
+  memory_profiler
   microformats (~> 4.0)
   mime-types (~> 3.1)
   nokogiri (~> 1.8)
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index a6214dc3f..ce3208209 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -16,6 +16,7 @@ module Admin
       show_staff_badge
       bootstrap_timeline_accounts
       thumbnail
+      hero
       min_invite_role
       activity_api_enabled
       peers_api_enabled
@@ -34,6 +35,7 @@ module Admin
 
     UPLOAD_SETTINGS = %w(
       thumbnail
+      hero
     ).freeze
 
     def edit
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index 6cc3da498..70236d1a8 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   end
 
   def account_ids
-    @_account_ids ||= Array(params[:id]).map(&:to_i)
+    Array(params[:id]).map(&:to_i)
   end
 end
diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb
index 9f330f0df..d4e6337e7 100644
--- a/app/controllers/api/v1/media_controller.rb
+++ b/app/controllers/api/v1/media_controller.rb
@@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
   private
 
   def media_params
-    params.permit(:file, :description)
+    params.permit(:file, :description, :focus)
   end
 
   def file_type_error
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7534b5375..a296d96db 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
   end
 
   def store_current_location
-    store_location_for(:user, request.url)
+    store_location_for(:user, request.url) unless request.format == :json
   end
 
   def require_admin!
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index ce9cf98d7..475cd540a 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -11,6 +11,15 @@ class Auth::SessionsController < Devise::SessionsController
   prepend_before_action :set_pack
   before_action :set_instance_presenter, only: [:new]
 
+  def new
+    Devise.omniauth_configs.each do |provider, config|
+      if config.strategy.redirect_at_sign_in
+        return redirect_to(omniauth_authorize_path(resource_name, provider))
+      end
+    end
+    super
+  end
+
   def create
     super do |resource|
       remember_me(resource)
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index 9c03ece86..6369a3aeb 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -2,6 +2,16 @@
 
 class Settings::ExportsController < Settings::BaseController
   def show
-    @export = Export.new(current_account)
+    @export  = Export.new(current_account)
+    @backups = current_user.backups
+  end
+
+  def create
+    authorize :backup, :create?
+
+    backup = current_user.backups.create!
+    BackupWorker.perform_async(backup.id)
+
+    redirect_to settings_export_path
   end
 end
diff --git a/app/javascript/images/icon_file_download.svg b/app/javascript/images/icon_file_download.svg
new file mode 100644
index 000000000..53e97e4f8
--- /dev/null
+++ b/app/javascript/images/icon_file_download.svg
@@ -0,0 +1,4 @@
+<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
+    <path d="M0 0h24v24H0z" fill="none"/>
+</svg>
\ No newline at end of file
diff --git a/app/javascript/images/mailer/icon_file_download.png b/app/javascript/images/mailer/icon_file_download.png
new file mode 100644
index 000000000..8a6a8673b
--- /dev/null
+++ b/app/javascript/images/mailer/icon_file_download.png
Binary files differdiff --git a/app/javascript/images/reticle.png b/app/javascript/images/reticle.png
new file mode 100644
index 000000000..998994f5c
--- /dev/null
+++ b/app/javascript/images/reticle.png
Binary files differdiff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 8a35049b3..1732ff189 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -178,11 +178,11 @@ export function uploadCompose(files) {
   };
 };
 
-export function changeUploadCompose(id, description) {
+export function changeUploadCompose(id, params) {
   return (dispatch, getState) => {
     dispatch(changeUploadComposeRequest());
 
-    api(getState).put(`/api/v1/media/${id}`, { description }).then(response => {
+    api(getState).put(`/api/v1/media/${id}`, params).then(response => {
       dispatch(changeUploadComposeSuccess(response.data));
     }).catch(error => {
       dispatch(changeUploadComposeFail(id, error));
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index a3ffc45ea..9e1bb77c2 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -12,6 +12,26 @@ const messages = defineMessages({
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 });
 
+const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => {
+  const containerCenter = Math.floor(containerSize / 2);
+  const focusFactor     = (focusSize + 1) / 2;
+  const scaledImage     = Math.floor(imageSize / containerToImageRatio);
+
+  let focus = Math.floor(focusFactor * scaledImage);
+
+  if (toMinus) focus = scaledImage - focus;
+
+  let focusOffset = focus - containerCenter;
+
+  const remainder = scaledImage - focus;
+  const containerRemainder = containerSize - containerCenter;
+
+  if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder;
+  if (focusOffset < 0) focusOffset = 0;
+
+  return (focusOffset * -100 / containerSize) + '%';
+};
+
 class Item extends React.PureComponent {
 
   static contextTypes = {
@@ -24,6 +44,8 @@ class Item extends React.PureComponent {
     index: PropTypes.number.isRequired,
     size: PropTypes.number.isRequired,
     onClick: PropTypes.func.isRequired,
+    containerWidth: PropTypes.number,
+    containerHeight: PropTypes.number,
   };
 
   static defaultProps = {
@@ -62,7 +84,7 @@ class Item extends React.PureComponent {
   }
 
   render () {
-    const { attachment, index, size, standalone } = this.props;
+    const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
 
     let width  = 50;
     let height = 100;
@@ -116,16 +138,40 @@ class Item extends React.PureComponent {
     let thumbnail = '';
 
     if (attachment.get('type') === 'image') {
-      const previewUrl = attachment.get('preview_url');
+      const previewUrl   = attachment.get('preview_url');
       const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 
-      const originalUrl = attachment.get('url');
-      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
+      const originalUrl    = attachment.get('url');
+      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
+      const originalHeight = attachment.getIn(['meta', 'original', 'height']);
 
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
 
       const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
-      const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+      const sizes  = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
+
+      const focusX     = attachment.getIn(['meta', 'focus', 'x']);
+      const focusY     = attachment.getIn(['meta', 'focus', 'y']);
+      const imageStyle = {};
+
+      if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) {
+        const widthRatio  = originalWidth / (containerWidth * (width / 100));
+        const heightRatio = originalHeight / (containerHeight * (height / 100));
+
+        let hShift = 0;
+        let vShift = 0;
+
+        if (widthRatio > heightRatio) {
+          hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX);
+        } else if(widthRatio < heightRatio) {
+          vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true);
+        }
+
+        imageStyle.top  = vShift;
+        imageStyle.left = hShift;
+      } else {
+        imageStyle.height = '100%';
+      }
 
       thumbnail = (
         <a
@@ -134,7 +180,14 @@ class Item extends React.PureComponent {
           onClick={this.handleClick}
           target='_blank'
         >
-          <img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} />
+          <img
+            src={previewUrl}
+            srcSet={srcSet}
+            sizes={sizes}
+            alt={attachment.get('description')}
+            title={attachment.get('description')}
+            style={imageStyle}
+          />
         </a>
       );
     } else if (attachment.get('type') === 'gifv') {
@@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
   }
 
   handleRef = (node) => {
-    if (node && this.isStandaloneEligible()) {
+    if (node /*&& this.isStandaloneEligible()*/) {
       // offsetWidth triggers a layout, so only calculate when we need to
       this.setState({
         width: node.offsetWidth,
@@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
       if (this.isStandaloneEligible()) {
         children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
       } else {
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
+        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />);
       }
     }
 
     return (
-      <div className='media-gallery' style={style}>
+      <div className='media-gallery' style={style} ref={this.handleRef}>
         <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
           <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
         </div>
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index 3a3d17710..61b2d19e0 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -1,15 +1,13 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
-import IconButton from '../../../components/icon_button';
 import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import classNames from 'classnames';
 
 const messages = defineMessages({
-  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
   description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
 });
 
@@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
     intl: PropTypes.object.isRequired,
     onUndo: PropTypes.func.isRequired,
     onDescriptionChange: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
   };
 
   state = {
@@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
     this.props.onUndo(this.props.media.get('id'));
   }
 
+  handleFocalPointClick = () => {
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  }
+
   handleInputChange = e => {
     this.setState({ dirtyDescription: e.target.value });
   }
@@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
     const { intl, media } = this.props;
     const active          = this.state.hovered || this.state.focused;
     const description     = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
 
     return (
       <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
         <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
           {({ scale }) => (
-            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
-              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className={classNames('compose-form__upload__actions', { active })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
+                {media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
+              </div>
 
               <div className={classNames('compose-form__upload-description', { active })}>
                 <label>
diff --git a/app/javascript/mastodon/features/compose/containers/upload_container.js b/app/javascript/mastodon/features/compose/containers/upload_container.js
index ca9c3b704..d6b57e5ff 100644
--- a/app/javascript/mastodon/features/compose/containers/upload_container.js
+++ b/app/javascript/mastodon/features/compose/containers/upload_container.js
@@ -1,6 +1,7 @@
 import { connect } from 'react-redux';
 import Upload from '../components/upload';
 import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
+import { openModal } from '../../../actions/modal';
 
 const mapStateToProps = (state, { id }) => ({
   media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
   },
 
   onDescriptionChange: (id, description) => {
-    dispatch(changeUploadCompose(id, description));
+    dispatch(changeUploadCompose(id, { description }));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(openModal('FOCAL_POINT', { id }));
   },
 
 });
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
new file mode 100644
index 000000000..ee5c791d4
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js
@@ -0,0 +1,122 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import ImageLoader from './image_loader';
+import classNames from 'classnames';
+import { changeUploadCompose } from '../../../actions/compose';
+import { getPointerPosition } from '../../video';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onSave: (x, y) => {
+    dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  },
+
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+export default class FocalPointModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    x: 0,
+    y: 0,
+    focusX: 0,
+    focusY: 0,
+    dragging: false,
+  };
+
+  componentWillMount () {
+    this.updatePositionFromMedia(this.props.media);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.media.get('id') !== nextProps.media.get('id')) {
+      this.updatePositionFromMedia(nextProps.media);
+    }
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove);
+    document.addEventListener('mouseup', this.handleMouseUp);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
+  handleMouseMove = e => {
+    this.updatePosition(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+
+    this.setState({ dragging: false });
+    this.props.onSave(this.state.focusX, this.state.focusY);
+  }
+
+  updatePosition = e => {
+    const { x, y } = getPointerPosition(this.node, e);
+    const focusX   = (x - .5) *  2;
+    const focusY   = (y - .5) * -2;
+
+    this.setState({ x, y, focusX, focusY });
+  }
+
+  updatePositionFromMedia = media => {
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+
+    if (focusX && focusY) {
+      const x = (focusX /  2) + .5;
+      const y = (focusY / -2) + .5;
+
+      this.setState({ x, y, focusX, focusY });
+    } else {
+      this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { media } = this.props;
+    const { x, y, dragging } = this.state;
+
+    const width  = media.getIn(['meta', 'original', 'width']) || null;
+    const height = media.getIn(['meta', 'original', 'height']) || null;
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div className={classNames('media-modal__content focal-point', { dragging })} ref={this.setRef}>
+          <ImageLoader
+            previewSrc={media.get('preview_url')}
+            src={media.get('url')}
+            width={width}
+            height={height}
+          />
+
+          <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+          <div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
index 5839ba40a..20bf21153 100644
--- a/app/javascript/mastodon/features/ui/components/modal_root.js
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -8,6 +8,7 @@ import MediaModal from './media_modal';
 import VideoModal from './video_modal';
 import BoostModal from './boost_modal';
 import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
 import {
   OnboardingModal,
   MuteModal,
@@ -27,6 +28,7 @@ const MODAL_COMPONENTS = {
   'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
   'EMBED': EmbedModal,
   'LIST_EDITOR': ListEditor,
+  'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
 };
 
 export default class ModalRoot extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 6335d84b6..c81a5cb5f 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -30,7 +30,7 @@ const formatTime = secondsNum => {
   return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 };
 
-const findElementPosition = el => {
+export const findElementPosition = el => {
   let box;
 
   if (el.getBoundingClientRect && el.parentNode) {
@@ -61,7 +61,7 @@ const findElementPosition = el => {
   };
 };
 
-const getPointerPosition = (el, event) => {
+export const getPointerPosition = (el, event) => {
   const position = {};
   const box = findElementPosition(el);
   const boxW = el.offsetWidth;
@@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
     pageY = event.changedTouches[0].pageY;
   }
 
-  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
   position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 
   return position;
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index c709fb88c..1358fb4aa 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -34,7 +34,7 @@ import uuid from '../uuid';
 import { me } from '../initial_state';
 
 const initialState = ImmutableMap({
-  mounted: false,
+  mounted: 0,
   sensitive: false,
   spoiler: false,
   spoiler_text: '',
@@ -159,10 +159,10 @@ export default function compose(state = initialState, action) {
   case STORE_HYDRATE:
     return hydrate(state, action.state.get('compose'));
   case COMPOSE_MOUNT:
-    return state.set('mounted', true);
+    return state.set('mounted', state.get('mounted') + 1);
   case COMPOSE_UNMOUNT:
     return state
-      .set('mounted', false)
+      .set('mounted', Math.max(state.get('mounted') - 1, 0))
       .set('is_composing', false);
   case COMPOSE_SENSITIVITY_CHANGE:
     return state.withMutations(map => {
@@ -265,7 +265,7 @@ export default function compose(state = initialState, action) {
       .set('is_submitting', false)
       .update('media_attachments', list => list.map(item => {
         if (item.get('id') === action.media.id) {
-          return item.set('description', action.media.description);
+          return fromJS(action.media);
         }
 
         return item;
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 0806171be..a95b75984 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -1,3 +1,130 @@
+$maximum-width: 1235px;
+$fluid-breakpoint: $maximum-width + 20px;
+$column-breakpoint: 700px;
+$small-breakpoint: 960px;
+
+.container {
+  box-sizing: border-box;
+  max-width: $maximum-width;
+  margin: 0 auto;
+  position: relative;
+
+  @media screen and (max-width: $fluid-breakpoint) {
+    width: 100%;
+    padding: 0 10px;
+  }
+}
+
+.show-xs,
+.show-sm {
+  display: none;
+}
+
+.show-m {
+  display: block;
+}
+
+@media screen and (max-width: $small-breakpoint) {
+  .hide-sm {
+    display: none !important;
+  }
+
+  .show-sm {
+    display: block !important;
+  }
+}
+
+@media screen and (max-width: $column-breakpoint) {
+  .hide-xs {
+    display: none !important;
+  }
+
+  .show-xs {
+    display: block !important;
+  }
+}
+
+.row {
+  display: flex;
+  flex-wrap: wrap;
+  margin: 0 -5px;
+
+  @for $i from 1 through 15 {
+    .column-#{$i} {
+      box-sizing: border-box;
+      min-height: 1px;
+      flex: 0 0 percentage($i / 15);
+      max-width: percentage($i / 15);
+      padding: 0 5px;
+
+      @media screen and (max-width: $small-breakpoint) {
+        &-sm {
+          box-sizing: border-box;
+          min-height: 1px;
+          flex: 0 0 percentage($i / 15);
+          max-width: percentage($i / 15);
+          padding: 0 5px;
+
+          @media screen and (max-width: $column-breakpoint) {
+            max-width: 100%;
+            flex: 0 0 100%;
+            margin-bottom: 10px;
+
+            &:last-child {
+              margin-bottom: 0;
+            }
+          }
+        }
+      }
+
+      @media screen and (max-width: $column-breakpoint) {
+        max-width: 100%;
+        flex: 0 0 100%;
+        margin-bottom: 10px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+  }
+}
+
+.column-flex {
+  display: flex;
+  flex-direction: column;
+}
+
+.separator-or {
+  position: relative;
+  margin: 40px 0;
+  text-align: center;
+
+  &::before {
+    content: "";
+    display: block;
+    width: 100%;
+    height: 0;
+    border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+    position: absolute;
+    top: 50%;
+    left: 0;
+  }
+
+  span {
+    display: inline-block;
+    background: $ui-base-color;
+    font-size: 12px;
+    font-weight: 500;
+    color: $ui-primary-color;
+    text-transform: uppercase;
+    position: relative;
+    z-index: 1;
+    padding: 0 8px;
+    cursor: default;
+  }
+}
+
 .landing-page {
   p,
   li {
@@ -116,10 +243,14 @@
   }
 
   hr {
-    border-color: rgba($ui-base-lighter-color, .6);
+    width: 100%;
+    height: 0;
+    border: 0;
+    border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
+    margin: 20px 0;
   }
 
-  .container {
+  .container-alt {
     width: 100%;
     box-sizing: border-box;
     max-width: 800px;
@@ -152,24 +283,20 @@
         }
       }
     }
+  }
 
-    .mascot-container {
-      max-width: 800px;
-      margin: 0 auto;
-      position: absolute;
-      top: 0;
-      left: 0;
-      right: 0;
-      height: 100%;
+  .brand {
+    a {
+      padding-left: 0;
+      padding-right: 0;
+      color: $white;
     }
 
-    .mascot {
-      position: absolute;
-      bottom: -14px;
-      width: auto;
-      height: auto;
-      left: 60px;
-      z-index: 3;
+    img {
+      height: 32px;
+      position: relative;
+      top: 4px;
+      left: -10px;
     }
   }
 
@@ -177,7 +304,7 @@
     line-height: 30px;
     overflow: hidden;
 
-    .container {
+    .container-alt {
       display: flex;
       justify-content: space-between;
     }
@@ -203,21 +330,6 @@
         }
       }
 
-      .brand {
-        a {
-          padding-left: 0;
-          padding-right: 0;
-          color: $white;
-        }
-
-        img {
-          height: 32px;
-          position: relative;
-          top: 4px;
-          left: -10px;
-        }
-      }
-
       ul {
         list-style: none;
         margin: 0;
@@ -243,53 +355,6 @@
       align-items: center;
       position: relative;
 
-      .floats {
-        position: absolute;
-        width: 100%;
-        height: 100%;
-        top: 0;
-        left: 0;
-
-        div {
-          position: absolute;
-          transition: all 0.1s linear;
-          animation-name: floating;
-          animation-iteration-count: infinite;
-          animation-direction: alternate;
-          animation-timing-function: ease-in-out;
-          z-index: 2;
-        }
-
-        .float-1 {
-          width: 324px;
-          height: 170px;
-          right: -120px;
-          bottom: 0;
-          animation-duration: 3s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>');
-        }
-
-        .float-2 {
-          width: 241px;
-          height: 100px;
-          right: 210px;
-          bottom: 0;
-          animation-duration: 3.5s;
-          animation-delay: 0.2s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>');
-        }
-
-        .float-3 {
-          width: 267px;
-          height: 140px;
-          right: 110px;
-          top: -30px;
-          animation-duration: 4s;
-          animation-delay: 0.5s;
-          background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>');
-        }
-      }
-
       .heading {
         position: relative;
         z-index: 4;
@@ -346,18 +411,18 @@
     background: darken($ui-base-color, 4%);
     padding: 20px 0;
 
-    .container {
+    .container-alt {
       position: relative;
       padding-right: 280px + 15px;
     }
 
-    .information-board-sections {
+    &__sections {
       display: flex;
       justify-content: space-between;
       flex-wrap: wrap;
     }
 
-    .section {
+    &__section {
       flex: 1 0 0;
       font-family: 'mastodon-font-sans-serif', sans-serif;
       font-size: 16px;
@@ -382,6 +447,10 @@
         font-size: 32px;
         line-height: 48px;
       }
+
+      @media screen and (max-width: $column-breakpoint) {
+        text-align: center;
+      }
     }
 
     .panel {
@@ -460,111 +529,282 @@
     }
   }
 
-  .features {
-    padding: 50px 0;
+  &.alternative {
+    padding: 10px 0;
 
-    .container {
-      display: flex;
-    }
+    .brand {
+      text-align: center;
+      padding: 30px 0;
+      margin-bottom: 10px;
 
-    #mastodon-timeline {
-      display: flex;
-      -webkit-overflow-scrolling: touch;
-      -ms-overflow-style: -ms-autohiding-scrollbar;
-      font-family: 'mastodon-font-sans-serif', sans-serif;
-      font-size: 13px;
-      line-height: 18px;
-      font-weight: 400;
-      color: $primary-text-color;
-      width: 330px;
-      margin-right: 30px;
-      flex: 0 0 auto;
-      background: $ui-base-color;
-      overflow: hidden;
-      border-radius: 4px;
-      box-shadow: 0 0 6px rgba($black, 0.1);
+      img {
+        position: static;
+      }
 
-      .column-header {
-        color: inherit;
-        font-family: inherit;
-        font-size: 16px;
-        line-height: inherit;
-        font-weight: inherit;
-        margin: 0;
-        padding: 0;
+      @media screen and (max-width: $small-breakpoint) {
+        padding: 15px 0;
       }
 
-      .column {
+      @media screen and (max-width: $column-breakpoint) {
         padding: 0;
-        border-radius: 4px;
-        overflow: hidden;
+        margin-bottom: -10px;
       }
+    }
+  }
 
-      .scrollable {
-        height: 400px;
-      }
+  &__information,
+  &__forms {
+    padding: 20px;
+  }
+
+  &__call-to-action {
+    margin-bottom: 10px;
+    background: darken($ui-base-color, 4%);
+    border-radius: 4px;
+    padding: 25px 40px;
+    overflow: hidden;
+
+    .row {
+      align-items: center;
+    }
+
+    .information-board__section {
+      padding: 0;
+    }
+  }
+
+  &__logo {
+    margin-right: 20px;
+
+    img {
+      height: 50px;
+      width: auto;
+      mix-blend-mode: lighten;
+    }
+  }
+
+  &__information {
+    padding: 45px 40px;
+    margin-bottom: 10px;
 
-      p {
-        font-size: inherit;
-        line-height: inherit;
-        font-weight: inherit;
-        color: $primary-text-color;
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      padding: 25px 20px;
+    }
+  }
+
+  &__information,
+  &__forms,
+  #mastodon-timeline {
+    box-sizing: border-box;
+    background: $ui-base-color;
+    border-radius: 4px;
+    box-shadow: 0 0 6px rgba($black, 0.1);
+  }
+
+  &__mascot {
+    height: 104px;
+    position: relative;
+    left: -40px;
+    bottom: 25px;
+
+    img {
+      height: 190px;
+      width: auto;
+    }
+  }
+
+  &__short-description {
+    .row {
+      align-items: center;
+      margin-bottom: 40px;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      .row {
         margin-bottom: 20px;
+      }
+    }
 
-        &:last-child {
-          margin-bottom: 0;
-        }
+    p a {
+      color: $ui-secondary-color;
+    }
 
-        a {
+    h1 {
+      font-weight: 500;
+      color: $primary-text-color;
+      margin-bottom: 0;
+
+      small {
+        color: $ui-primary-color;
+
+        span {
           color: $ui-secondary-color;
-          text-decoration: none;
         }
       }
     }
 
-    .about-mastodon {
-      max-width: 675px;
+    p:last-child {
+      margin-bottom: 0;
+    }
+  }
 
-      p {
-        margin-bottom: 20px;
+  &__hero {
+    margin-bottom: 10px;
+
+    img {
+      display: block;
+      margin: 0;
+      max-width: 100%;
+      height: auto;
+      border-radius: 4px;
+    }
+  }
+
+  &__forms {
+    height: 100%;
+
+    @media screen and (max-width: $small-breakpoint) {
+      margin-bottom: 10px;
+      height: auto;
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      background: transparent;
+      box-shadow: none;
+      padding: 0 20px;
+      margin-top: 30px;
+      margin-bottom: 40px;
+
+      .separator-or {
+        span {
+          background: darken($ui-base-color, 8%);
+        }
       }
+    }
 
-      .features-list {
-        margin-top: 20px;
+    hr {
+      margin: 40px 0;
+    }
 
-        .features-list__row {
-          display: flex;
-          padding: 10px 0;
-          justify-content: space-between;
+    .button {
+      display: block;
+    }
 
-          &:first-child {
-            padding-top: 0;
-          }
+    .subtle-hint a {
+      text-decoration: none;
 
-          .visual {
-            flex: 0 0 auto;
-            display: flex;
-            align-items: center;
-            margin-left: 15px;
-
-            .fa {
-              display: block;
-              color: $ui-primary-color;
-              font-size: 48px;
-            }
-          }
+      &:hover,
+      &:focus,
+      &:active {
+        text-decoration: underline;
+      }
+    }
+  }
 
-          .text {
-            font-size: 16px;
-            line-height: 30px;
-            color: $ui-primary-color;
+  #mastodon-timeline {
+    display: flex;
+    -webkit-overflow-scrolling: touch;
+    -ms-overflow-style: -ms-autohiding-scrollbar;
+    font-family: 'mastodon-font-sans-serif', sans-serif;
+    font-size: 13px;
+    line-height: 18px;
+    font-weight: 400;
+    color: $primary-text-color;
+    width: 100%;
+    flex: 1 1 auto;
+    overflow: hidden;
 
-            h6 {
-              font-size: inherit;
-              line-height: inherit;
-              margin-bottom: 0;
-            }
-          }
+    .column-header {
+      color: inherit;
+      font-family: inherit;
+      font-size: 16px;
+      line-height: inherit;
+      font-weight: inherit;
+      margin: 0;
+      padding: 0;
+    }
+
+    .column {
+      padding: 0;
+      border-radius: 4px;
+      overflow: hidden;
+      width: 100%;
+    }
+
+    .scrollable {
+      height: 400px;
+    }
+
+    p {
+      font-size: inherit;
+      line-height: inherit;
+      font-weight: inherit;
+      color: $primary-text-color;
+      margin-bottom: 20px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      a {
+        color: $ui-secondary-color;
+        text-decoration: none;
+      }
+    }
+
+    @media screen and (max-width: $column-breakpoint) {
+      height: 90vh;
+    }
+  }
+
+  &__features {
+    .features-list {
+      margin: 40px 0 !important;
+    }
+
+    &__action {
+      text-align: center;
+    }
+  }
+
+  .features-list {
+    margin-top: 20px;
+
+    .features-list__row {
+      display: flex;
+      padding: 10px 0;
+      justify-content: space-between;
+
+      &:first-child {
+        padding-top: 0;
+      }
+
+      .visual {
+        flex: 0 0 auto;
+        display: flex;
+        align-items: center;
+        margin-left: 15px;
+
+        .fa {
+          display: block;
+          color: $ui-primary-color;
+          font-size: 48px;
+        }
+      }
+
+      .text {
+        font-size: 16px;
+        line-height: 30px;
+        color: $ui-primary-color;
+
+        h6 {
+          font-size: inherit;
+          line-height: inherit;
+          margin-bottom: 0;
         }
       }
     }
@@ -600,21 +840,31 @@
     }
   }
 
+  &__footer {
+    margin-top: 10px;
+    text-align: center;
+    color: $ui-base-lighter-color;
+
+    p {
+      font-size: 14px;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+      }
+    }
+  }
+
   @media screen and (max-width: 840px) {
-    .container {
+    .container-alt {
       padding: 0 20px;
     }
 
     .information-board {
-
-      .container {
+      .container-alt {
         padding-right: 20px;
       }
 
-      .section {
-        text-align: center;
-      }
-
       .panel {
         position: static;
         margin-top: 20px;
@@ -626,16 +876,6 @@
         }
       }
     }
-
-    .header-wrapper .mascot {
-      left: 20px;
-    }
-  }
-
-  @media screen and (max-width: 689px) {
-    .header-wrapper .mascot {
-      display: none;
-    }
   }
 
   @media screen and (max-width: 675px) {
@@ -651,13 +891,12 @@
       }
     }
 
-    .header .container,
-    .features .container {
+    .header .container-alt,
+    .features .container-alt {
       display: block;
     }
 
     .header {
-
       .links {
         padding-top: 15px;
         background: darken($ui-base-color, 4%);
@@ -682,10 +921,6 @@
         margin-top: 30px;
         padding: 0;
 
-        .floats {
-          display: none;
-        }
-
         .heading {
           padding: 30px 20px;
           text-align: center;
@@ -700,16 +935,6 @@
         }
       }
     }
-
-    .features #mastodon-timeline {
-      height: 70vh;
-      width: 100%;
-      margin-bottom: 50px;
-
-      .column {
-        width: 100%;
-      }
-    }
   }
 
   .cta {
@@ -720,7 +945,7 @@
     .features {
       padding: 30px 0;
 
-      .container {
+      .container-alt {
         max-width: 820px;
 
         #mastodon-timeline {
@@ -772,7 +997,7 @@
       .features {
         padding: 10px 0;
 
-        .container {
+        .container-alt {
           display: flex;
           flex-direction: column;
 
@@ -808,17 +1033,3 @@
     }
   }
 }
-
-@keyframes floating {
-  from {
-    transform: translate(0, 0);
-  }
-
-  65% {
-    transform: translate(0, 4px);
-  }
-
-  to {
-    transform: translate(0, -0);
-  }
-}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 66e4adc2b..09b38859b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -40,14 +40,20 @@
     cursor: default;
   }
 
-  &.button-alternative {
+  &.button-primary,
+  &.button-alternative,
+  &.button-secondary,
+  &.button-alternative-2 {
     font-size: 16px;
     line-height: 36px;
     height: auto;
-    color: $ui-base-color;
-    background: $ui-primary-color;
     text-transform: none;
     padding: 4px 16px;
+  }
+
+  &.button-alternative {
+    color: $ui-base-color;
+    background: $ui-primary-color;
 
     &:active,
     &:focus,
@@ -56,15 +62,20 @@
     }
   }
 
+  &.button-alternative-2 {
+    background: $ui-base-lighter-color;
+
+    &:active,
+    &:focus,
+    &:hover {
+      background-color: lighten($ui-base-lighter-color, 4%);
+    }
+  }
+
   &.button-secondary {
-    font-size: 16px;
-    line-height: 36px;
-    height: auto;
     color: $ui-primary-color;
-    text-transform: none;
     background: transparent;
     padding: 3px 15px;
-    border-radius: 4px;
     border: 1px solid $ui-primary-color;
 
     &:active,
@@ -433,6 +444,34 @@
       min-width: 40%;
       margin: 5px;
 
+      &__actions {
+        background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
+        display: flex;
+        align-items: flex-start;
+        justify-content: space-between;
+        opacity: 0;
+        transition: opacity .1s ease;
+
+        .icon-button {
+          flex: 0 1 auto;
+          color: $ui-secondary-color;
+          font-size: 14px;
+          font-weight: 500;
+          padding: 10px;
+          font-family: inherit;
+
+          &:hover,
+          &:focus,
+          &:active {
+            color: lighten($ui-secondary-color, 4%);
+          }
+        }
+
+        &.active {
+          opacity: 1;
+        }
+      }
+
       &-description {
         position: absolute;
         z-index: 2;
@@ -470,10 +509,6 @@
           opacity: 1;
         }
       }
-
-      .icon-button {
-        mix-blend-mode: difference;
-      }
     }
 
     .compose-form__upload-thumbnail {
@@ -481,8 +516,9 @@
       background-position: center;
       background-size: cover;
       background-repeat: no-repeat;
-      height: 100px;
+      height: 140px;
       width: 100%;
+      overflow: hidden;
     }
   }
 
@@ -4133,8 +4169,12 @@ a.status-card {
   &,
   img {
     width: 100%;
-    height: 100%;
+  }
+
+  img {
+    position: relative;
     object-fit: cover;
+    height: auto;
   }
 }
 
@@ -4842,3 +4882,31 @@ noscript {
     margin-bottom: 0;
   }
 }
+
+.focal-point {
+  position: relative;
+  cursor: pointer;
+  overflow: hidden;
+
+  &.dragging {
+    cursor: move;
+  }
+
+  &__reticle {
+    position: absolute;
+    width: 100px;
+    height: 100px;
+    transform: translate(-50%, -50%);
+    background: url('../images/reticle.png') no-repeat 0 0;
+    border-radius: 50%;
+    box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
+  }
+
+  &__overlay {
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    top: 0;
+    left: 0;
+  }
+}
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index af2589e23..6fa1fa38f 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -1,4 +1,4 @@
-.container {
+.container-alt {
   width: 700px;
   margin: 0 auto;
   margin-top: 40px;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 64c429420..a7afbb859 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
       next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
 
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
-      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence)
+      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
       media_attachments << media_attachment
 
       next if skip_download?
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 90d589d90..8198ac580 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
         'conversation'              => 'ostatus:conversation',
         'toot'                      => 'http://joinmastodon.org/ns#',
         'Emoji'                     => 'toot:Emoji',
+        'focalPoint'                => { '@container' => '@list', '@id' => 'toot:focalPoint' },
       },
     ],
   }.freeze
diff --git a/app/lib/fast_geometry_parser.rb b/app/lib/fast_geometry_parser.rb
new file mode 100644
index 000000000..5209c2bc5
--- /dev/null
+++ b/app/lib/fast_geometry_parser.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class FastGeometryParser
+  def self.from_file(file)
+    width, height = FastImage.size(file.path)
+
+    raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
+
+    Paperclip::Geometry.new(width, height)
+  end
+end
diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb
index 2fc9caba3..9848c34a2 100644
--- a/app/mailers/user_mailer.rb
+++ b/app/mailers/user_mailer.rb
@@ -66,4 +66,16 @@ class UserMailer < Devise::Mailer
       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
     end
   end
+
+  def backup_ready(user, backup)
+    @resource = user
+    @instance = Rails.configuration.x.local_domain
+    @backup   = backup
+
+    return if @resource.disabled?
+
+    I18n.with_locale(@resource.locale || I18n.default_locale) do
+      mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
+    end
+  end
 end
diff --git a/app/models/backup.rb b/app/models/backup.rb
new file mode 100644
index 000000000..5a7e6a14d
--- /dev/null
+++ b/app/models/backup.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: backups
+#
+#  id                :integer          not null, primary key
+#  user_id           :integer
+#  dump_file_name    :string
+#  dump_content_type :string
+#  dump_file_size    :integer
+#  dump_updated_at   :datetime
+#  processed         :boolean          default(FALSE), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class Backup < ApplicationRecord
+  belongs_to :user, inverse_of: :backups
+
+  has_attached_file :dump
+  do_not_validate_attachment_file_type :dump
+end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index 53d0d876f..7712a29fd 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -7,15 +7,9 @@ module AccountAvatar
 
   class_methods do
     def avatar_styles(file)
-      styles   = {}
-      geometry = Paperclip::Geometry.from_file(file)
-
-      styles[:original] = '120x120#' if geometry.width != geometry.height || geometry.width > 120 || geometry.height > 120
-      styles[:static]   = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
-
+      styles = { original: { geometry: '120x120#', file_geometry_parser: FastGeometryParser } }
+      styles[:static] = { geometry: '120x120#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
       styles
-    rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-      {}
     end
 
     private :avatar_styles
@@ -23,7 +17,7 @@ module AccountAvatar
 
   included do
     # Avatar upload
-    has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }
+    has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
     validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
     validates_attachment_size :avatar, less_than: 2.megabytes
   end
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 991473d8c..04c576b28 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -7,15 +7,9 @@ module AccountHeader
 
   class_methods do
     def header_styles(file)
-      styles   = {}
-      geometry = Paperclip::Geometry.from_file(file)
-
-      styles[:original] = '700x335#' unless geometry.width == 700 && geometry.height == 335
-      styles[:static]   = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
-
+      styles = { original: { geometry: '700x335#', file_geometry_parser: FastGeometryParser } }
+      styles[:static] = { geometry: '700x335#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
       styles
-    rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-      {}
     end
 
     private :header_styles
@@ -23,7 +17,7 @@ module AccountHeader
 
   included do
     # Header upload
-    has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }
+    has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
     validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
     validates_attachment_size :header, less_than: 2.megabytes
   end
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index a3d55108d..87d93c1fd 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -53,8 +53,11 @@ module Omniauthable
     private
 
     def user_params_from_auth(auth)
-      email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
-      email             = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email)
+      strategy          = Devise.omniauth_configs[auth.provider.to_sym].strategy
+      assume_verified   = strategy.try(:security).try(:assume_email_is_verified)
+      email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
+      email             = auth.info.verified_email || auth.info.email
+      email             = email_is_verified && !User.exists?(email: auth.info.email) && email
 
       {
         email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 6f17363c8..283d0e714 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -34,7 +34,18 @@ class MediaAttachment < ApplicationRecord
   VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
   AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 
-  IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze
+  IMAGE_STYLES = {
+    original: {
+      geometry: '1280x1280>',
+      file_geometry_parser: FastGeometryParser,
+    },
+
+    small: {
+      geometry: '400x400>',
+      file_geometry_parser: FastGeometryParser,
+    },
+  }.freeze
+
   AUDIO_STYLES = {
     original: {
       format: 'mp4',
@@ -50,6 +61,7 @@ class MediaAttachment < ApplicationRecord
       },
     },
   }.freeze
+
   VIDEO_STYLES = {
     small: {
       convert_options: {
@@ -97,6 +109,24 @@ class MediaAttachment < ApplicationRecord
     shortcode
   end
 
+  def focus=(point)
+    return if point.blank?
+
+    x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
+
+    meta = file.instance_read(:meta) || {}
+    meta['focus'] = { 'x' => x, 'y' => y }
+
+    file.instance_write(:meta, meta)
+  end
+
+  def focus
+    x = file.meta['focus']['x']
+    y = file.meta['focus']['y']
+
+    "#{x},#{y}"
+  end
+
   before_create :prepare_description, unless: :local?
   before_create :set_shortcode
   before_post_process :set_type_and_extension
@@ -178,7 +208,7 @@ class MediaAttachment < ApplicationRecord
   end
 
   def populate_meta
-    meta = {}
+    meta = file.instance_read(:meta) || {}
 
     file.queued_for_write.each do |style, file|
       meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
@@ -188,16 +218,16 @@ class MediaAttachment < ApplicationRecord
   end
 
   def image_geometry(file)
-    geo = Paperclip::Geometry.from_file file
+    width, height = FastImage.size(file.path)
+
+    return {} if width.nil?
 
     {
-      width:  geo.width.to_i,
-      height: geo.height.to_i,
-      size: "#{geo.width.to_i}x#{geo.height.to_i}",
-      aspect: geo.width.to_f / geo.height.to_f,
+      width:  width,
+      height: height,
+      size: "#{width}x#{height}",
+      aspect: width.to_f / height.to_f,
     }
-  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-    {}
   end
 
   def video_metadata(file)
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index 716b82243..86eecdfe5 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -33,7 +33,7 @@ class PreviewCard < ApplicationRecord
 
   has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '400x400>' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
@@ -58,10 +58,11 @@ class PreviewCard < ApplicationRecord
 
     return if file.nil?
 
-    geo         = Paperclip::Geometry.from_file(file)
-    self.width  = geo.width.to_i
-    self.height = geo.height.to_i
-  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
-    nil
+    width, height = FastImage.size(file.path)
+
+    return nil if width.nil?
+
+    self.width  = width
+    self.height = height
   end
 end
diff --git a/app/models/site_upload.rb b/app/models/site_upload.rb
index 8ffdc8313..641128adf 100644
--- a/app/models/site_upload.rb
+++ b/app/models/site_upload.rb
@@ -34,8 +34,8 @@ class SiteUpload < ApplicationRecord
 
     return if tempfile.nil?
 
-    geometry  = Paperclip::Geometry.from_file(tempfile)
-    self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
+    width, height = FastImage.size(tempfile.path)
+    self.meta = { width: width, height: height }
   end
 
   def clear_cache
diff --git a/app/models/status.rb b/app/models/status.rb
index 86bf3deba..125fa1bb5 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -79,7 +79,7 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
-  cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account
+  cache_associated :account, :application, :media_attachments, :conversation, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, :conversation, mentions: :account], thread: :account
 
   delegate :domain, to: :account, prefix: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index af54efded..197799294 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -60,6 +60,7 @@ class User < ApplicationRecord
   accepts_nested_attributes_for :account
 
   has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+  has_many :backups, inverse_of: :user
 
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
   validates_with BlacklistedEmailValidator, if: :email_changed?
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
index 3e617001f..d1de5e81a 100644
--- a/app/policies/application_policy.rb
+++ b/app/policies/application_policy.rb
@@ -15,4 +15,8 @@ class ApplicationPolicy
   def current_user
     current_account&.user
   end
+
+  def user_signed_in?
+    !current_user.nil?
+  end
 end
diff --git a/app/policies/backup_policy.rb b/app/policies/backup_policy.rb
new file mode 100644
index 000000000..0ef89a8d0
--- /dev/null
+++ b/app/policies/backup_policy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class BackupPolicy < ApplicationPolicy
+  MIN_AGE = 1.week
+
+  def create?
+    user_signed_in? && current_user.backups.where('created_at >= ?', MIN_AGE.ago).count.zero?
+  end
+end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index d27fb7b01..b1e99b31b 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -45,7 +45,7 @@ class AccountRelationshipsPresenter
       maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
 
       if maps_for_account.is_a?(Hash)
-        @cached.merge!(maps_for_account)
+        @cached.deep_merge!(maps_for_account)
       else
         @uncached_account_ids << account_id
       end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 1c08fb3bc..db288d5db 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -48,4 +48,8 @@ class InstancePresenter
   def thumbnail
     @thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
   end
+
+  def hero
+    @hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
+  end
 end
diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb
index 9832133fc..d43af3f8e 100644
--- a/app/serializers/activitypub/collection_serializer.rb
+++ b/app/serializers/activitypub/collection_serializer.rb
@@ -13,8 +13,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
   attribute :part_of, if: -> { object.part_of.present? }
 
   has_one :first, if: -> { object.first.present? }
-  has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? }
-  has_many :items, key: :ordered_items, if: -> { (object.items.present? || page?) && ordered? }
+  has_many :items, key: :items, if: -> { (!object.items.nil? || page?) && !ordered? }
+  has_many :items, key: :ordered_items, if: -> { (!object.items.nil? || page?) && ordered? }
 
   def type
     if page?
diff --git a/app/serializers/activitypub/image_serializer.rb b/app/serializers/activitypub/image_serializer.rb
index a015c6b1b..3c08f77e8 100644
--- a/app/serializers/activitypub/image_serializer.rb
+++ b/app/serializers/activitypub/image_serializer.rb
@@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
   include RoutingHelper
 
   attributes :type, :media_type, :url
+  attribute :focal_point, if: :focal_point?
 
   def type
     'Image'
@@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
   def media_type
     object.content_type
   end
+
+  def focal_point?
+    object.respond_to?(:meta) && object.meta.is_a?(Hash) && object.meta['focus'].is_a?(Hash)
+  end
+
+  def focal_point
+    [object.meta['focus']['x'], object.meta['focus']['y']]
+  end
 end
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
new file mode 100644
index 000000000..fadc24a82
--- /dev/null
+++ b/app/services/backup_service.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'rubygems/package'
+
+class BackupService < BaseService
+  attr_reader :account, :backup, :collection
+
+  def call(backup)
+    @backup  = backup
+    @account = backup.user.account
+
+    build_json!
+    build_archive!
+  end
+
+  private
+
+  def build_json!
+    @collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
+
+    account.statuses.with_includes.find_in_batches do |statuses|
+      statuses.each do |status|
+        item = serialize(status, ActivityPub::ActivitySerializer)
+        item.delete(:'@context')
+
+        unless item[:type] == 'Announce' || item[:object][:attachment].blank?
+          item[:object][:attachment].each do |attachment|
+            attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
+          end
+        end
+
+        @collection[:orderedItems] << item
+      end
+
+      GC.start
+    end
+  end
+
+  def build_archive!
+    tmp_file = Tempfile.new(%w(archive .tar.gz))
+
+    File.open(tmp_file, 'wb') do |file|
+      Zlib::GzipWriter.wrap(file) do |gz|
+        Gem::Package::TarWriter.new(gz) do |tar|
+          dump_media_attachments!(tar)
+          dump_outbox!(tar)
+          dump_actor!(tar)
+        end
+      end
+    end
+
+    archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(2)].join('-') + '.tar.gz'
+
+    @backup.dump      = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
+    @backup.processed = true
+    @backup.save!
+  ensure
+    tmp_file.close
+    tmp_file.unlink
+  end
+
+  def dump_media_attachments!(tar)
+    MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
+      media_attachments.each do |m|
+        download_to_tar(tar, m.file, m.file.path)
+      end
+
+      GC.start
+    end
+  end
+
+  def dump_outbox!(tar)
+    json = Oj.dump(collection)
+
+    tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
+      io.write(json)
+    end
+  end
+
+  def dump_actor!(tar)
+    actor = serialize(account, ActivityPub::ActorSerializer)
+
+    actor[:icon][:url]  = 'avatar' + File.extname(actor[:icon][:url])  if actor[:icon]
+    actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image]
+
+    download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists?
+    download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists?
+
+    json = Oj.dump(actor)
+
+    tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
+      io.write(json)
+    end
+
+    tar.add_file_simple('key.pem', 0o444, account.private_key.bytesize) do |io|
+      io.write(account.private_key)
+    end
+  end
+
+  def collection_presenter
+    ActivityPub::CollectionPresenter.new(
+      id: account_outbox_url(account),
+      type: :ordered,
+      size: account.statuses_count,
+      items: []
+    )
+  end
+
+  def serialize(object, serializer)
+    ActiveModelSerializers::SerializableResource.new(
+      object,
+      serializer: serializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
+  CHUNK_SIZE = 1.megabyte
+
+  def download_to_tar(tar, attachment, filename)
+    adapter = Paperclip.io_adapters.for(attachment)
+
+    tar.add_file_simple(filename, 0o444, adapter.size) do |io|
+      while (buffer = adapter.read(CHUNK_SIZE))
+        io.write(buffer)
+      end
+    end
+  end
+end
diff --git a/app/views/about/_forms.html.haml b/app/views/about/_forms.html.haml
new file mode 100644
index 000000000..9916b6bf4
--- /dev/null
+++ b/app/views/about/_forms.html.haml
@@ -0,0 +1,14 @@
+- if @instance_presenter.open_registrations
+  = render 'registration'
+- else
+  - if @instance_presenter.closed_registrations_message.blank?
+    %p= t('about.closed_registrations')
+  - else
+    = @instance_presenter.closed_registrations_message.html_safe
+
+  = link_to t('auth.register'), 'https://joinmastodon.org', class: 'button button-primary'
+
+.separator-or
+  %span= t('auth.or')
+
+= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'
diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml
index ccf4f08b9..f79c37e65 100644
--- a/app/views/about/_links.html.haml
+++ b/app/views/about/_links.html.haml
@@ -1,4 +1,4 @@
-.container.links
+.container-alt.links
   .brand
     = link_to root_url do
       = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 7a28f9738..6ca1d7129 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -10,6 +10,6 @@
   = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
 
   .actions
-    = f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative'
+    = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
 
   %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
diff --git a/app/views/about/more.html.haml b/app/views/about/more.html.haml
index 84daadba8..f86051fbf 100644
--- a/app/views/about/more.html.haml
+++ b/app/views/about/more.html.haml
@@ -9,34 +9,34 @@
     .header
       = render 'links'
 
-      .container.hero
+      .container-alt.hero
         .heading
           %h3= t('about.description_headline', domain: site_hostname)
           %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
   .information-board
-    .container
-      .information-board-sections
-        .section
+    .container-alt
+      .information-board__sections
+        .information-board__section
           %span= t 'about.user_count_before'
           %strong= number_with_delimiter @instance_presenter.user_count
           %span= t 'about.user_count_after'
-        .section
+        .information-board__section
           %span= t 'about.status_count_before'
           %strong= number_with_delimiter @instance_presenter.status_count
           %span= t 'about.status_count_after'
-        .section
+        .information-board__section
           %span= t 'about.domain_count_before'
           %strong= number_with_delimiter @instance_presenter.domain_count
           %span= t 'about.domain_count_after'
       = render 'contact', contact: @instance_presenter
 
   .extended-description
-    .container
+    .container-alt
       = @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
 
   .footer-links
-    .container
+    .container-alt
       %p
         = link_to t('about.source_code'), @instance_presenter.source_url
         - if @instance_presenter.commit_hash == ""
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 487c8429b..bc357e522 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -5,62 +5,74 @@
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = render partial: 'shared/og'
 
-.landing-page
-  .header-wrapper
-    .mascot-container
-      = image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot'
+.landing-page.alternative
+  .container
+    .row
+      .column-4.hide-sm.show-xs.show-m
+        .landing-page__forms
+          .brand
+            = link_to root_url do
+              = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
 
-    .header
-      = render 'links'
+          .hide-xs
+            = render 'forms'
 
-      .container.hero
-        .floats
-          %div{ role: 'presentation', class: 'float-1' }
-          %div{ role: 'presentation', class: 'float-2' }
-          %div{ role: 'presentation', class: 'float-3' }
-        .heading
-          %h1
-            = @instance_presenter.site_title
-            %small= t 'about.hosted_on', domain: site_hostname
-        - if @instance_presenter.open_registrations
-          = render 'registration'
-        - else
-          .closed-registrations-message
-            %div
-              - if @instance_presenter.closed_registrations_message.blank?
-                %p= t('about.closed_registrations')
-              - else
-                = @instance_presenter.closed_registrations_message.html_safe
+      .column-7.column-9-sm
+        .landing-page__hero
+          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
 
-            = simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
-              = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
-              = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
+        .landing-page__information
+          .landing-page__short-description
+            .row
+              .landing-page__logo.hide-xs
+                = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
 
-              .actions
-                = f.button :button, t('auth.login'), type: :submit
-            = link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
+              %h1
+                = @instance_presenter.site_title
+                %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
 
-  .about-short
-    .container
-      %h3= t('about.description_headline', domain: site_hostname)
-      %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
+            %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
 
-  .features
-    .container
-      - if Setting.timeline_preview
-        #mastodon-timeline{ data: { props: Oj.dump(default_props) } }
+        .show-xs
+          .landing-page__forms
+            = render 'forms'
+        .landing-page__call-to-action.hide-xs
+          .row
+            .column-5
+              .landing-page__mascot
+                = image_tag asset_pack_path('elephant_ui_plane.svg')
+            .column-5
+              .information-board__section
+                %span= t 'about.user_count_before'
+                %strong= number_with_delimiter @instance_presenter.user_count
+                %span= t 'about.user_count_after'
+            .column-5
+              .information-board__section
+                %span= t 'about.status_count_before'
+                %strong= number_with_delimiter @instance_presenter.status_count
+                %span= t 'about.status_count_after'
+        .landing-page__information
+          .landing-page__features
+            %h3= t 'about.what_is_mastodon'
+            %p= t 'about.about_mastodon_html'
 
-      .about-mastodon
-        %h3= t 'about.what_is_mastodon'
-        %p= t 'about.about_mastodon_html'
-        = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
-        = render 'features'
-  .footer-links
-    .container
-      %p
-        = link_to t('about.source_code'), @instance_presenter.source_url
-        - if @instance_presenter.commit_hash == ""
-          %strong= " (#{@instance_presenter.version_number})"
-        - else
-          %strong= " (#{@instance_presenter.version_number}, "
-          %strong= " #{@instance_presenter.commit_hash})"
+            = render 'features'
+
+            .landing-page__features__action
+              = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
+
+          .landing-page__footer
+            %p
+              = link_to t('about.source_code'), @instance_presenter.source_url
+              = " (#{@instance_presenter.version_number})"
+
+      .column-4.column-6-sm.column-flex
+        .show-sm.hide-xs
+          .landing-page__forms
+            .brand
+              = link_to root_url do
+                = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+
+            = render 'forms'
+        - if Setting.timeline_preview
+          #mastodon-timeline{ data: { props: Oj.dump(default_props) } }
diff --git a/app/views/about/terms.html.haml b/app/views/about/terms.html.haml
index ba780759c..c7d36ed47 100644
--- a/app/views/about/terms.html.haml
+++ b/app/views/about/terms.html.haml
@@ -7,5 +7,5 @@
       = render 'links'
 
   .extended-description
-    .container
+    .container-alt
       = @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index 73fd5642e..08d05d738 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -12,6 +12,7 @@
 
   .fields-group
     = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
+    = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
 
   %hr/
 
diff --git a/app/views/layouts/auth.html.haml b/app/views/layouts/auth.html.haml
index f4812ac6a..ca9c13945 100644
--- a/app/views/layouts/auth.html.haml
+++ b/app/views/layouts/auth.html.haml
@@ -1,5 +1,5 @@
 - content_for :content do
-  .container
+  .container-alt
     .logo-container
       %h1
         = link_to root_path do
diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml
index a5d79f5c0..e808593cd 100644
--- a/app/views/layouts/modal.html.haml
+++ b/app/views/layouts/modal.html.haml
@@ -8,7 +8,7 @@
       = link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
         = fa_icon 'sign-out'
 
-  .container= yield
+  .container-alt= yield
   .modal-layout__mastodon
     %div
 
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index b3795eaad..07441a77d 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -1,5 +1,5 @@
 - content_for :content do
-  .container= yield
+  .container-alt= yield
   .footer
     - if !user_signed_in? && single_user_mode?
       %span.single-user-login
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index e0df1c480..89d768d3f 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -20,3 +20,26 @@
         %th= t('exports.mutes')
         %td= @export.total_mutes
         %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
+
+%p.muted-hint= t('exports.archive_takeout.hint_html')
+
+- if policy(:backup).create?
+  %p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post
+
+- unless @backups.empty?
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('exports.archive_takeout.date')
+          %th= t('exports.archive_takeout.size')
+          %th
+      %tbody
+        - @backups.each do |backup|
+          %tr
+            %td= l backup.created_at
+            - if backup.processed?
+              %td= number_to_human_size backup.dump_file_size
+              %td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url
+            - else
+              %td{ colspan: 2 }= t('exports.archive_takeout.in_progress')
diff --git a/app/views/user_mailer/backup_ready.html.haml b/app/views/user_mailer/backup_ready.html.haml
new file mode 100644
index 000000000..d5a4b8b48
--- /dev/null
+++ b/app/views/user_mailer/backup_ready.html.haml
@@ -0,0 +1,59 @@
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.hero
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center.padded
+                              %table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                                %tbody
+                                  %tr
+                                    %td
+                                      = image_tag full_pack_url('icon_file_download.png'), alt: ''
+
+                              %h1= t 'user_mailer.backup_ready.title'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell.content-start
+                  .email-row
+                    .col-6
+                      %table.column{ cellspacing: 0, cellpadding: 0 }
+                        %tbody
+                          %tr
+                            %td.column-cell.text-center
+                              %p= t 'user_mailer.backup_ready.explanation'
+
+%table.email-table{ cellspacing: 0, cellpadding: 0 }
+  %tbody
+    %tr
+      %td.email-body
+        .email-container
+          %table.content-section{ cellspacing: 0, cellpadding: 0 }
+            %tbody
+              %tr
+                %td.content-cell
+                  %table.column{ cellspacing: 0, cellpadding: 0 }
+                    %tbody
+                      %tr
+                        %td.column-cell.button-cell
+                          %table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
+                            %tbody
+                              %tr
+                                %td.button-primary
+                                  = link_to full_asset_url(@backup.dump.url) do
+                                    %span= t 'exports.archive_takeout.download'
diff --git a/app/views/user_mailer/backup_ready.text.erb b/app/views/user_mailer/backup_ready.text.erb
new file mode 100644
index 000000000..eb89e7d74
--- /dev/null
+++ b/app/views/user_mailer/backup_ready.text.erb
@@ -0,0 +1,7 @@
+<%= t 'user_mailer.backup_ready.title' %>
+
+===
+
+<%= t 'user_mailer.backup_ready.explanation' %>
+
+=> <%= full_asset_url(@backup.dump.url) %>
diff --git a/app/workers/backup_worker.rb b/app/workers/backup_worker.rb
new file mode 100644
index 000000000..ec6db4e9e
--- /dev/null
+++ b/app/workers/backup_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class BackupWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull'
+
+  def perform(backup_id)
+    backup = Backup.find(backup_id)
+    user   = backup.user
+
+    BackupService.new.call(backup)
+
+    user.backups.where.not(id: backup.id).destroy_all
+    UserMailer.backup_ready(user, backup).deliver_later
+  end
+end
diff --git a/app/workers/scheduler/backup_cleanup_scheduler.rb b/app/workers/scheduler/backup_cleanup_scheduler.rb
new file mode 100644
index 000000000..7a9d4f894
--- /dev/null
+++ b/app/workers/scheduler/backup_cleanup_scheduler.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+require 'sidekiq-scheduler'
+
+class Scheduler::BackupCleanupScheduler
+  include Sidekiq::Worker
+
+  def perform
+    old_backups.find_each(&:destroy!)
+  end
+
+  private
+
+  def old_backups
+    Backup.where('created_at < ?', 7.days.ago)
+  end
+end
diff --git a/config/application.rb b/config/application.rb
index 17bb02b3f..b1cc7727a 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -7,6 +7,7 @@ require 'rails/all'
 Bundler.require(*Rails.groups)
 
 require_relative '../app/lib/exceptions'
+require_relative '../lib/paperclip/lazy_thumbnail'
 require_relative '../lib/paperclip/gif_transcoder'
 require_relative '../lib/paperclip/video_transcoder'
 require_relative '../lib/paperclip/audio_transcoder'
diff --git a/config/initializers/chewy.rb b/config/initializers/chewy.rb
index 702f7516c..d5347f2bf 100644
--- a/config/initializers/chewy.rb
+++ b/config/initializers/chewy.rb
@@ -9,6 +9,7 @@ Chewy.settings = {
   prefix: prefix,
   enabled: enabled,
   journal: false,
+  sidekiq: { queue: 'pull' },
 }
 
 Chewy.root_strategy    = enabled ? :sidekiq : :bypass
diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb
index 97f32c0a4..92a73d82a 100644
--- a/config/initializers/omniauth.rb
+++ b/config/initializers/omniauth.rb
@@ -4,10 +4,12 @@ end
 
 Devise.setup do |config|
   # Devise omniauth strategies
+  options = {}
+  options[:redirect_at_sign_in] = ENV['OAUTH_REDIRECT_AT_SIGN_IN'] == 'true'
 
   # CAS strategy
   if ENV['CAS_ENABLED'] == 'true'
-    cas_options = {}
+    cas_options = options
     cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL']
     cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST']
     cas_options[:port] = ENV['CAS_PORT'] if ENV['CAS_PORT']
@@ -18,7 +20,7 @@ Devise.setup do |config|
     cas_options[:login_url] = ENV['CAS_LOGIN_URL'] if ENV['CAS_LOGIN_URL']
     cas_options[:uid_field] = ENV['CAS_UID_FIELD'] || 'user' if ENV['CAS_UID_FIELD']
     cas_options[:ca_path] = ENV['CAS_CA_PATH'] if ENV['CAS_CA_PATH']
-    cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true' if ENV['CAS_DISABLE_SSL_VERIFICATION']
+    cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true'
     cas_options[:uid_key] = ENV['CAS_UID_KEY'] || 'user'
     cas_options[:name_key] = ENV['CAS_NAME_KEY'] || 'name'
     cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email'
@@ -33,7 +35,7 @@ Devise.setup do |config|
 
   # SAML strategy
   if ENV['SAML_ENABLED'] == 'true'
-    saml_options = {}
+    saml_options = options
     saml_options[:assertion_consumer_service_url] = ENV['SAML_ACS_URL'] if ENV['SAML_ACS_URL']
     saml_options[:issuer] = ENV['SAML_ISSUER'] if ENV['SAML_ISSUER']
     saml_options[:idp_sso_target_url] = ENV['SAML_IDP_SSO_TARGET_URL']  if ENV['SAML_IDP_SSO_TARGET_URL']
@@ -48,10 +50,13 @@ Devise.setup do |config|
     saml_options[:security] = {}
     saml_options[:security][:want_assertions_signed] = ENV['SAML_SECURITY_WANT_ASSERTION_SIGNED'] == 'true'
     saml_options[:security][:want_assertions_encrypted] = ENV['SAML_SECURITY_WANT_ASSERTION_ENCRYPTED'] == 'true'
+    saml_options[:security][:assume_email_is_verified] = ENV['SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED'] == 'true'
     saml_options[:attribute_statements] = {}
     saml_options[:attribute_statements][:uid] = [ENV['SAML_ATTRIBUTES_STATEMENTS_UID']] if ENV['SAML_ATTRIBUTES_STATEMENTS_UID']
     saml_options[:attribute_statements][:email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']
     saml_options[:attribute_statements][:full_name] = [ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']] if ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']
+    saml_options[:attribute_statements][:verified] = [ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED']] if ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED']
+    saml_options[:attribute_statements][:verified_email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL']
     saml_options[:uid_attribute] = ENV['SAML_UID_ATTRIBUTE'] if ENV['SAML_UID_ATTRIBUTE']
     config.omniauth :saml, saml_options
   end
diff --git a/config/locales/ar.yml b/config/locales/ar.yml
index eadeaef3e..88b4d88bb 100644
--- a/config/locales/ar.yml
+++ b/config/locales/ar.yml
@@ -14,7 +14,6 @@ ar:
       humane_approach_title: أسلوب يعيد الإعتبار للإنسان
       not_a_product_title: إنك إنسان و لست سلعة
       real_conversation_title: مبني لتحقيق تواصل حقيقي
-    find_another_instance: إبحث عن مثيل خادوم آخر
     generic_description: "%{domain} هو سيرفر من بين سيرفرات الشبكة"
     hosted_on: ماستدون مُستضاف على %{domain}
     learn_more: تعلم المزيد
diff --git a/config/locales/ca.yml b/config/locales/ca.yml
index ffa639f6b..0357b4abb 100644
--- a/config/locales/ca.yml
+++ b/config/locales/ca.yml
@@ -23,7 +23,6 @@ ca:
       real_conversation_title: Construït per a converses reals
       within_reach_body: Diverses aplicacions per a iOS, Android i altres plataformes gràcies a un ecosistema API amable amb el desenvolupador, et permet mantenir-te al dia amb els amics en qualsevol lloc..
       within_reach_title: Sempre a l'abast
-    find_another_instance: Troba altres instàncies
     generic_description: "%{domain} és un servidor a la xarxa"
     hosted_on: Mastodon allotjat a %{domain}
     learn_more: Més informació
diff --git a/config/locales/de.yml b/config/locales/de.yml
index f03e393f5..8f17413e1 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -23,7 +23,6 @@ de:
       real_conversation_title: Für das echte Gespräch gemacht
       within_reach_body: Verschiedene Apps für iOS, Android und andere Plattformen erlauben dir dank unserem blühenden API-Ökosystem, dich von überall auf dem Laufenden zu halten.
       within_reach_title: Immer für dich da
-    find_another_instance: Eine andere Instanz finden
     generic_description: "%{domain} ist ein Server im Netzwerk"
     hosted_on: Mastodon, beherbergt auf %{domain}
     learn_more: Mehr erfahren
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 5f9c0b3c5..274710d8b 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -23,7 +23,6 @@ en:
       real_conversation_title: Built for real conversation
       within_reach_body: Multiple apps for iOS, Android, and other platforms thanks to a developer-friendly API ecosystem allow you to keep up with your friends anywhere.
       within_reach_title: Always within reach
-    find_another_instance: Find another instance
     generic_description: "%{domain} is one server in the network"
     hosted_on: Mastodon hosted on %{domain}
     learn_more: Learn more
@@ -274,6 +273,9 @@ en:
       contact_information:
         email: Business e-mail
         username: Contact username
+      hero:
+        desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
+        title: Hero image
       peers_api_enabled:
         desc_html: Domain names this instance has encountered in the fediverse
         title: Publish list of discovered instances
@@ -421,6 +423,13 @@ en:
       title: This page is not correct
     noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">native apps</a> for Mastodon for your platform.
   exports:
+    archive_takeout:
+      date: Date
+      download: Download your archive
+      hint_html: You can request an archive of your <strong>toots and uploaded media</strong>. The exported data will be in ActivityPub format, readable by any compliant software.
+      in_progress: Compiling your archive...
+      request: Request your archive
+      size: Size
     blocks: You block
     csv: CSV
     follows: You follow
@@ -742,6 +751,10 @@ en:
     setup: Set up
     wrong_code: The entered code was invalid! Are server time and device time correct?
   user_mailer:
+    backup_ready:
+      explanation: You requested a full backup of your Mastodon account. It's now ready for download!
+      subject: Your archive is ready for download
+      title: Archive takeout
     welcome:
       edit_profile_action: Setup profile
       edit_profile_step: You can customize your profile by uploading an avatar, header, changing your display name and more. If you’d like to review new followers before they’re allowed to follow you, you can lock your account.
diff --git a/config/locales/es.yml b/config/locales/es.yml
index a948e7629..102f9415e 100644
--- a/config/locales/es.yml
+++ b/config/locales/es.yml
@@ -23,7 +23,6 @@ es:
       real_conversation_title: Hecho para verdaderas conversaciones
       within_reach_body: Aplicaciones múltiples para iOS, Android, y otras plataformas gracias a un ecosistema de APIs amigable al desarrollador para permitirte estar con tus amigos donde sea.
       within_reach_title: Siempre al alcance
-    find_another_instance: Busca otra instancia
     generic_description: "%{domain} es un servidor en la red"
     hosted_on: Mastodon hosteado en %{domain}
     learn_more: Aprende más
diff --git a/config/locales/fa.yml b/config/locales/fa.yml
index c498c592c..395d226bd 100644
--- a/config/locales/fa.yml
+++ b/config/locales/fa.yml
@@ -23,7 +23,6 @@ fa:
       real_conversation_title: برای گفتگوهای واقعی
       within_reach_body: اپ‌های متنوع برای iOS، اندروید، و سیستم‌های دیگر به خاطر وجود یک اکوسیستم API دوستانه برای برنامه‌نویسان. از همه جا با دوستان خود ارتباط داشته باشید.
       within_reach_title: همیشه در دسترس
-    find_another_instance: یافتن سرورهای دیگر
     generic_description: "%{domain} یک سرور روی شبکه است"
     hosted_on: ماستدون، میزبانی‌شده روی %{domain}
     learn_more: بیشتر بدانید
diff --git a/config/locales/fi.yml b/config/locales/fi.yml
index 1067496c9..e9c7273ce 100644
--- a/config/locales/fi.yml
+++ b/config/locales/fi.yml
@@ -21,7 +21,6 @@ fi:
       real_conversation_title: Rakennettu oikealle keskustelulle
       within_reach_body: Kehittäjäystävällisen rajapintaekosysteemin ansiosta useita appeja Androidille, iOS:lle ja muille alustoille, jotka mahdollistavat yhteydenpidon ystäviesi kanssa missä vain.
       within_reach_title: Aina lähellä
-    find_another_instance: Löydä toinen instanssi
     learn_more: Lisätietoja
     other_instances: Muut palvelimet
     source_code: Lähdekoodi
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index dcfa35802..2030b282d 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -23,7 +23,6 @@ fr:
       real_conversation_title: Construit pour de vraies conversations
       within_reach_body: Grâce à l’existence d’un environnement API accueillant pour les développeur·se·s, de multiples applications pour iOS, Android et d’autres plateformes vous permettent de rester en contact avec vos ami·e·s où que vous soyez.
       within_reach_title: Toujours à portée de main
-    find_another_instance: Trouver une autre instance
     generic_description: "%{domain} est seulement un serveur du réseau"
     hosted_on: Instance Mastodon hébergée par %{domain}
     learn_more: En savoir plus
diff --git a/config/locales/gl.yml b/config/locales/gl.yml
index 40d72cbe4..5de6031fc 100644
--- a/config/locales/gl.yml
+++ b/config/locales/gl.yml
@@ -23,7 +23,6 @@ gl:
       real_conversation_title: Construído para conversacións reais
       within_reach_body: Existen múltiples aplicativos para iOS, Android e outras plataformas grazas a un entorno API amigable para o desenvolvedor que lle permite estar ao tanto cos seus amigos en calquer lugar.
       within_reach_title: Sempre en contacto
-    find_another_instance: Atope outra instancia
     generic_description: "%{domain} é un servidor na rede"
     hosted_on: Mastodon aloxado en %{domain}
     learn_more: Coñeza máis
diff --git a/config/locales/he.yml b/config/locales/he.yml
index 1f27dda7a..c83f4ba10 100644
--- a/config/locales/he.yml
+++ b/config/locales/he.yml
@@ -23,7 +23,6 @@ he:
       real_conversation_title: בנוי לשיחות אמתיות
       within_reach_body: שלל אפליקציות עבור iOS, אנדרואיד ופלטפורמות אחרות שיאפשרו לך לשמור על קשר עם חברים בכל מקום, תודות למערכת מנשקי תוכנה ידידותיים למפתחים.
       within_reach_title: תמיד במרחק נגיעה
-    find_another_instance: לאיתור שרת אחר
     generic_description: "%{domain} הוא שרת אחד בתוך הרשת"
     hosted_on: מסטודון שיושב בכתובת %{domain}
     learn_more: מידע נוסף
diff --git a/config/locales/hu.yml b/config/locales/hu.yml
index 6e39f9800..918e85d1f 100644
--- a/config/locales/hu.yml
+++ b/config/locales/hu.yml
@@ -23,7 +23,6 @@ hu:
       real_conversation_title: Valódi beszélgetésekre tervezve
       within_reach_body: A fejlesztőbarát API-nak köszönhetően számos iOS, Android és egyéb platformra írt alkalmazás teszi lehetővé, hogy bármikor, bárhonnan részt vehess a társalgásban.
       within_reach_title: Mindig elérhetőnek lenni
-    find_another_instance: További instanciák keresése
     generic_description: "%{domain} csak egy a számtalan szerver közül a föderációban"
     hosted_on: "%{domain} Mastodon instancia"
     learn_more: Tudj meg többet
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index 715826899..67feed0f0 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -23,7 +23,6 @@ ja:
       real_conversation_title: 本当のコミュニケーションのために
       within_reach_body: デベロッパーフレンドリーな API により実現された、iOS や Android、その他様々なプラットフォームのためのアプリでどこでも友人とやりとりできます。
       within_reach_title: いつでも身近に
-    find_another_instance: 他のインスタンスを探す
     generic_description: "%{domain} は、Mastodon インスタンスの一つです"
     hosted_on: Mastodon hosted on %{domain}
     learn_more: もっと詳しく
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 3fe9fc5fc..bb6e9a88e 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -23,7 +23,6 @@ ko:
       real_conversation_title: 진정한 커뮤니케이션을 위하여
       within_reach_body: 개발자 친화적인 API에 의해서 실현된 iOS나 Android, 그 외의 여러 Platform들 덕분에 어디서든 친구들과 자유롭게 메세지를 주고 받을 수 있습니다.
       within_reach_title: 언제나 유저의 곁에서
-    find_another_instance: 다른 인스턴스 찾기
     generic_description: "%{domain} 은 Mastodon의 인스턴스 입니다."
     hosted_on: "%{domain}에서 호스팅 되는 마스토돈"
     learn_more: 자세히
diff --git a/config/locales/nl.yml b/config/locales/nl.yml
index 7d2e27ad8..0db7127bb 100644
--- a/config/locales/nl.yml
+++ b/config/locales/nl.yml
@@ -23,7 +23,6 @@ nl:
       real_conversation_title: Voor echte gesprekken gemaakt
       within_reach_body: Meerdere apps voor iOS, Android en andere platformen, met dank aan het ontwikkelaarsvriendelijke API-systeem, zorgen ervoor dat je overal op de hoogte blijft.
       within_reach_title: Altijd binnen bereik
-    find_another_instance: Vind een andere server
     generic_description: "%{domain} is een server in het Mastodonnetwerk"
     hosted_on: Mastodon op %{domain}
     learn_more: Meer leren
diff --git a/config/locales/no.yml b/config/locales/no.yml
index 1e9597a53..d198177cd 100644
--- a/config/locales/no.yml
+++ b/config/locales/no.yml
@@ -23,7 +23,6 @@
       real_conversation_title: Laget for ekte samtaler
       within_reach_body: Takket være et utviklingsvennlig API-økosystem vil flere apper for iOS, Android og andre plattformer la deg holde kontakten med dine venner hvor som helst.
       within_reach_title: Alltid innen rekkevidde
-    find_another_instance: Finn en annen instans
     generic_description: "%{domain} er en tjener i nettverket"
     hosted_on: Mastodon driftet på %{domain}
     learn_more: Lær mer
diff --git a/config/locales/oc.yml b/config/locales/oc.yml
index 56554610d..80b103763 100644
--- a/config/locales/oc.yml
+++ b/config/locales/oc.yml
@@ -23,7 +23,6 @@ oc:
       real_conversation_title: Fach per de conversacions vertadièras
       within_reach_body: Multiplas aplicacion per iOS, Android, e autras plataformas mercés a un entorn API de bon utilizar, vos permet de gardar lo contacte pertot.
       within_reach_title: Totjorn al costat
-    find_another_instance: Trobar mai instàncias
     generic_description: "%{domain} es un dels servidors del malhum"
     hosted_on: Mastodon albergat sus %{domain}
     learn_more: Ne saber mai
diff --git a/config/locales/pl.yml b/config/locales/pl.yml
index 8d8675df9..678140374 100644
--- a/config/locales/pl.yml
+++ b/config/locales/pl.yml
@@ -23,7 +23,6 @@ pl:
       real_conversation_title: Zaprojektowany do prawdziwych rozmów
       within_reach_body: Wiele aplikacji dla Androida, iOS i innych platform dzięki przyjaznemu programistom API sprawia, że możesz utrzymywać kontakt ze znajomymi praktycznie wszędzie.
       within_reach_title: Zawsze w Twoim zasięgu
-    find_another_instance: Znajdź inną instancję
     generic_description: "%{domain} jest jednym z serwerów sieci"
     hosted_on: Mastodon uruchomiony na %{domain}
     learn_more: Dowiedz się więcej
@@ -275,6 +274,9 @@ pl:
       contact_information:
         email: Służbowy adres e-mail
         username: Nazwa użytkownika do kontaktu
+      hero:
+        desc_html: Wyświetlany na stronie głównej. Zalecany jest rozmiar przynajmniej 600x100 pikseli. Jeżeli nie ustawiony, zostanie użyta miniatura instancji.
+        title: Obraz bohatera
       peers_api_enabled:
         desc_html: Nazwy domen, z którymi ta instancja wchodziła w interakcje
         title: Publikuj listę znanych instancji
@@ -422,6 +424,13 @@ pl:
       title: Ta strona jest nieprawidłowa
     noscript_html: Aby korzystać z aplikacji Mastodon, włącz JavaScript. Możesz też skorzystać z jednej z <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">natywnych aplikacji</a> obsługującej Twoje urządzenie.
   exports:
+    archive_takeout:
+      date: Data
+      download: Pobierz swoje archiwum
+      hint_html: Możesz uzyskać archiwum swoich <strong>wpisów i wysłanej zawartości multimedialnej</strong>. Wyeksportowane dane będą dostępne w formacie ActivityPub, obsługiwanym przez odpowiednie programy.
+      in_progress: Tworzenie archiwum…
+      request: Uzyskaj archiwum
+      size: Rozmiar
     blocks: Zablokowani
     csv: CSV
     follows: Śledzeni
@@ -749,6 +758,10 @@ pl:
     setup: Skonfiguruj
     wrong_code: Wprowadzony kod jest niepoprawny! Czy czas serwera i urządzenia jest poprawny?
   user_mailer:
+    backup_ready:
+      explanation: Zażądałeś pełnej kopii zapasowej konta na Mastodonie. Jest ono dostępne do pobrania
+      subject: Twoje archiwum jest gotowe do pobrania
+      title: Odbiór archiwum
     welcome:
       edit_profile_action: Skonfiguruj profil
       edit_profile_step: Możesz dostować profil wysyłając awatar, obraz nagłówka, zmieniając wyświetlaną nazwę i wiele więcej. Jeżeli chcesz, możesz zablokować konto, aby kontrolować, kto może Cię śledzić.
diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml
index 6b911a9a9..cc3876a6c 100644
--- a/config/locales/pt-BR.yml
+++ b/config/locales/pt-BR.yml
@@ -23,7 +23,6 @@ pt-BR:
       real_conversation_title: Feito para conversas reais
       within_reach_body: Vários apps para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores permitem que você possa se manter atualizado sobre seus amigos de qualquer lugar.
       within_reach_title: Sempre ao seu alcance
-    find_another_instance: Encontre outra instância
     generic_description: "%{domain} é um servidor na rede"
     hosted_on: Mastodon hospedado em %{domain}
     learn_more: Saiba mais
diff --git a/config/locales/pt.yml b/config/locales/pt.yml
index 93eaf84d6..c77368e3f 100644
--- a/config/locales/pt.yml
+++ b/config/locales/pt.yml
@@ -23,7 +23,6 @@ pt:
       real_conversation_title: Feito para conversas reais
       within_reach_body: Várias aplicações para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores, permitem-te que te mantenhas em contacto com os teus amigos em qualquer lugar.
       within_reach_title: Sempre ao teu alcance
-    find_another_instance: Encontra outra instância
     generic_description: "%{domain} é um servidor na rede"
     hosted_on: Mastodon em %{domain}
     learn_more: Saber mais
diff --git a/config/locales/ru.yml b/config/locales/ru.yml
index 842fd7d54..467f24ca8 100644
--- a/config/locales/ru.yml
+++ b/config/locales/ru.yml
@@ -23,7 +23,6 @@ ru:
       real_conversation_title: Создан для настоящего общения
       within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно.
       within_reach_title: Всегда под рукой
-    find_another_instance: Найти другой узел
     generic_description: "%{domain} - один из серверов сети"
     hosted_on: Mastodon размещен на %{domain}
     learn_more: Узнать больше
diff --git a/config/locales/sk.yml b/config/locales/sk.yml
index 84433a209..853988546 100644
--- a/config/locales/sk.yml
+++ b/config/locales/sk.yml
@@ -23,7 +23,6 @@ sk:
       real_conversation_title: Vytvorený pre reálnu konverzáciu
       within_reach_body: Viacero aplikácií pre iOS, Android a iné platformy, ktoré vďaka jednoduchému API ekosystému vám dovoľujú byť online so svojimi priateľmi kdekoľvek.
       within_reach_title: Stále v dosahu
-    find_another_instance: Nájdi inú inštanciu
     generic_description: "%{domain} je jeden server v sieti"
     hosted_on: Mastodon hostovaný na %{domain}
     learn_more: Dozvedieť sa viac
diff --git a/config/locales/sr-Latn.yml b/config/locales/sr-Latn.yml
index ac80e81ec..4eed44345 100644
--- a/config/locales/sr-Latn.yml
+++ b/config/locales/sr-Latn.yml
@@ -23,7 +23,6 @@ sr-Latn:
       real_conversation_title: Pravljen za pravi razgovor
       within_reach_body: Više aplikacija za iOS, Android, kao i druge platforme zahvaljujući ekosistemu dobrih API-ja će Vam omogućiti da ostanete u kontaktu sa prijateljima svuda.
       within_reach_title: Uvek u kontaktu
-    find_another_instance: Nađite drugu instancu
     generic_description: "%{domain} je server na mreži"
     hosted_on: Mastodont hostovan na %{domain}
     learn_more: Saznajte više
diff --git a/config/locales/sr.yml b/config/locales/sr.yml
index 755396828..c56498765 100644
--- a/config/locales/sr.yml
+++ b/config/locales/sr.yml
@@ -23,7 +23,6 @@ sr:
       real_conversation_title: Прављен за прави разговор
       within_reach_body: Више апликација за iOS, Андроид, као и друге платформе захваљујући екосистему добрих API-ја ће Вам омогућити да останете у контакту са пријатељима свуда.
       within_reach_title: Увек у контакту
-    find_another_instance: Нађите другу инстанцу
     generic_description: "%{domain} је сервер на мрежи"
     hosted_on: Мастодонт хостован на %{domain}
     learn_more: Сазнајте више
diff --git a/config/locales/sv.yml b/config/locales/sv.yml
index 79ffa9387..d20e8ba9f 100644
--- a/config/locales/sv.yml
+++ b/config/locales/sv.yml
@@ -23,7 +23,6 @@ sv:
       real_conversation_title: Byggd för riktiga konversationer
       within_reach_body: Flera appar för iOS, Android och andra plattformar tack vare ett utvecklingsvänligt API-ekosystem gör att du kan hålla kontakten med dina vänner var som helst.
       within_reach_title: Alltid inom räckhåll
-    find_another_instance: Hitta en annan instans
     generic_description: "%{domain} är en server i nätverket"
     hosted_on: Mastodon värd på %{domain}
     learn_more: Lär dig mer
diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml
index 7deb241a1..1bd2e5039 100644
--- a/config/locales/zh-CN.yml
+++ b/config/locales/zh-CN.yml
@@ -23,7 +23,6 @@ zh-CN:
       real_conversation_title: 为真正的交流而生
       within_reach_body: 通过一个面向开发者友好的 API 生态系统,Mastodon 让你可以随时随地通过众多 iOS、Android 以及其他平台的应用与朋友们保持联系。
       within_reach_title: 始终触手可及
-    find_another_instance: 寻找另一个实例
     generic_description: "%{domain} 是这个庞大网络中的一台服务器"
     hosted_on: 一个在 %{domain} 上运行的 Mastodon 实例
     learn_more: 详细了解
diff --git a/config/routes.rb b/config/routes.rb
index eea11b499..903f4553e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -83,7 +83,7 @@ Rails.application.routes.draw do
     resource :notifications, only: [:show, :update]
     resource :import, only: [:show, :create]
 
-    resource :export, only: [:show]
+    resource :export, only: [:show, :create]
     namespace :exports, constraints: { format: :csv } do
       resources :follows, only: :index, controller: :following_accounts
       resources :blocks, only: :index, controller: :blocked_accounts
diff --git a/config/sidekiq.yml b/config/sidekiq.yml
index bfe29b8f8..244e9ea48 100644
--- a/config/sidekiq.yml
+++ b/config/sidekiq.yml
@@ -30,3 +30,6 @@
   email_scheduler:
     cron: '0 10 * * 2'
     class: Scheduler::EmailScheduler
+  backup_cleanup_scheduler:
+    cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
+    class: Scheduler::BackupCleanupScheduler
diff --git a/db/migrate/20180211015820_create_backups.rb b/db/migrate/20180211015820_create_backups.rb
new file mode 100644
index 000000000..9725a3e9f
--- /dev/null
+++ b/db/migrate/20180211015820_create_backups.rb
@@ -0,0 +1,11 @@
+class CreateBackups < ActiveRecord::Migration[5.1]
+  def change
+    create_table :backups do |t|
+      t.references :user, foreign_key: { on_delete: :nullify }
+      t.attachment :dump
+      t.boolean :processed, null: false, default: false
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c118377df..a22e86837 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20180206000000) do
+ActiveRecord::Schema.define(version: 20180211015820) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -92,6 +92,18 @@ ActiveRecord::Schema.define(version: 20180206000000) do
     t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
   end
 
+  create_table "backups", force: :cascade do |t|
+    t.bigint "user_id"
+    t.string "dump_file_name"
+    t.string "dump_content_type"
+    t.integer "dump_file_size"
+    t.datetime "dump_updated_at"
+    t.boolean "processed", default: false, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["user_id"], name: "index_backups_on_user_id"
+  end
+
   create_table "blocks", force: :cascade do |t|
     t.datetime "created_at", null: false
     t.datetime "updated_at", null: false
diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh
deleted file mode 100644
index 1af5dde64..000000000
--- a/docker_entrypoint.sh
+++ /dev/null
@@ -1,14 +0,0 @@
-#!/bin/sh
-
-### 1. Adds local user (UID and GID are provided from environment variables).
-### 2. Updates permissions, except for ./public/system (should be chown on previous installations).
-### 3. Executes the command as that user.
-
-echo "Creating mastodon user (UID : ${UID} and GID : ${GID})..."
-addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon
-
-echo "Updating permissions..."
-find /mastodon -path /mastodon/public/system -prune -o -not -user mastodon -not -group mastodon -print0 | xargs -0 chown -f mastodon:mastodon
-
-echo "Executing process..."
-LD_PRELOAD=/lib/stack-fix.so exec su-exec mastodon:mastodon /sbin/tini -- "$@"
diff --git a/lib/paperclip/gif_transcoder.rb b/lib/paperclip/gif_transcoder.rb
index 629e37581..62787983c 100644
--- a/lib/paperclip/gif_transcoder.rb
+++ b/lib/paperclip/gif_transcoder.rb
@@ -16,7 +16,7 @@ module Paperclip
 
       final_file = Paperclip::Transcoder.make(file, options, attachment)
 
-      attachment.instance.file_file_name    = 'media.mp4'
+      attachment.instance.file_file_name    = File.basename(attachment.instance.file_file_name, '.*') + '.mp4'
       attachment.instance.file_content_type = 'video/mp4'
       attachment.instance.type              = MediaAttachment.types[:gifv]
 
diff --git a/lib/paperclip/lazy_thumbnail.rb b/lib/paperclip/lazy_thumbnail.rb
new file mode 100644
index 000000000..42f9a557a
--- /dev/null
+++ b/lib/paperclip/lazy_thumbnail.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Paperclip
+  class LazyThumbnail < Paperclip::Thumbnail
+    def make
+      return File.open(@file.path) unless needs_convert?
+      Paperclip::Thumbnail.make(file, options, attachment)
+    end
+
+    private
+
+    def needs_convert?
+      needs_different_geometry? || needs_different_format?
+    end
+
+    def needs_different_geometry?
+      !@target_geometry.nil? && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height
+    end
+
+    def needs_different_format?
+      @format.present? && @current_format != @format
+    end
+  end
+end
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index e144621e5..d2e4f38a9 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -752,6 +752,7 @@ namespace :mastodon do
 
         if [404, 410].include?(res.code)
           if options[:force]
+            SuspendAccountService.new.call(account)
             account.destroy
           else
             progress_bar.pause
@@ -764,6 +765,7 @@ namespace :mastodon do
             if confirm.casecmp('n').zero?
               next
             else
+              SuspendAccountService.new.call(account)
               account.destroy
             end
           end
diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
index 508415fc8..e0de790c8 100644
--- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb
@@ -66,6 +66,28 @@ describe Api::V1::Accounts::RelationshipsController do
         expect(json.second[:requested]).to be false
         expect(json.second[:domain_blocking]).to be false
       end
+
+      it 'returns JSON with correct data on cached requests too' do
+        get :index, params: { id: [simon.id] }
+
+        json = body_as_json
+
+        expect(json).to be_a Enumerable
+        expect(json.first[:following]).to be true
+        expect(json.first[:showing_reblogs]).to be true
+      end
+
+      it 'returns JSON with correct data after change too' do
+        user.account.unfollow!(simon)
+
+        get :index, params: { id: [simon.id] }
+
+        json = body_as_json
+
+        expect(json).to be_a Enumerable
+        expect(json.first[:following]).to be false
+        expect(json.first[:showing_reblogs]).to be false
+      end
     end
   end
 end
diff --git a/spec/fabricators/backup_fabricator.rb b/spec/fabricators/backup_fabricator.rb
new file mode 100644
index 000000000..99a5bdcda
--- /dev/null
+++ b/spec/fabricators/backup_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:backup) do
+  user
+end
diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb
index 8d2a9368d..d9cdb9264 100644
--- a/spec/mailers/previews/user_mailer_preview.rb
+++ b/spec/mailers/previews/user_mailer_preview.rb
@@ -34,4 +34,9 @@ class UserMailerPreview < ActionMailer::Preview
   def welcome
     UserMailer.welcome(User.first)
   end
+
+  # Preview this email at http://localhost:3000/rails/mailers/user_mailer/backup_ready
+  def backup_ready
+    UserMailer.backup_ready(User.first, Backup.first)
+  end
 end
diff --git a/spec/models/backup_spec.rb b/spec/models/backup_spec.rb
new file mode 100644
index 000000000..fabcdc845
--- /dev/null
+++ b/spec/models/backup_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe Backup, type: :model do
+
+end
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index ca59fa9e3..be1320ed3 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -18,6 +18,9 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
                                 source_url: 'https://github.com/tootsuite/mastodon',
                                 open_registrations: false,
                                 thumbnail: nil,
+                                hero: nil,
+                                user_count: 0,
+                                status_count: 0,
                                 closed_registrations_message: 'yes',
                                 commit_hash: commit_hash)