about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2019-03-20 13:54:00 +0100
committerThibaut Girka <thib@sitedethib.com>2019-03-20 13:54:00 +0100
commit1d6152f4404d40a6113bad2e70326fb5c2145ef4 (patch)
tree4edcc500883b3e533c06517b147bf221b06f6bf0 /app
parentb9a998f201913dd1c89ddcb0c4c9e181eb73bfcf (diff)
parent158c31b9df538691666e5b91f48a0afecd2985fe (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Conflicts:
- config/locales/en.yml
  Conflict caused by the glitch-soc-specific “flavour” string being too close
  to the newly introduced “identity_proofs” string. Just included both.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/accounts_controller.rb2
-rw-r--r--app/controllers/api/proofs_controller.rb30
-rw-r--r--app/controllers/settings/identity_proofs_controller.rb45
-rw-r--r--app/controllers/well_known/keybase_proof_config_controller.rb9
-rw-r--r--app/helpers/settings_helper.rb3
-rw-r--r--app/javascript/images/logo_transparent_black.svg1
-rw-r--r--app/javascript/images/proof_providers/keybase.pngbin0 -> 12665 bytes
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js9
-rw-r--r--app/javascript/mastodon/features/standalone/public_timeline/index.js14
-rw-r--r--app/javascript/styles/mastodon/about.scss2
-rw-r--r--app/javascript/styles/mastodon/forms.scss55
-rw-r--r--app/lib/proof_provider.rb12
-rw-r--r--app/lib/proof_provider/keybase.rb59
-rw-r--r--app/lib/proof_provider/keybase/badge.rb48
-rw-r--r--app/lib/proof_provider/keybase/config_serializer.rb70
-rw-r--r--app/lib/proof_provider/keybase/serializer.rb25
-rw-r--r--app/lib/proof_provider/keybase/verifier.rb62
-rw-r--r--app/lib/proof_provider/keybase/worker.rb33
-rw-r--r--app/models/account_identity_proof.rb46
-rw-r--r--app/models/concerns/account_associations.rb3
-rw-r--r--app/services/suspend_account_service.rb2
-rw-r--r--app/views/about/show.html.haml32
-rw-r--r--app/views/accounts/_bio.html.haml15
-rw-r--r--app/views/settings/identity_proofs/_proof.html.haml20
-rw-r--r--app/views/settings/identity_proofs/index.html.haml17
-rw-r--r--app/views/settings/identity_proofs/new.html.haml31
26 files changed, 602 insertions, 43 deletions
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e160c603a..e7795e95c 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -53,7 +53,7 @@ module Admin
 
     def reject
       authorize @account.user, :reject?
-      SuspendAccountService.new.call(@account, including_user: true, destroy: true)
+      SuspendAccountService.new.call(@account, including_user: true, destroy: true, skip_distribution: true)
       redirect_to admin_accounts_path(pending: '1')
     end
 
diff --git a/app/controllers/api/proofs_controller.rb b/app/controllers/api/proofs_controller.rb
new file mode 100644
index 000000000..a84ad2014
--- /dev/null
+++ b/app/controllers/api/proofs_controller.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+class Api::ProofsController < Api::BaseController
+  before_action :set_account
+  before_action :set_provider
+  before_action :check_account_approval
+  before_action :check_account_suspension
+
+  def index
+    render json: @account, serializer: @provider.serializer_class
+  end
+
+  private
+
+  def set_provider
+    @provider = ProofProvider.find(params[:provider]) || raise(ActiveRecord::RecordNotFound)
+  end
+
+  def set_account
+    @account = Account.find_local!(params[:username])
+  end
+
+  def check_account_approval
+    not_found if @account.user_pending?
+  end
+
+  def check_account_suspension
+    gone if @account.suspended?
+  end
+end
diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb
new file mode 100644
index 000000000..4a3b89a5e
--- /dev/null
+++ b/app/controllers/settings/identity_proofs_controller.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class Settings::IdentityProofsController < Settings::BaseController
+  layout 'admin'
+
+  before_action :authenticate_user!
+  before_action :check_required_params, only: :new
+
+  def index
+    @proofs = AccountIdentityProof.where(account: current_account).order(provider: :asc, provider_username: :asc)
+    @proofs.each(&:refresh!)
+  end
+
+  def new
+    @proof = current_account.identity_proofs.new(
+      token: params[:token],
+      provider: params[:provider],
+      provider_username: params[:provider_username]
+    )
+
+    render layout: 'auth'
+  end
+
+  def create
+    @proof = current_account.identity_proofs.where(provider: resource_params[:provider], provider_username: resource_params[:provider_username]).first_or_initialize(resource_params)
+    @proof.token = resource_params[:token]
+
+    if @proof.save
+      redirect_to @proof.on_success_path(params[:user_agent])
+    else
+      flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
+      redirect_to settings_identity_proofs_path
+    end
+  end
+
+  private
+
+  def check_required_params
+    redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :token].all? { |k| params[k].present? }
+  end
+
+  def resource_params
+    params.require(:account_identity_proof).permit(:provider, :provider_username, :token)
+  end
+end
diff --git a/app/controllers/well_known/keybase_proof_config_controller.rb b/app/controllers/well_known/keybase_proof_config_controller.rb
new file mode 100644
index 000000000..eb41e586f
--- /dev/null
+++ b/app/controllers/well_known/keybase_proof_config_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module WellKnown
+  class KeybaseProofConfigController < ActionController::Base
+    def show
+      render json: {}, serializer: ProofProvider::Keybase::ConfigSerializer
+    end
+  end
+end
diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb
index 241addb83..92bc222ea 100644
--- a/app/helpers/settings_helper.rb
+++ b/app/helpers/settings_helper.rb
@@ -6,6 +6,7 @@ module SettingsHelper
     ar: 'العربية',
     ast: 'Asturianu',
     bg: 'Български',
+    bn: 'বাংলা',
     ca: 'Català',
     co: 'Corsu',
     cs: 'Čeština',
@@ -19,8 +20,10 @@ module SettingsHelper
     fa: 'فارسی',
     fi: 'Suomi',
     fr: 'Français',
+    ga: 'Gaeilge',
     gl: 'Galego',
     he: 'עברית',
+    hi: 'हिन्दी',
     hr: 'Hrvatski',
     hu: 'Magyar',
     hy: 'Հայերեն',
diff --git a/app/javascript/images/logo_transparent_black.svg b/app/javascript/images/logo_transparent_black.svg
new file mode 100644
index 000000000..e44bcf5e1
--- /dev/null
+++ b/app/javascript/images/logo_transparent_black.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 216.4144 232.00976"><path d="M107.86523 0C78.203984.2425 49.672422 3.4535937 33.044922 11.089844c0 0-32.97656262 14.752031-32.97656262 65.082031 0 11.525-.224375 25.306175.140625 39.919925 1.19750002 49.22 9.02375002 97.72843 54.53124962 109.77343 20.9825 5.55375 38.99711 6.71547 53.505856 5.91797 26.31125-1.45875 41.08203-9.38867 41.08203-9.38867l-.86914-19.08984s-18.80171 5.92758-39.91796 5.20508c-20.921254-.7175-43.006879-2.25516-46.390629-27.94141-.3125-2.25625-.46875-4.66938-.46875-7.20313 0 0 20.536953 5.0204 46.564449 6.21289 15.915.73001 30.8393-.93343 45.99805-2.74218 29.07-3.47125 54.38125-21.3818 57.5625-37.74805 5.0125-25.78125 4.59961-62.916015 4.59961-62.916015 0-50.33-32.97461-65.082031-32.97461-65.082031C166.80539 3.4535938 138.255.2425 108.59375 0h-.72852zM74.296875 39.326172c12.355 0 21.710234 4.749297 27.896485 14.248047l6.01367 10.080078 6.01563-10.080078c6.185-9.49875 15.54023-14.248047 27.89648-14.248047 10.6775 0 19.28156 3.753672 25.85156 11.076172 6.36875 7.3225 9.53907 17.218828 9.53907 29.673828v60.941408h-24.14454V81.869141c0-12.46875-5.24453-18.798829-15.73828-18.798829-11.6025 0-17.41797 7.508516-17.41797 22.353516v32.375002H96.207031V85.423828c0-14.845-5.815468-22.353515-17.417969-22.353516-10.49375 0-15.740234 6.330079-15.740234 18.798829v59.148439H38.904297V80.076172c0-12.455 3.171016-22.351328 9.541015-29.673828 6.568751-7.3225 15.172813-11.076172 25.851563-11.076172z" fill="#000"/></svg>
diff --git a/app/javascript/images/proof_providers/keybase.png b/app/javascript/images/proof_providers/keybase.png
new file mode 100644
index 000000000..7e3ac657f
--- /dev/null
+++ b/app/javascript/images/proof_providers/keybase.png
Binary files differdiff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 0880d98c8..73919c39d 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { expandHashtagTimeline } from 'mastodon/actions/timelines';
-import { connectHashtagStream } from 'mastodon/actions/streaming';
 import Masonry from 'react-masonry-infinite';
 import { List as ImmutableList } from 'immutable';
 import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
@@ -31,14 +30,6 @@ class HashtagTimeline extends React.PureComponent {
     const { dispatch, hashtag } = this.props;
 
     dispatch(expandHashtagTimeline(hashtag));
-    this.disconnect = dispatch(connectHashtagStream(hashtag, hashtag));
-  }
-
-  componentWillUnmount () {
-    if (this.disconnect) {
-      this.disconnect();
-      this.disconnect = null;
-    }
   }
 
   handleLoadMore = () => {
diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js
index 10129e606..19b0b14be 100644
--- a/app/javascript/mastodon/features/standalone/public_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js
@@ -3,7 +3,6 @@ import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
-import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
 import Masonry from 'react-masonry-infinite';
 import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
 import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
@@ -37,27 +36,14 @@ class PublicTimeline extends React.PureComponent {
 
   componentDidUpdate (prevProps) {
     if (prevProps.local !== this.props.local) {
-      this._disconnect();
       this._connect();
     }
   }
 
-  componentWillUnmount () {
-    this._disconnect();
-  }
-
   _connect () {
     const { dispatch, local } = this.props;
 
     dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
-    this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream());
-  }
-
-  _disconnect () {
-    if (this.disconnect) {
-      this.disconnect();
-      this.disconnect = null;
-    }
   }
 
   handleLoadMore = () => {
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index 465ef2c11..d3b4a5909 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -657,7 +657,7 @@ $small-breakpoint: 960px;
     display: flex;
     justify-content: center;
     align-items: center;
-    padding: 100px;
+    padding: 50px;
 
     img {
       height: 52px;
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 6051c1d00..9ef45e425 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -801,3 +801,58 @@ code {
     }
   }
 }
+
+.connection-prompt {
+  margin-bottom: 25px;
+
+  .fa-link {
+    background-color: darken($ui-base-color, 4%);
+    border-radius: 100%;
+    font-size: 24px;
+    padding: 10px;
+  }
+
+  &__column {
+    align-items: center;
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    flex-shrink: 1;
+
+    &-sep {
+      flex-grow: 0;
+      overflow: visible;
+      position: relative;
+      z-index: 1;
+    }
+  }
+
+  .account__avatar {
+    margin-bottom: 20px;
+  }
+
+  &__connection {
+    background-color: lighten($ui-base-color, 8%);
+    box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
+    border-radius: 4px;
+    padding: 25px 10px;
+    position: relative;
+    text-align: center;
+
+    &::after {
+      background-color: darken($ui-base-color, 4%);
+      content: '';
+      display: block;
+      height: 100%;
+      left: 50%;
+      position: absolute;
+      width: 1px;
+    }
+  }
+
+  &__row {
+    align-items: center;
+    display: flex;
+    flex-direction: row;
+  }
+}
diff --git a/app/lib/proof_provider.rb b/app/lib/proof_provider.rb
new file mode 100644
index 000000000..102c50f4f
--- /dev/null
+++ b/app/lib/proof_provider.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module ProofProvider
+  SUPPORTED_PROVIDERS = %w(keybase).freeze
+
+  def self.find(identifier, proof = nil)
+    case identifier
+    when 'keybase'
+      ProofProvider::Keybase.new(proof)
+    end
+  end
+end
diff --git a/app/lib/proof_provider/keybase.rb b/app/lib/proof_provider/keybase.rb
new file mode 100644
index 000000000..96322a265
--- /dev/null
+++ b/app/lib/proof_provider/keybase.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase
+  BASE_URL = 'https://keybase.io'
+
+  class Error < StandardError; end
+
+  class ExpectedProofLiveError < Error; end
+
+  class UnexpectedResponseError < Error; end
+
+  def initialize(proof = nil)
+    @proof = proof
+  end
+
+  def serializer_class
+    ProofProvider::Keybase::Serializer
+  end
+
+  def worker_class
+    ProofProvider::Keybase::Worker
+  end
+
+  def validate!
+    unless @proof.token&.size == 66
+      @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.invalid_token'))
+      return
+    end
+
+    return if @proof.provider_username.blank?
+
+    if verifier.valid?
+      @proof.verified = true
+      @proof.live     = false
+    else
+      @proof.errors.add(:base, I18n.t('identity_proofs.errors.keybase.verification_failed', kb_username: @proof.provider_username))
+    end
+  end
+
+  def refresh!
+    worker_class.new.perform(@proof)
+  rescue ProofProvider::Keybase::Error
+    nil
+  end
+
+  def on_success_path(user_agent = nil)
+    verifier.on_success_path(user_agent)
+  end
+
+  def badge
+    @badge ||= ProofProvider::Keybase::Badge.new(@proof.account.username, @proof.provider_username, @proof.token)
+  end
+
+  private
+
+  def verifier
+    @verifier ||= ProofProvider::Keybase::Verifier.new(@proof.account.username, @proof.provider_username, @proof.token)
+  end
+end
diff --git a/app/lib/proof_provider/keybase/badge.rb b/app/lib/proof_provider/keybase/badge.rb
new file mode 100644
index 000000000..3aa067ecf
--- /dev/null
+++ b/app/lib/proof_provider/keybase/badge.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase::Badge
+  include RoutingHelper
+
+  def initialize(local_username, provider_username, token)
+    @local_username    = local_username
+    @provider_username = provider_username
+    @token             = token
+  end
+
+  def proof_url
+    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/sigchain\##{@token}"
+  end
+
+  def profile_url
+    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}"
+  end
+
+  def icon_url
+    "#{ProofProvider::Keybase::BASE_URL}/#{@provider_username}/proof_badge/#{@token}?username=#{@local_username}&domain=#{domain}"
+  end
+
+  def avatar_url
+    Rails.cache.fetch("proof_providers/keybase/#{@provider_username}/avatar_url", expires_in: 5.minutes) { remote_avatar_url } || default_avatar_url
+  end
+
+  private
+
+  def remote_avatar_url
+    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/user/pic_url.json", params: { username: @provider_username })
+
+    request.perform do |res|
+      json = Oj.load(res.body_with_limit, mode: :strict)
+      json['pic_url'] if json.is_a?(Hash)
+    end
+  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
+    nil
+  end
+
+  def default_avatar_url
+    asset_pack_path('media/images/proof_providers/keybase.png')
+  end
+
+  def domain
+    Rails.configuration.x.local_domain
+  end
+end
diff --git a/app/lib/proof_provider/keybase/config_serializer.rb b/app/lib/proof_provider/keybase/config_serializer.rb
new file mode 100644
index 000000000..474ea74e2
--- /dev/null
+++ b/app/lib/proof_provider/keybase/config_serializer.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attributes :version, :domain, :display_name, :username,
+             :brand_color, :logo, :description, :prefill_url,
+             :profile_url, :check_url, :check_path, :avatar_path,
+             :contact
+
+  def version
+    1
+  end
+
+  def domain
+    Rails.configuration.x.local_domain
+  end
+
+  def display_name
+    Setting.site_title
+  end
+
+  def logo
+    { svg_black: full_asset_url(asset_pack_path('media/images/logo_transparent_black.svg')), svg_full: full_asset_url(asset_pack_path('media/images/logo.svg')) }
+  end
+
+  def brand_color
+    '#282c37'
+  end
+
+  def description
+    Setting.site_short_description.presence || Setting.site_description.presence || I18n.t('about.about_mastodon_html')
+  end
+
+  def username
+    { min: 1, max: 30, re: Account::USERNAME_RE.inspect }
+  end
+
+  def prefill_url
+    params = {
+      provider: 'keybase',
+      token: '%{sig_hash}',
+      provider_username: '%{kb_username}',
+      username: '%{username}',
+      user_agent: '%{kb_ua}',
+    }
+
+    CGI.unescape(new_settings_identity_proof_url(params))
+  end
+
+  def profile_url
+    CGI.unescape(short_account_url('%{username}')) # rubocop:disable Style/FormatStringToken
+  end
+
+  def check_url
+    CGI.unescape(api_proofs_url(username: '%{username}', provider: 'keybase'))
+  end
+
+  def check_path
+    ['signatures']
+  end
+
+  def avatar_path
+    ['avatar']
+  end
+
+  def contact
+    [Setting.site_contact_email.presence].compact
+  end
+end
diff --git a/app/lib/proof_provider/keybase/serializer.rb b/app/lib/proof_provider/keybase/serializer.rb
new file mode 100644
index 000000000..d29283600
--- /dev/null
+++ b/app/lib/proof_provider/keybase/serializer.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase::Serializer < ActiveModel::Serializer
+  include RoutingHelper
+
+  attribute :avatar
+
+  has_many :identity_proofs, key: :signatures
+
+  def avatar
+    full_asset_url(object.avatar_original_url)
+  end
+
+  class AccountIdentityProofSerializer < ActiveModel::Serializer
+    attributes :sig_hash, :kb_username
+
+    def sig_hash
+      object.token
+    end
+
+    def kb_username
+      object.provider_username
+    end
+  end
+end
diff --git a/app/lib/proof_provider/keybase/verifier.rb b/app/lib/proof_provider/keybase/verifier.rb
new file mode 100644
index 000000000..86f249dd7
--- /dev/null
+++ b/app/lib/proof_provider/keybase/verifier.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase::Verifier
+  def initialize(local_username, provider_username, token)
+    @local_username    = local_username
+    @provider_username = provider_username
+    @token             = token
+  end
+
+  def valid?
+    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_valid.json", params: query_params)
+
+    request.perform do |res|
+      json = Oj.load(res.body_with_limit, mode: :strict)
+
+      if json.is_a?(Hash)
+        json.fetch('proof_valid', false)
+      else
+        false
+      end
+    end
+  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
+    false
+  end
+
+  def on_success_path(user_agent = nil)
+    url = Addressable::URI.parse("#{ProofProvider::Keybase::BASE_URL}/_/proof_creation_success")
+    url.query_values = query_params.merge(kb_ua: user_agent || 'unknown')
+    url.to_s
+  end
+
+  def status
+    request = Request.new(:get, "#{ProofProvider::Keybase::BASE_URL}/_/api/1.0/sig/proof_live.json", params: query_params)
+
+    request.perform do |res|
+      raise ProofProvider::Keybase::UnexpectedResponseError unless res.code == 200
+
+      json = Oj.load(res.body_with_limit, mode: :strict)
+
+      raise ProofProvider::Keybase::UnexpectedResponseError unless json.is_a?(Hash) && json.key?('proof_valid') && json.key?('proof_live')
+
+      json
+    end
+  rescue Oj::ParseError, HTTP::Error, OpenSSL::SSL::SSLError
+    raise ProofProvider::Keybase::UnexpectedResponseError
+  end
+
+  private
+
+  def query_params
+    {
+      domain: domain,
+      kb_username: @provider_username,
+      username: @local_username,
+      sig_hash: @token,
+    }
+  end
+
+  def domain
+    Rails.configuration.x.local_domain
+  end
+end
diff --git a/app/lib/proof_provider/keybase/worker.rb b/app/lib/proof_provider/keybase/worker.rb
new file mode 100644
index 000000000..2872f59c1
--- /dev/null
+++ b/app/lib/proof_provider/keybase/worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class ProofProvider::Keybase::Worker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'pull', retry: 20, unique: :until_executed
+
+  sidekiq_retry_in do |count, exception|
+    # Retry aggressively when the proof is valid but not live in Keybase.
+    # This is likely because Keybase just hasn't noticed the proof being
+    # served from here yet.
+
+    if exception.class == ProofProvider::Keybase::ExpectedProofLiveError
+      case count
+      when 0..2 then 0.seconds
+      when 2..6 then 1.second
+      end
+    end
+  end
+
+  def perform(proof_id)
+    proof    = proof_id.is_a?(AccountIdentityProof) ? proof_id : AccountIdentityProof.find(proof_id)
+    verifier = ProofProvider::Keybase::Verifier.new(proof.account.username, proof.provider_username, proof.token)
+    status   = verifier.status
+
+    # If Keybase thinks the proof is valid, and it exists here in Mastodon,
+    # then it should be live. Keybase just has to notice that it's here
+    # and then update its state. That might take a couple seconds.
+    raise ProofProvider::Keybase::ExpectedProofLiveError if status['proof_valid'] && !status['proof_live']
+
+    proof.update!(verified: status['proof_valid'], live: status['proof_live'])
+  end
+end
diff --git a/app/models/account_identity_proof.rb b/app/models/account_identity_proof.rb
new file mode 100644
index 000000000..e7a3f97e5
--- /dev/null
+++ b/app/models/account_identity_proof.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: account_identity_proofs
+#
+#  id                :bigint(8)        not null, primary key
+#  account_id        :bigint(8)
+#  provider          :string           default(""), not null
+#  provider_username :string           default(""), not null
+#  token             :text             default(""), not null
+#  verified          :boolean          default(FALSE), not null
+#  live              :boolean          default(FALSE), not null
+#  created_at        :datetime         not null
+#  updated_at        :datetime         not null
+#
+
+class AccountIdentityProof < ApplicationRecord
+  belongs_to :account
+
+  validates :provider, inclusion: { in: ProofProvider::SUPPORTED_PROVIDERS }
+  validates :provider_username, format: { with: /\A[a-z0-9_]+\z/i }, length: { minimum: 2, maximum: 15 }
+  validates :provider_username, uniqueness: { scope: [:account_id, :provider] }
+  validates :token, format: { with: /\A[a-f0-9]+\z/ }, length: { maximum: 66 }
+
+  validate :validate_with_provider, if: :token_changed?
+
+  scope :active, -> { where(verified: true, live: true) }
+
+  after_create_commit :queue_worker
+
+  delegate :refresh!, :on_success_path, :badge, to: :provider_instance
+
+  private
+
+  def provider_instance
+    @provider_instance ||= ProofProvider.find(provider, self)
+  end
+
+  def queue_worker
+    provider_instance.worker_class.perform_async(id)
+  end
+
+  def validate_with_provider
+    provider_instance.validate!
+  end
+end
diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb
index 1b22f750c..ecccaf35e 100644
--- a/app/models/concerns/account_associations.rb
+++ b/app/models/concerns/account_associations.rb
@@ -7,6 +7,9 @@ module AccountAssociations
     # Local users
     has_one :user, inverse_of: :account, dependent: :destroy
 
+    # Identity proofs
+    has_many :identity_proofs, class_name: 'AccountIdentityProof', dependent: :destroy, inverse_of: :account
+
     # Timelines
     has_many :stream_entries, inverse_of: :account, dependent: :destroy
     has_many :statuses, inverse_of: :account, dependent: :destroy
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 24fa1be69..6c2ecad30 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -68,7 +68,7 @@ class SuspendAccountService < BaseService
   end
 
   def purge_content!
-    distribute_delete_actor! if @account.local?
+    distribute_delete_actor! if @account.local? && !@options[:skip_distribution]
 
     @account.statuses.reorder(nil).find_in_batches do |statuses|
       BatchedRemoveStatusService.new.call(statuses, skip_side_effects: @options[:destroy])
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 21dcf226d..45e5f0717 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -17,23 +17,25 @@
         = render 'registration'
 
       .directory
-        .directory__tag{ class: Setting.profile_directory ? nil : 'disabled' }
-          = optional_link_to Setting.profile_directory, explore_path do
-            %h4
-              = fa_icon 'address-book fw'
-              = t('about.discover_users')
-              %small= t('about.browse_directory')
+        - if Setting.profile_directory
+          .directory__tag
+            = optional_link_to Setting.profile_directory, explore_path do
+              %h4
+                = fa_icon 'address-book fw'
+                = t('about.discover_users')
+                %small= t('about.browse_directory')
 
-            .avatar-stack
-              - @instance_presenter.sample_accounts.each do |account|
-                = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
+              .avatar-stack
+                - @instance_presenter.sample_accounts.each do |account|
+                  = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
 
-        .directory__tag{ class: Setting.timeline_preview ? nil : 'disabled' }
-          = optional_link_to Setting.timeline_preview, public_timeline_path do
-            %h4
-              = fa_icon 'globe fw'
-              = t('about.see_whats_happening')
-              %small= t('about.browse_public_posts')
+        - if Setting.timeline_preview
+          .directory__tag
+            = optional_link_to Setting.timeline_preview, public_timeline_path do
+              %h4
+                = fa_icon 'globe fw'
+                = t('about.see_whats_happening')
+                %small= t('about.browse_public_posts')
 
         .directory__tag
           = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener' do
diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml
index 2ea34a048..efc26d136 100644
--- a/app/views/accounts/_bio.html.haml
+++ b/app/views/accounts/_bio.html.haml
@@ -1,7 +1,17 @@
+- proofs = account.identity_proofs.active
+- fields = account.fields
+
 .public-account-bio
-  - unless account.fields.empty?
+  - unless fields.empty? && proofs.empty?
     .account__header__fields
-      - account.fields.each do |field|
+      - proofs.each do |proof|
+        %dl
+          %dt= proof.provider.capitalize
+          %dd.verified
+            = link_to fa_icon('check'), proof.badge.proof_url, class: 'verified__mark', title: t('accounts.link_verified_on', date: l(proof.updated_at))
+            = link_to proof.provider_username, proof.badge.profile_url
+
+      - fields.each do |field|
         %dl
           %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true)
           %dd{ title: field.value, class: custom_field_classes(field) }
@@ -9,6 +19,7 @@
               %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) }
                 = fa_icon 'check'
             = Formatter.instance.format_field(account, field.value, custom_emojify: true)
+
   = account_badge(account)
 
   - if account.note.present?
diff --git a/app/views/settings/identity_proofs/_proof.html.haml b/app/views/settings/identity_proofs/_proof.html.haml
new file mode 100644
index 000000000..524827ad7
--- /dev/null
+++ b/app/views/settings/identity_proofs/_proof.html.haml
@@ -0,0 +1,20 @@
+%tr
+  %td
+    = link_to proof.badge.profile_url, class: 'name-tag' do
+      = image_tag proof.badge.avatar_url, width: 15, height: 15, alt: '', class: 'avatar'
+      %span.username
+        = proof.provider_username
+        %span= "(#{proof.provider.capitalize})"
+
+  %td
+    - if proof.live?
+      %span.positive-hint
+        = fa_icon 'check-circle fw'
+        = t('identity_proofs.active')
+    - else
+      %span.negative-hint
+        = fa_icon 'times-circle fw'
+        = t('identity_proofs.inactive')
+
+  %td
+    = table_link_to 'external-link', t('identity_proofs.view_proof'), proof.badge.proof_url if proof.badge.proof_url
diff --git a/app/views/settings/identity_proofs/index.html.haml b/app/views/settings/identity_proofs/index.html.haml
new file mode 100644
index 000000000..d0ea03ecd
--- /dev/null
+++ b/app/views/settings/identity_proofs/index.html.haml
@@ -0,0 +1,17 @@
+- content_for :page_title do
+  = t('settings.identity_proofs')
+
+%p= t('identity_proofs.explanation_html')
+
+- unless @proofs.empty?
+  %hr.spacer/
+
+  .table-wrapper
+    %table.table
+      %thead
+        %tr
+          %th= t('identity_proofs.identity')
+          %th= t('identity_proofs.status')
+          %th
+      %tbody
+        = render partial: 'settings/identity_proofs/proof', collection: @proofs, as: :proof
diff --git a/app/views/settings/identity_proofs/new.html.haml b/app/views/settings/identity_proofs/new.html.haml
new file mode 100644
index 000000000..8ce6e61c9
--- /dev/null
+++ b/app/views/settings/identity_proofs/new.html.haml
@@ -0,0 +1,31 @@
+- content_for :page_title do
+  = t('identity_proofs.authorize_connection_prompt')
+
+.form-container
+  .oauth-prompt
+    %h2= t('identity_proofs.authorize_connection_prompt')
+
+  = simple_form_for @proof, url: settings_identity_proofs_url, html: { method: :post } do |f|
+    = f.input :provider, as: :hidden
+    = f.input :provider_username, as: :hidden
+    = f.input :token, as: :hidden
+
+    = hidden_field_tag :user_agent, params[:user_agent]
+
+    .connection-prompt
+      .connection-prompt__row.connection-prompt__connection
+        .connection-prompt__column
+          = image_tag current_account.avatar.url(:original), size: 96, class: 'account__avatar'
+
+          %p= t('identity_proofs.i_am_html', username: content_tag(:strong,current_account.username), service: site_hostname)
+
+        .connection-prompt__column.connection-prompt__column-sep
+          = fa_icon 'link'
+
+        .connection-prompt__column
+          = image_tag @proof.badge.avatar_url, size: 96, class: 'account__avatar'
+
+          %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize)
+
+    = f.button :button, t('identity_proofs.authorize'), type: :submit
+    = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative'