about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.env.production.sample1
-rw-r--r--app/controllers/admin/statuses_controller.rb4
-rw-r--r--app/helpers/admin/action_logs_helper.rb6
-rw-r--r--app/javascript/core/admin.js3
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js12
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js6
-rw-r--r--app/javascript/mastodon/features/status/index.js4
-rw-r--r--app/javascript/mastodon/features/video/index.js20
-rw-r--r--app/javascript/styles/mastodon/components.scss20
-rw-r--r--app/javascript/styles/mastodon/tables.scss16
-rw-r--r--app/lib/activitypub/activity/follow.rb2
-rw-r--r--app/lib/activitypub/activity/undo.rb6
-rw-r--r--app/models/export.rb10
-rw-r--r--app/models/favourite.rb13
-rw-r--r--app/models/follow.rb5
-rw-r--r--app/models/status.rb72
-rw-r--r--app/models/status_stat.rb17
-rw-r--r--app/serializers/rest/status_serializer.rb3
-rw-r--r--app/services/authorize_follow_service.rb2
-rw-r--r--app/services/resolve_url_service.rb10
-rw-r--r--app/services/search_service.rb2
-rw-r--r--app/views/accounts/_header.html.haml6
-rw-r--r--app/views/admin/reports/_status.html.haml12
-rw-r--r--app/views/settings/exports/show.html.haml14
-rw-r--r--app/views/settings/imports/show.html.haml11
-rw-r--r--config/initializers/devise.rb3
-rw-r--r--config/locales/en.yml2
-rw-r--r--db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb2
-rw-r--r--db/migrate/20180528141303_fix_accounts_unique_index.rb4
-rw-r--r--db/migrate/20180812162710_create_status_stats.rb12
-rw-r--r--db/migrate/20180812173710_copy_status_stats.rb19
-rw-r--r--db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb23
-rw-r--r--db/post_migrate/20180813113448_copy_status_stats_cleanup.rb12
-rw-r--r--db/schema.rb16
-rw-r--r--lib/devise/ldap_authenticatable.rb3
-rw-r--r--spec/fabricators/status_stat_fabricator.rb6
-rw-r--r--spec/lib/activitypub/activity/undo_spec.rb26
-rw-r--r--spec/models/export_spec.rb6
-rw-r--r--spec/models/follow_spec.rb16
-rw-r--r--spec/models/status_stat_spec.rb5
-rw-r--r--spec/services/search_service_spec.rb6
41 files changed, 350 insertions, 88 deletions
diff --git a/.env.production.sample b/.env.production.sample
index 1f952be06..235b19207 100644
--- a/.env.production.sample
+++ b/.env.production.sample
@@ -165,6 +165,7 @@ STREAMING_CLUSTER_NUM=1
 # LDAP_BIND_DN=
 # LDAP_PASSWORD=
 # LDAP_UID=cn
+# LDAP_SEARCH_FILTER="%{uid}=%{email}"
 
 # PAM authentication (optional)
 # PAM authentication uses for the email generation the "email" pam variable
diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb
index 382bfc4a2..a69f12084 100644
--- a/app/controllers/admin/statuses_controller.rb
+++ b/app/controllers/admin/statuses_controller.rb
@@ -29,6 +29,10 @@ module Admin
       flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
 
       redirect_to admin_account_statuses_path(@account.id, current_params)
+    rescue ActionController::ParameterMissing
+      flash[:alert] = I18n.t('admin.statuses.no_status_selected')
+
+      redirect_to admin_account_statuses_path(@account.id, current_params)
     end
 
     private
diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb
index 4c663211e..85bd30304 100644
--- a/app/helpers/admin/action_logs_helper.rb
+++ b/app/helpers/admin/action_logs_helper.rb
@@ -34,7 +34,11 @@ module Admin::ActionLogsHelper
       link_to attributes['domain'], "https://#{attributes['domain']}"
     when 'Status'
       tmp_status = Status.new(attributes)
-      link_to tmp_status.account&.acct || "##{tmp_status.account_id}", TagManager.instance.url_for(tmp_status)
+      if tmp_status.account
+        link_to tmp_status.account&.acct || "##{tmp_status.account_id}", admin_account_path(tmp_status.account_id)
+      else
+        I18n.t('admin.action_logs.deleted_status')
+      end
     end
   end
 
diff --git a/app/javascript/core/admin.js b/app/javascript/core/admin.js
index 3302454ab..28f27fbc6 100644
--- a/app/javascript/core/admin.js
+++ b/app/javascript/core/admin.js
@@ -1,9 +1,6 @@
 //  This file will be loaded on admin pages, regardless of theme.
 
 import { delegate } from 'rails-ujs';
-import { start } from '../mastodon/common';
-
-start();
 
 function handleDeleteStatus(event) {
   const [data] = event.detail;
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 0ae21e3f0..c799d4e98 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -32,6 +32,16 @@ const messages = defineMessages({
   embed: { id: 'status.embed', defaultMessage: 'Embed' },
 });
 
+const obfuscatedCount = count => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
 @injectIntl
 export default class StatusActionBar extends ImmutablePureComponent {
 
@@ -194,7 +204,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
 
     return (
       <div className='status__action-bar'>
-        <IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} />
+        <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' disabled={anonymousAccess} title={replyTitle} icon={replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
         <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' disabled={anonymousAccess} animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
         {shareButton}
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
index 43b4811e1..bc6f86628 100644
--- a/app/javascript/mastodon/features/account/components/action_bar.js
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -147,17 +147,17 @@ export default class ActionBar extends React.PureComponent {
 
         <div className='account__action-bar'>
           <div className='account__action-bar-links'>
-            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
               <FormattedMessage id='account.posts' defaultMessage='Toots' />
               <strong>{shortNumberFormat(account.get('statuses_count'))}</strong>
             </Link>
 
-            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
               <FormattedMessage id='account.follows' defaultMessage='Follows' />
               <strong>{shortNumberFormat(account.get('following_count'))}</strong>
             </Link>
 
-            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+            <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
               <FormattedMessage id='account.followers' defaultMessage='Followers' />
               <strong>{shortNumberFormat(account.get('followers_count'))}</strong>
             </Link>
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 89387ca43..0ffeaa4dc 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -355,7 +355,9 @@ export default class Status extends ImmutablePureComponent {
     if (status && ancestorsIds && ancestorsIds.size > 0) {
       const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
 
-      element.scrollIntoView(true);
+      window.requestAnimationFrame(() => {
+        element.scrollIntoView(true);
+      });
       this._scrolledIntoView = true;
     }
   }
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 47a165e16..55ea32acb 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -158,6 +158,9 @@ export default class Video extends React.PureComponent {
     this.setState({ dragging: true });
     this.video.pause();
     this.handleMouseMove(e);
+
+    e.preventDefault();
+    e.stopPropagation();
   }
 
   handleMouseUp = () => {
@@ -174,8 +177,10 @@ export default class Video extends React.PureComponent {
     const { x } = getPointerPosition(this.seek, e);
     const currentTime = Math.floor(this.video.duration * x);
 
-    this.video.currentTime = currentTime;
-    this.setState({ currentTime });
+    if (!isNaN(currentTime)) {
+      this.video.currentTime = currentTime;
+      this.setState({ currentTime });
+    }
   }, 60);
 
   togglePlay = () => {
@@ -281,6 +286,15 @@ export default class Video extends React.PureComponent {
       playerStyle.height = height;
     }
 
+    let preload;
+    if (startTime || fullscreen || dragging) {
+      preload = 'auto';
+    } else if (detailed) {
+      preload = 'metadata';
+    } else {
+      preload = 'none';
+    }
+
     return (
       <div
         role='menuitem'
@@ -296,7 +310,7 @@ export default class Video extends React.PureComponent {
           ref={this.setVideoRef}
           src={src}
           poster={preview}
-          preload={startTime ? 'auto' : 'none'}
+          preload={preload}
           loop
           role='button'
           tabIndex='0'
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 8067b80bb..547bcfd1e 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -921,15 +921,31 @@
   align-items: center;
   display: flex;
   margin-top: 8px;
+
+  &__counter {
+    display: inline-flex;
+    margin-right: 11px;
+    align-items: center;
+
+    .status__action-bar-button {
+      margin-right: 4px;
+    }
+
+    &__label {
+      display: inline-block;
+      width: 14px;
+      font-size: 12px;
+      font-weight: 500;
+      color: $action-button-color;
+    }
+  }
 }
 
 .status__action-bar-button {
-  float: left;
   margin-right: 18px;
 }
 
 .status__action-bar-dropdown {
-  float: left;
   height: 23.15px;
   width: 23.15px;
 }
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index e54c55947..c2206cf55 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -1,9 +1,3 @@
-@keyframes Swag {
-  0% { background-position: 0% 0%; }
-  50% { background-position: 100% 0%; }
-  100% { background-position: 200% 0%; }
-}
-
 .table {
   width: 100%;
   max-width: 100%;
@@ -191,14 +185,12 @@ a.table-action-link {
   .status__content {
     padding-top: 0;
 
+    summary {
+      display: list-item;
+    }
+
     strong {
       font-weight: 700;
-      background: linear-gradient(to right, orange , yellow, green, cyan, blue, violet,orange , yellow, green, cyan, blue, violet);
-      background-size: 200% 100%;
-      -webkit-background-clip: text;
-      background-clip: text;
-      color: transparent;
-      animation: Swag 2s linear 0s infinite;
     }
   }
 }
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
index 826dcf18e..c45832648 100644
--- a/app/lib/activitypub/activity/follow.rb
+++ b/app/lib/activitypub/activity/follow.rb
@@ -13,7 +13,7 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
 
     # Fast-forward repeat follow requests
     if @account.following?(target_account)
-      AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
+      AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true, follow_request_uri: @json['id'])
       return
     end
 
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
index cbed417c4..64c2be7d9 100644
--- a/app/lib/activitypub/activity/undo.rb
+++ b/app/lib/activitypub/activity/undo.rb
@@ -5,6 +5,8 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
     case @object['type']
     when 'Announce'
       undo_announce
+    when 'Accept'
+      undo_accept
     when 'Follow'
       undo_follow
     when 'Like'
@@ -27,6 +29,10 @@ class ActivityPub::Activity::Undo < ActivityPub::Activity
     end
   end
 
+  def undo_accept
+    ::Follow.find_by(target_account: @account, uri: target_uri)&.revoke_request!
+  end
+
   def undo_follow
     target_account = account_from_uri(target_uri)
 
diff --git a/app/models/export.rb b/app/models/export.rb
index f0d5dd255..0eeac0dc0 100644
--- a/app/models/export.rb
+++ b/app/models/export.rb
@@ -24,8 +24,16 @@ class Export
     account.media_attachments.sum(:file_file_size)
   end
 
+  def total_statuses
+    account.statuses_count
+  end
+
   def total_follows
-    account.following.count
+    account.following_count
+  end
+
+  def total_followers
+    account.followers_count
   end
 
   def total_blocks
diff --git a/app/models/favourite.rb b/app/models/favourite.rb
index 0fce82f6f..ce7a6a336 100644
--- a/app/models/favourite.rb
+++ b/app/models/favourite.rb
@@ -32,20 +32,11 @@ class Favourite < ApplicationRecord
   private
 
   def increment_cache_counters
-    if association(:status).loaded?
-      status.update_attribute(:favourites_count, status.favourites_count + 1)
-    else
-      Status.where(id: status_id).update_all('favourites_count = COALESCE(favourites_count, 0) + 1')
-    end
+    status.increment_count!(:favourites_count)
   end
 
   def decrement_cache_counters
     return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
-
-    if association(:status).loaded?
-      status.update_attribute(:favourites_count, [status.favourites_count - 1, 0].max)
-    else
-      Status.where(id: status_id).update_all('favourites_count = GREATEST(COALESCE(favourites_count, 0) - 1, 0)')
-    end
+    status.decrement_count!(:favourites_count)
   end
 end
diff --git a/app/models/follow.rb b/app/models/follow.rb
index 3fce14b9a..714f4e898 100644
--- a/app/models/follow.rb
+++ b/app/models/follow.rb
@@ -32,6 +32,11 @@ class Follow < ApplicationRecord
     false # Force uri_for to use uri attribute
   end
 
+  def revoke_request!
+    FollowRequest.create!(account: account, target_account: target_account, show_reblogs: show_reblogs, uri: uri)
+    destroy!
+  end
+
   before_validation :set_uri, only: :create
   after_destroy :remove_endorsements
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 398462302..01615c876 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -15,8 +15,6 @@
 #  visibility             :integer          default("public"), not null
 #  spoiler_text           :text             default(""), not null
 #  reply                  :boolean          default(FALSE), not null
-#  favourites_count       :integer          default(0), not null
-#  reblogs_count          :integer          default(0), not null
 #  language               :string
 #  conversation_id        :bigint(8)
 #  local                  :boolean
@@ -28,6 +26,8 @@
 #
 
 class Status < ApplicationRecord
+  self.cache_versioning = false
+
   include Paginable
   include Streamable
   include Cacheable
@@ -62,6 +62,7 @@ class Status < ApplicationRecord
 
   has_one :notification, as: :activity, dependent: :destroy
   has_one :stream_entry, as: :activity, inverse_of: :status
+  has_one :status_stat, inverse_of: :status
 
   validates :uri, uniqueness: true, presence: true, unless: :local?
   validates :text, presence: true, unless: -> { with_media? || reblog? }
@@ -86,7 +87,25 @@ class Status < ApplicationRecord
 
   scope :not_local_only, -> { where(local_only: [false, nil]) }
 
-  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
+  cache_associated :account,
+                   :application,
+                   :media_attachments,
+                   :conversation,
+                   :status_stat,
+                   :tags,
+                   :stream_entry,
+                   mentions: :account,
+                   reblog: [
+                     :account,
+                     :application,
+                     :stream_entry,
+                     :tags,
+                     :media_attachments,
+                     :conversation,
+                     :status_stat,
+                     mentions: :account,
+                   ],
+                   thread: :account
 
   delegate :domain, to: :account, prefix: true
 
@@ -180,6 +199,26 @@ class Status < ApplicationRecord
     @marked_for_mass_destruction
   end
 
+  def replies_count
+    status_stat&.replies_count || 0
+  end
+
+  def reblogs_count
+    status_stat&.reblogs_count || 0
+  end
+
+  def favourites_count
+    status_stat&.favourites_count || 0
+  end
+
+  def increment_count!(key)
+    update_status_stat!(key => public_send(key) + 1)
+  end
+
+  def decrement_count!(key)
+    update_status_stat!(key => [public_send(key) - 1, 0].max)
+  end
+
   after_create  :increment_counter_caches
   after_destroy :decrement_counter_caches
 
@@ -197,6 +236,10 @@ class Status < ApplicationRecord
   before_validation :set_local
 
   class << self
+    def cache_ids
+      left_outer_joins(:status_stat).select('statuses.id, greatest(statuses.updated_at, status_stats.updated_at) AS updated_at')
+    end
+
     def in_chosen_languages(account)
       where(language: nil).or where(language: account.chosen_languages)
     end
@@ -372,6 +415,11 @@ class Status < ApplicationRecord
 
   private
 
+  def update_status_stat!(attrs)
+    record = status_stat || build_status_stat
+    record.update(attrs)
+  end
+
   def store_uri
     update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
   end
@@ -434,13 +482,8 @@ class Status < ApplicationRecord
       Account.where(id: account_id).update_all('statuses_count = COALESCE(statuses_count, 0) + 1')
     end
 
-    return unless reblog?
-
-    if association(:reblog).loaded?
-      reblog.update_attribute(:reblogs_count, reblog.reblogs_count + 1)
-    else
-      Status.where(id: reblog_of_id).update_all('reblogs_count = COALESCE(reblogs_count, 0) + 1')
-    end
+    reblog.increment_count!(:reblogs_count) if reblog?
+    thread.increment_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 
   def decrement_counter_caches
@@ -452,12 +495,7 @@ class Status < ApplicationRecord
       Account.where(id: account_id).update_all('statuses_count = GREATEST(COALESCE(statuses_count, 0) - 1, 0)')
     end
 
-    return unless reblog?
-
-    if association(:reblog).loaded?
-      reblog.update_attribute(:reblogs_count, [reblog.reblogs_count - 1, 0].max)
-    else
-      Status.where(id: reblog_of_id).update_all('reblogs_count = GREATEST(COALESCE(reblogs_count, 0) - 1, 0)')
-    end
+    reblog.decrement_count!(:reblogs_count) if reblog?
+    thread.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
   end
 end
diff --git a/app/models/status_stat.rb b/app/models/status_stat.rb
new file mode 100644
index 000000000..9d358776b
--- /dev/null
+++ b/app/models/status_stat.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_stats
+#
+#  id               :bigint(8)        not null, primary key
+#  status_id        :bigint(8)        not null
+#  replies_count    :bigint(8)        default(0), not null
+#  reblogs_count    :bigint(8)        default(0), not null
+#  favourites_count :bigint(8)        default(0), not null
+#  created_at       :datetime         not null
+#  updated_at       :datetime         not null
+#
+
+class StatusStat < ApplicationRecord
+  belongs_to :status, inverse_of: :status_stat
+end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index c8fffcf68..89776b1fc 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -3,7 +3,8 @@
 class REST::StatusSerializer < ActiveModel::Serializer
   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
              :sensitive, :spoiler_text, :visibility, :language,
-             :uri, :content, :url, :reblogs_count, :favourites_count
+             :uri, :content, :url, :replies_count, :reblogs_count,
+             :favourites_count
 
   attribute :favourited, if: :current_user?
   attribute :reblogged, if: :current_user?
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index f47d488f1..1674239df 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -3,7 +3,7 @@
 class AuthorizeFollowService < BaseService
   def call(source_account, target_account, **options)
     if options[:skip_follow_request]
-      follow_request = FollowRequest.new(account: source_account, target_account: target_account)
+      follow_request = FollowRequest.new(account: source_account, target_account: target_account, uri: options[:follow_request_uri])
     else
       follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
       follow_request.authorize!
diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb
index a068c1ed8..1db1917e2 100644
--- a/app/services/resolve_url_service.rb
+++ b/app/services/resolve_url_service.rb
@@ -2,11 +2,13 @@
 
 class ResolveURLService < BaseService
   include JsonLdHelper
+  include Authorization
 
   attr_reader :url
 
-  def call(url)
+  def call(url, on_behalf_of: nil)
     @url = url
+    @on_behalf_of = on_behalf_of
 
     return process_local_url if local_url?
 
@@ -84,6 +86,10 @@ class ResolveURLService < BaseService
 
   def check_local_status(status)
     return if status.nil?
-    status if status.public_visibility? || status.unlisted_visibility?
+    authorize_with @on_behalf_of, status, :show?
+    status
+  rescue Mastodon::NotPermittedError
+    # Do not disclose the existence of status the user is not authorized to see
+    nil
   end
 end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 5bb395942..cc1fcb52f 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -53,7 +53,7 @@ class SearchService < BaseService
   end
 
   def url_resource
-    @_url_resource ||= ResolveURLService.new.call(query)
+    @_url_resource ||= ResolveURLService.new.call(query, on_behalf_of: @account)
   end
 
   def url_resource_symbol
diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml
index d3b9893c4..caf03bd7c 100644
--- a/app/views/accounts/_header.html.haml
+++ b/app/views/accounts/_header.html.haml
@@ -14,17 +14,17 @@
       .public-account-header__tabs__tabs
         .details-counters
           .counter{ class: active_nav_class(short_account_url(account)) }
-            = link_to short_account_url(account), class: 'u-url u-uid' do
+            = link_to short_account_url(account), class: 'u-url u-uid', title: number_with_delimiter(account.statuses_count) do
               %span.counter-number= number_to_human account.statuses_count, strip_insignificant_zeros: true
               %span.counter-label= t('accounts.posts')
 
           .counter{ class: active_nav_class(account_following_index_url(account)) }
-            = link_to account_following_index_url(account) do
+            = link_to account_following_index_url(account), title: number_with_delimiter(account.following_count) do
               %span.counter-number= number_to_human account.following_count, strip_insignificant_zeros: true
               %span.counter-label= t('accounts.following')
 
           .counter{ class: active_nav_class(account_followers_url(account)) }
-            = link_to account_followers_url(account) do
+            = link_to account_followers_url(account), title: number_with_delimiter(account.followers_count) do
               %span.counter-number= number_to_human account.followers_count, strip_insignificant_zeros: true
               %span.counter-label= t('accounts.followers')
         .spacer
diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml
index 5e174f312..5b410ec84 100644
--- a/app/views/admin/reports/_status.html.haml
+++ b/app/views/admin/reports/_status.html.haml
@@ -3,11 +3,13 @@
     = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
   .batch-table__row__content
     .status__content><
-      - unless status.proper.spoiler_text.blank?
-        %p><
-          %strong> Content warning: #{Formatter.instance.format_spoiler(status.proper)}
-
-      = Formatter.instance.format(status.proper, custom_emojify: true)
+      - if status.proper.spoiler_text.blank?
+        = Formatter.instance.format(status.proper, custom_emojify: true)
+      - else
+        %details<
+          %summary><
+            %strong> Content warning: #{Formatter.instance.format_spoiler(status.proper)}
+          = Formatter.instance.format(status.proper, custom_emojify: true)
 
     - unless status.proper.media_attachments.empty?
       - if status.proper.media_attachments.first.video?
diff --git a/app/views/settings/exports/show.html.haml b/app/views/settings/exports/show.html.haml
index 30cd26914..ef2d2b894 100644
--- a/app/views/settings/exports/show.html.haml
+++ b/app/views/settings/exports/show.html.haml
@@ -9,16 +9,24 @@
         %td= number_to_human_size @export.total_storage
         %td
       %tr
+        %th= t('accounts.statuses')
+        %td= number_with_delimiter @export.total_statuses
+        %td
+      %tr
         %th= t('exports.follows')
-        %td= number_to_human @export.total_follows
+        %td= number_with_delimiter @export.total_follows
         %td= table_link_to 'download', t('exports.csv'), settings_exports_follows_path(format: :csv)
       %tr
+        %th= t('accounts.followers')
+        %td= number_with_delimiter @export.total_followers
+        %td
+      %tr
         %th= t('exports.blocks')
-        %td= number_to_human @export.total_blocks
+        %td= number_with_delimiter @export.total_blocks
         %td= table_link_to 'download', t('exports.csv'), settings_exports_blocks_path(format: :csv)
       %tr
         %th= t('exports.mutes')
-        %td= number_to_human @export.total_mutes
+        %td= number_with_delimiter @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')
diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml
index 991dd4e94..2b43cb134 100644
--- a/app/views/settings/imports/show.html.haml
+++ b/app/views/settings/imports/show.html.haml
@@ -1,11 +1,14 @@
 - content_for :page_title do
   = t('settings.import')
 
-%p.hint= t('imports.preface')
-
 = simple_form_for @import, url: settings_import_path do |f|
-  = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
-  = f.input :data, wrapper: :with_label, hint: t('simple_form.hints.imports.data')
+  %p.hint= t('imports.preface')
+
+  .field-group
+    = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+
+  .field-group
+    = f.input :data, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data')
 
   .actions
     = f.button :button, t('imports.upload'), type: :submit
diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb
index 8532c9d9a..cd9bacf68 100644
--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -59,6 +59,8 @@ module Devise
   @@ldap_password = nil
   mattr_accessor :ldap_tls_no_verify
   @@ldap_tls_no_verify = false
+  mattr_accessor :ldap_search_filter
+  @@ldap_search_filter = nil
 
   class Strategies::PamAuthenticatable
     def valid?
@@ -362,5 +364,6 @@ Devise.setup do |config|
     config.ldap_password       = ENV.fetch('LDAP_PASSWORD')
     config.ldap_uid            = ENV.fetch('LDAP_UID', 'cn')
     config.ldap_tls_no_verify  = ENV['LDAP_TLS_NO_VERIFY'] == 'true'
+    config.ldap_search_filter  = ENV.fetch('LDAP_SEARCH_FILTER', '%{uid}=%{email}')
   end
 end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4ed868c8b..ecabd9a36 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -184,6 +184,7 @@ en:
         unsuspend_account: "%{name} unsuspended %{target}'s account"
         update_custom_emoji: "%{name} updated emoji %{target}"
         update_status: "%{name} updated status by %{target}"
+      deleted_status: "(deleted status)"
       title: Audit log
     custom_emojis:
       by_domain: Domain
@@ -401,6 +402,7 @@ en:
       media:
         title: Media
       no_media: No media
+      no_status_selected: No statuses were changed as none were selected
       title: Account statuses
       with_media: With media
     subscriptions:
diff --git a/db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb b/db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb
index 120f74402..3369e3b9e 100644
--- a/db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb
+++ b/db/migrate/20171201000000_change_account_id_nonnullable_in_lists.rb
@@ -1,5 +1,3 @@
-require Rails.root.join('lib', 'mastodon', 'migration_helpers')
-
 class ChangeAccountIdNonnullableInLists < ActiveRecord::Migration[5.1]
   def change
     change_column_null :lists, :account_id, false
diff --git a/db/migrate/20180528141303_fix_accounts_unique_index.rb b/db/migrate/20180528141303_fix_accounts_unique_index.rb
index 624ea229e..bd4e158b7 100644
--- a/db/migrate/20180528141303_fix_accounts_unique_index.rb
+++ b/db/migrate/20180528141303_fix_accounts_unique_index.rb
@@ -6,6 +6,10 @@ class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
     def local?
       domain.nil?
     end
+
+    def acct
+      local? ? username : "#{username}@#{domain}"
+    end
   end
 
   disable_ddl_transaction!
diff --git a/db/migrate/20180812162710_create_status_stats.rb b/db/migrate/20180812162710_create_status_stats.rb
new file mode 100644
index 000000000..d4da36fe7
--- /dev/null
+++ b/db/migrate/20180812162710_create_status_stats.rb
@@ -0,0 +1,12 @@
+class CreateStatusStats < ActiveRecord::Migration[5.2]
+  def change
+    create_table :status_stats do |t|
+      t.belongs_to :status, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
+      t.bigint :replies_count, null: false, default: 0
+      t.bigint :reblogs_count, null: false, default: 0
+      t.bigint :favourites_count, null: false, default: 0
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20180812173710_copy_status_stats.rb b/db/migrate/20180812173710_copy_status_stats.rb
new file mode 100644
index 000000000..64a564ca0
--- /dev/null
+++ b/db/migrate/20180812173710_copy_status_stats.rb
@@ -0,0 +1,19 @@
+class CopyStatusStats < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      execute <<-SQL.squish
+        INSERT INTO status_stats (status_id, reblogs_count, favourites_count, created_at, updated_at)
+        SELECT id, reblogs_count, favourites_count, created_at, updated_at
+        FROM statuses
+        ON CONFLICT (status_id) DO UPDATE
+        SET reblogs_count = EXCLUDED.reblogs_count, favourites_count = EXCLUDED.favourites_count
+      SQL
+    end
+  end
+
+  def down
+    # Nothing
+  end
+end
diff --git a/db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb b/db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb
new file mode 100644
index 000000000..7077a4e65
--- /dev/null
+++ b/db/migrate/20180814171349_add_confidential_to_doorkeeper_application.rb
@@ -0,0 +1,23 @@
+require Rails.root.join('lib', 'mastodon', 'migration_helpers')
+
+class AddConfidentialToDoorkeeperApplication < ActiveRecord::Migration[5.2]
+  include Mastodon::MigrationHelpers
+
+  disable_ddl_transaction!
+
+  def up
+    safety_assured do
+      add_column_with_default(
+        :oauth_applications,
+        :confidential,
+        :boolean,
+        allow_null: false,
+        default: true # maintaining backwards compatibility: require secrets
+      )
+    end
+  end
+
+  def down
+    remove_column :oauth_applications, :confidential
+  end
+end
diff --git a/db/post_migrate/20180813113448_copy_status_stats_cleanup.rb b/db/post_migrate/20180813113448_copy_status_stats_cleanup.rb
new file mode 100644
index 000000000..f3ae772c7
--- /dev/null
+++ b/db/post_migrate/20180813113448_copy_status_stats_cleanup.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class CopyStatusStatsCleanup < ActiveRecord::Migration[5.2]
+  disable_ddl_transaction!
+
+  def change
+    safety_assured do
+      remove_column :statuses, :reblogs_count, :integer, default: 0, null: false
+      remove_column :statuses, :favourites_count, :integer, default: 0, null: false
+    end
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c2a313ffa..bac85869a 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: 2018_08_13_160548) do
+ActiveRecord::Schema.define(version: 2018_08_14_171349) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -359,6 +359,7 @@ ActiveRecord::Schema.define(version: 2018_08_13_160548) do
     t.string "website"
     t.string "owner_type"
     t.bigint "owner_id"
+    t.boolean "confidential", default: true, null: false
     t.index ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type"
     t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
   end
@@ -466,6 +467,16 @@ ActiveRecord::Schema.define(version: 2018_08_13_160548) do
     t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true
   end
 
+  create_table "status_stats", force: :cascade do |t|
+    t.bigint "status_id", null: false
+    t.bigint "replies_count", default: 0, null: false
+    t.bigint "reblogs_count", default: 0, null: false
+    t.bigint "favourites_count", default: 0, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
+  end
+
   create_table "statuses", id: :bigint, default: -> { "timestamp_id('statuses'::text)" }, force: :cascade do |t|
     t.string "uri"
     t.text "text", default: "", null: false
@@ -478,8 +489,6 @@ ActiveRecord::Schema.define(version: 2018_08_13_160548) do
     t.integer "visibility", default: 0, null: false
     t.text "spoiler_text", default: "", null: false
     t.boolean "reply", default: false, null: false
-    t.integer "favourites_count", default: 0, null: false
-    t.integer "reblogs_count", default: 0, null: false
     t.string "language"
     t.bigint "conversation_id"
     t.boolean "local"
@@ -643,6 +652,7 @@ ActiveRecord::Schema.define(version: 2018_08_13_160548) do
   add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade
   add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
   add_foreign_key "status_pins", "statuses", on_delete: :cascade
+  add_foreign_key "status_stats", "statuses", on_delete: :cascade
   add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", name: "fk_c7fa917661", on_delete: :nullify
   add_foreign_key "statuses", "accounts", name: "fk_9bda1543f7", on_delete: :cascade
   add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify
diff --git a/lib/devise/ldap_authenticatable.rb b/lib/devise/ldap_authenticatable.rb
index ef786fbb7..534c7a851 100644
--- a/lib/devise/ldap_authenticatable.rb
+++ b/lib/devise/ldap_authenticatable.rb
@@ -24,7 +24,8 @@ module Devise
             connect_timeout: 10
           )
 
-          if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: "(#{Devise.ldap_uid}=#{email})", password: password))
+          filter = format(Devise.ldap_search_filter, uid: Devise.ldap_uid, email: email)
+          if (user_info = ldap.bind_as(base: Devise.ldap_base, filter: filter, password: password))
             user = User.ldap_get_user(user_info.first)
             success!(user)
           else
diff --git a/spec/fabricators/status_stat_fabricator.rb b/spec/fabricators/status_stat_fabricator.rb
new file mode 100644
index 000000000..9c67fd404
--- /dev/null
+++ b/spec/fabricators/status_stat_fabricator.rb
@@ -0,0 +1,6 @@
+Fabricator(:status_stat) do
+  status_id        nil
+  replies_count    ""
+  reblogs_count    ""
+  favourites_count ""
+end
diff --git a/spec/lib/activitypub/activity/undo_spec.rb b/spec/lib/activitypub/activity/undo_spec.rb
index e01c5e03e..9545e1f46 100644
--- a/spec/lib/activitypub/activity/undo_spec.rb
+++ b/spec/lib/activitypub/activity/undo_spec.rb
@@ -52,6 +52,32 @@ RSpec.describe ActivityPub::Activity::Undo do
       end
     end
 
+    context 'with Accept' do
+      let(:recipient) { Fabricate(:account) }
+      let(:object_json) do
+        {
+          id: 'bar',
+          type: 'Accept',
+          actor: ActivityPub::TagManager.instance.uri_for(sender),
+          object: 'follow-to-revoke',
+        }
+      end
+
+      before do
+        recipient.follow!(sender, uri: 'follow-to-revoke')
+      end
+
+      it 'deletes follow from recipient to sender' do
+        subject.perform
+        expect(recipient.following?(sender)).to be false
+      end
+
+      it 'creates a follow request from recipient to sender' do
+        subject.perform
+        expect(recipient.requested?(sender)).to be true
+      end
+    end
+
     context 'with Block' do
       let(:recipient) { Fabricate(:account) }
 
diff --git a/spec/models/export_spec.rb b/spec/models/export_spec.rb
index 6daa46145..277dcc526 100644
--- a/spec/models/export_spec.rb
+++ b/spec/models/export_spec.rb
@@ -48,17 +48,17 @@ describe Export do
   describe 'total_follows' do
     it 'returns the total number of the followed accounts' do
       target_accounts.each(&account.method(:follow!))
-      expect(Export.new(account).total_follows).to eq 2
+      expect(Export.new(account.reload).total_follows).to eq 2
     end
 
     it 'returns the total number of the blocked accounts' do
       target_accounts.each(&account.method(:block!))
-      expect(Export.new(account).total_blocks).to eq 2
+      expect(Export.new(account.reload).total_blocks).to eq 2
     end
 
     it 'returns the total number of the muted accounts' do
       target_accounts.each(&account.method(:mute!))
-      expect(Export.new(account).total_mutes).to eq 2
+      expect(Export.new(account.reload).total_mutes).to eq 2
     end
   end
 end
diff --git a/spec/models/follow_spec.rb b/spec/models/follow_spec.rb
index 43175d852..f221973b6 100644
--- a/spec/models/follow_spec.rb
+++ b/spec/models/follow_spec.rb
@@ -37,4 +37,20 @@ RSpec.describe Follow, type: :model do
       expect(a[1]).to eq follow0
     end
   end
+
+  describe 'revoke_request!' do
+    let(:follow)         { Fabricate(:follow, account: account, target_account: target_account) }
+    let(:account)        { Fabricate(:account) }
+    let(:target_account) { Fabricate(:account) }
+
+    it 'revokes the follow relation' do
+      follow.revoke_request!
+      expect(account.following?(target_account)).to be false
+    end
+
+    it 'creates a follow request' do
+      follow.revoke_request!
+      expect(account.requested?(target_account)).to be true
+    end
+  end
 end
diff --git a/spec/models/status_stat_spec.rb b/spec/models/status_stat_spec.rb
new file mode 100644
index 000000000..5e9351aff
--- /dev/null
+++ b/spec/models/status_stat_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe StatusStat, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index 673de5233..671080f1d 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -29,7 +29,7 @@ describe SearchService, type: :service do
           allow(ResolveURLService).to receive(:new).and_return(service)
           results = subject.call(@query, 10)
 
-          expect(service).to have_received(:call).with(@query)
+          expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
           expect(results).to eq empty_results
         end
       end
@@ -41,7 +41,7 @@ describe SearchService, type: :service do
           allow(ResolveURLService).to receive(:new).and_return(service)
 
           results = subject.call(@query, 10)
-          expect(service).to have_received(:call).with(@query)
+          expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
           expect(results).to eq empty_results.merge(accounts: [account])
         end
       end
@@ -53,7 +53,7 @@ describe SearchService, type: :service do
           allow(ResolveURLService).to receive(:new).and_return(service)
 
           results = subject.call(@query, 10)
-          expect(service).to have_received(:call).with(@query)
+          expect(service).to have_received(:call).with(@query, on_behalf_of: nil)
           expect(results).to eq empty_results.merge(statuses: [status])
         end
       end