about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.rubocop.yml2
-rw-r--r--app/controllers/api/v1/accounts/identity_proofs_controller.rb19
-rw-r--r--app/controllers/settings/identity_proofs_controller.rb22
-rw-r--r--app/javascript/mastodon/actions/identity_proofs.js30
-rw-r--r--app/javascript/mastodon/features/account/components/header.js17
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js4
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js2
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js3
-rw-r--r--app/javascript/mastodon/locales/cs.json2
-rw-r--r--app/javascript/mastodon/reducers/identity_proofs.js25
-rw-r--r--app/javascript/mastodon/reducers/index.js2
-rw-r--r--app/javascript/styles/mastodon/components.scss8
-rw-r--r--app/javascript/styles/mastodon/containers.scss8
-rw-r--r--app/javascript/styles/mastodon/forms.scss9
-rw-r--r--app/lib/proof_provider/keybase.rb3
-rw-r--r--app/lib/proof_provider/keybase/config_serializer.rb4
-rw-r--r--app/lib/proof_provider/keybase/verifier.rb6
-rw-r--r--app/models/account_identity_proof.rb2
-rw-r--r--app/serializers/rest/identity_proof_serializer.rb17
-rw-r--r--app/views/settings/identity_proofs/new.html.haml5
-rw-r--r--config/locales/activerecord.cs.yml5
-rw-r--r--config/locales/cs.yml1
-rw-r--r--config/locales/en.yml3
-rw-r--r--config/routes.rb1
-rw-r--r--lib/cli.rb73
-rw-r--r--spec/controllers/settings/identity_proofs_controller_spec.rb57
26 files changed, 299 insertions, 31 deletions
diff --git a/.rubocop.yml b/.rubocop.yml
index 59e8a757a..f1095e022 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -80,7 +80,7 @@ Rails/HttpStatus:
 Rails/Exit:
   Exclude:
     - 'lib/mastodon/*'
-    - 'lib/cli'
+    - 'lib/cli.rb'
 
 Style/ClassAndModuleChildren:
   Enabled: false
diff --git a/app/controllers/api/v1/accounts/identity_proofs_controller.rb b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
new file mode 100644
index 000000000..bea51ae11
--- /dev/null
+++ b/app/controllers/api/v1/accounts/identity_proofs_controller.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::IdentityProofsController < Api::BaseController
+  before_action :require_user!
+  before_action :set_account
+
+  respond_to :json
+
+  def index
+    @proofs = @account.identity_proofs.active
+    render json: @proofs, each_serializer: REST::IdentityProofSerializer
+  end
+
+  private
+
+  def set_account
+    @account = Account.find(params[:account_id])
+  end
+end
diff --git a/app/controllers/settings/identity_proofs_controller.rb b/app/controllers/settings/identity_proofs_controller.rb
index 4a3b89a5e..8f857fdcc 100644
--- a/app/controllers/settings/identity_proofs_controller.rb
+++ b/app/controllers/settings/identity_proofs_controller.rb
@@ -18,7 +18,12 @@ class Settings::IdentityProofsController < Settings::BaseController
       provider_username: params[:provider_username]
     )
 
-    render layout: 'auth'
+    if current_account.username == params[:username]
+      render layout: 'auth'
+    else
+      flash[:alert] = I18n.t('identity_proofs.errors.wrong_user', proving: params[:username], current: current_account.username)
+      redirect_to settings_identity_proofs_path
+    end
   end
 
   def create
@@ -26,6 +31,7 @@ class Settings::IdentityProofsController < Settings::BaseController
     @proof.token = resource_params[:token]
 
     if @proof.save
+      PostStatusService.new.call(current_user.account, text: post_params[:status_text]) if publish_proof?
       redirect_to @proof.on_success_path(params[:user_agent])
     else
       flash[:alert] = I18n.t('identity_proofs.errors.failed', provider: @proof.provider.capitalize)
@@ -36,10 +42,22 @@ class Settings::IdentityProofsController < Settings::BaseController
   private
 
   def check_required_params
-    redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :token].all? { |k| params[k].present? }
+    redirect_to settings_identity_proofs_path unless [:provider, :provider_username, :username, :token].all? { |k| params[k].present? }
   end
 
   def resource_params
     params.require(:account_identity_proof).permit(:provider, :provider_username, :token)
   end
+
+  def publish_proof?
+    ActiveModel::Type::Boolean.new.cast(post_params[:post_status])
+  end
+
+  def post_params
+    params.require(:account_identity_proof).permit(:post_status, :status_text)
+  end
+
+  def set_body_classes
+    @body_classes = ''
+  end
 end
diff --git a/app/javascript/mastodon/actions/identity_proofs.js b/app/javascript/mastodon/actions/identity_proofs.js
new file mode 100644
index 000000000..449debf61
--- /dev/null
+++ b/app/javascript/mastodon/actions/identity_proofs.js
@@ -0,0 +1,30 @@
+import api from '../api';
+
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
+export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL    = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
+
+export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
+  dispatch(fetchAccountIdentityProofsRequest(accountId));
+
+  api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
+    .then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
+    .catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
+};
+
+export const fetchAccountIdentityProofsRequest = id => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
+  id,
+});
+
+export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
+  accountId,
+  identity_proofs,
+});
+
+export const fetchAccountIdentityProofsFail = (accountId, err) => ({
+  type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
+  accountId,
+  err,
+});
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index d957de73d..76f50a5a4 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -62,6 +62,7 @@ class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
+    identity_props: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -81,7 +82,7 @@ class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, intl, domain } = this.props;
+    const { account, intl, domain, identity_proofs } = this.props;
 
     if (!account) {
       return null;
@@ -234,8 +235,20 @@ class Header extends ImmutablePureComponent {
 
           <div className='account__header__extra'>
             <div className='account__header__bio'>
-              {fields.size > 0 && (
+              { (fields.size > 0 || identity_proofs.size > 0) && (
                 <div className='account__header__fields'>
+                  {identity_proofs.map((proof, i) => (
+                    <dl key={i}>
+                      <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
+
+                      <dd className='verified'>
+                        <a href={proof.get('proof_url')} target='_blank' rel='noopener'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
+                          <Icon id='check' className='verified__mark' />
+                        </span></a>
+                        <a href={proof.get('profile_url')} target='_blank' rel='noopener'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
+                      </dd>
+                    </dl>
+                  ))}
                   {fields.map((pair, i) => (
                     <dl key={i}>
                       <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 16ada18c0..27dfcc516 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -12,6 +12,7 @@ export default class Header extends ImmutablePureComponent {
 
   static propTypes = {
     account: ImmutablePropTypes.map,
+    identity_proofs: ImmutablePropTypes.list,
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMention: PropTypes.func.isRequired,
@@ -84,7 +85,7 @@ export default class Header extends ImmutablePureComponent {
   }
 
   render () {
-    const { account, hideTabs } = this.props;
+    const { account, hideTabs, identity_proofs } = this.props;
 
     if (account === null) {
       return <MissingIndicator />;
@@ -96,6 +97,7 @@ export default class Header extends ImmutablePureComponent {
 
         <InnerHeader
           account={account}
+          identity_proofs={identity_proofs}
           onFollow={this.handleFollow}
           onBlock={this.handleBlock}
           onMention={this.handleMention}
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
index a06a0b095..4d4ae6e82 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -21,6 +21,7 @@ import { openModal } from '../../../actions/modal';
 import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { unfollowModal } from '../../../initial_state';
+import { List as ImmutableList } from 'immutable';
 
 const messages = defineMessages({
   unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
@@ -35,6 +36,7 @@ const makeMapStateToProps = () => {
   const mapStateToProps = (state, { accountId }) => ({
     account: getAccount(state, accountId),
     domain: state.getIn(['meta', 'domain']),
+    identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index afc484c60..883f40d77 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -12,6 +12,7 @@ import ColumnBackButton from '../../components/column_back_button';
 import { List as ImmutableList } from 'immutable';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { FormattedMessage } from 'react-intl';
+import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
 
 const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
   const path = withReplies ? `${accountId}:with_replies` : accountId;
@@ -42,6 +43,7 @@ class AccountTimeline extends ImmutablePureComponent {
     const { params: { accountId }, withReplies } = this.props;
 
     this.props.dispatch(fetchAccount(accountId));
+    this.props.dispatch(fetchAccountIdentityProofs(accountId));
     if (!withReplies) {
       this.props.dispatch(expandAccountFeaturedTimeline(accountId));
     }
@@ -51,6 +53,7 @@ class AccountTimeline extends ImmutablePureComponent {
   componentWillReceiveProps (nextProps) {
     if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
       this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
       if (!nextProps.withReplies) {
         this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
       }
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index 1a776cf95..9115f5081 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -83,7 +83,7 @@
   "compose_form.spoiler.unmarked": "Text není skrytý",
   "compose_form.spoiler_placeholder": "Sem napište vaše varování",
   "confirmation_modal.cancel": "Zrušit",
-  "confirmations.block.block_and_report": "Block & Report",
+  "confirmations.block.block_and_report": "Blokovat a nahlásit",
   "confirmations.block.confirm": "Blokovat",
   "confirmations.block.message": "Jste si jistý/á, že chcete zablokovat uživatele {name}?",
   "confirmations.delete.confirm": "Smazat",
diff --git a/app/javascript/mastodon/reducers/identity_proofs.js b/app/javascript/mastodon/reducers/identity_proofs.js
new file mode 100644
index 000000000..58af0a5fa
--- /dev/null
+++ b/app/javascript/mastodon/reducers/identity_proofs.js
@@ -0,0 +1,25 @@
+import { Map as ImmutableMap, fromJS } from 'immutable';
+import {
+  IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
+  IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
+  IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
+} from '../actions/identity_proofs';
+
+const initialState = ImmutableMap();
+
+export default function identityProofsReducer(state = initialState, action) {
+  switch(action.type) {
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
+    return state.set('isLoading', true);
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
+    return state.set('isLoading', false);
+  case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
+    return state.update(identity_proofs => identity_proofs.withMutations(map => {
+      map.set('isLoading', false);
+      map.set('loaded', true);
+      map.set(action.accountId, fromJS(action.identity_proofs));
+    }));
+  default:
+    return state;
+  }
+};
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index a7e9c4d0f..981ad8e64 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -30,6 +30,7 @@ import filters from './filters';
 import conversations from './conversations';
 import suggestions from './suggestions';
 import polls from './polls';
+import identity_proofs from './identity_proofs';
 
 const reducers = {
   dropdown_menu,
@@ -56,6 +57,7 @@ const reducers = {
   notifications,
   height_cache,
   custom_emojis,
+  identity_proofs,
   lists,
   listEditor,
   listAdder,
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 5e1f865ea..75a9be045 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3064,15 +3064,19 @@ a.status-card.compact:hover {
 .relationship-tag {
   color: $primary-text-color;
   margin-bottom: 4px;
-  opacity: 0.7;
   display: block;
   vertical-align: top;
-  background-color: rgba($base-overlay-background, 0.4);
+  background-color: $base-overlay-background;
   text-transform: uppercase;
   font-size: 11px;
   font-weight: 500;
   padding: 4px;
   border-radius: 4px;
+  opacity: 0.7;
+
+  &:hover {
+    opacity: 1;
+  }
 }
 
 .setting-toggle {
diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss
index 2b1d988f2..368c2304b 100644
--- a/app/javascript/styles/mastodon/containers.scss
+++ b/app/javascript/styles/mastodon/containers.scss
@@ -10,12 +10,10 @@
 }
 
 .logo-container {
-  margin: 100px auto;
-  margin-bottom: 50px;
+  margin: 100px auto 50px;
 
-  @media screen and (max-width: 400px) {
-    margin: 30px auto;
-    margin-bottom: 20px;
+  @media screen and (max-width: 500px) {
+    margin: 40px auto 0;
   }
 
   h1 {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 3ea104786..91888d305 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -854,13 +854,19 @@ code {
     flex: 1;
     flex-direction: column;
     flex-shrink: 1;
+    max-width: 50%;
 
     &-sep {
+      align-self: center;
       flex-grow: 0;
       overflow: visible;
       position: relative;
       z-index: 1;
     }
+
+    p {
+      word-break: break-word;
+    }
   }
 
   .account__avatar {
@@ -882,12 +888,13 @@ code {
       height: 100%;
       left: 50%;
       position: absolute;
+      top: 0;
       width: 1px;
     }
   }
 
   &__row {
-    align-items: center;
+    align-items: flex-start;
     display: flex;
     flex-direction: row;
   }
diff --git a/app/lib/proof_provider/keybase.rb b/app/lib/proof_provider/keybase.rb
index 96322a265..672e1cb4b 100644
--- a/app/lib/proof_provider/keybase.rb
+++ b/app/lib/proof_provider/keybase.rb
@@ -1,7 +1,8 @@
 # frozen_string_literal: true
 
 class ProofProvider::Keybase
-  BASE_URL = 'https://keybase.io'
+  BASE_URL = ENV.fetch('KEYBASE_BASE_URL', 'https://keybase.io')
+  DOMAIN = ENV.fetch('KEYBASE_DOMAIN', Rails.configuration.x.local_domain)
 
   class Error < StandardError; end
 
diff --git a/app/lib/proof_provider/keybase/config_serializer.rb b/app/lib/proof_provider/keybase/config_serializer.rb
index 557bafe84..5241d201f 100644
--- a/app/lib/proof_provider/keybase/config_serializer.rb
+++ b/app/lib/proof_provider/keybase/config_serializer.rb
@@ -14,7 +14,7 @@ class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer
   end
 
   def domain
-    Rails.configuration.x.local_domain
+    ProofProvider::Keybase::DOMAIN
   end
 
   def display_name
@@ -66,6 +66,6 @@ class ProofProvider::Keybase::ConfigSerializer < ActiveModel::Serializer
   end
 
   def contact
-    [Setting.site_contact_email.presence].compact
+    [Setting.site_contact_email.presence || 'unknown'].compact
   end
 end
diff --git a/app/lib/proof_provider/keybase/verifier.rb b/app/lib/proof_provider/keybase/verifier.rb
index 86f249dd7..ab1422323 100644
--- a/app/lib/proof_provider/keybase/verifier.rb
+++ b/app/lib/proof_provider/keybase/verifier.rb
@@ -49,14 +49,10 @@ class ProofProvider::Keybase::Verifier
 
   def query_params
     {
-      domain: domain,
+      domain: ProofProvider::Keybase::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/models/account_identity_proof.rb b/app/models/account_identity_proof.rb
index e7a3f97e5..1ac234735 100644
--- a/app/models/account_identity_proof.rb
+++ b/app/models/account_identity_proof.rb
@@ -26,7 +26,7 @@ class AccountIdentityProof < ApplicationRecord
 
   scope :active, -> { where(verified: true, live: true) }
 
-  after_create_commit :queue_worker
+  after_commit :queue_worker, if: :saved_change_to_token?
 
   delegate :refresh!, :on_success_path, :badge, to: :provider_instance
 
diff --git a/app/serializers/rest/identity_proof_serializer.rb b/app/serializers/rest/identity_proof_serializer.rb
new file mode 100644
index 000000000..0e7415935
--- /dev/null
+++ b/app/serializers/rest/identity_proof_serializer.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class REST::IdentityProofSerializer < ActiveModel::Serializer
+  attributes :provider, :provider_username, :updated_at, :proof_url, :profile_url
+
+  def proof_url
+    object.badge.proof_url
+  end
+
+  def profile_url
+    object.badge.profile_url
+  end
+
+  def provider
+    object.provider.capitalize
+  end
+end
diff --git a/app/views/settings/identity_proofs/new.html.haml b/app/views/settings/identity_proofs/new.html.haml
index 8ce6e61c9..5e4e9895d 100644
--- a/app/views/settings/identity_proofs/new.html.haml
+++ b/app/views/settings/identity_proofs/new.html.haml
@@ -27,5 +27,10 @@
 
           %p= t('identity_proofs.i_am_html', username: content_tag(:strong, @proof.provider_username), service: @proof.provider.capitalize)
 
+    .connection-prompt__post
+      = f.input :post_status, label: t('identity_proofs.publicize_checkbox'), as: :boolean, wrapper: :with_label, :input_html => { checked: true }
+
+      = f.input :status_text, as: :text, input_html: { value: t('identity_proofs.publicize_toot', username: @proof.provider_username, service: @proof.provider.capitalize, url: @proof.badge.proof_url), rows: 4 }
+
     = f.button :button, t('identity_proofs.authorize'), type: :submit
     = link_to t('simple_form.no'), settings_identity_proofs_url, class: 'button negative'
diff --git a/config/locales/activerecord.cs.yml b/config/locales/activerecord.cs.yml
index e9465228d..57240a19e 100644
--- a/config/locales/activerecord.cs.yml
+++ b/config/locales/activerecord.cs.yml
@@ -2,8 +2,9 @@
 cs:
   activerecord:
     attributes:
-      status:
-        owned_poll: Anketa
+      poll:
+        expires_at: Uzávěrka
+        options: Volby
     errors:
       models:
         account:
diff --git a/config/locales/cs.yml b/config/locales/cs.yml
index fa4a00cb5..2ec3f6790 100644
--- a/config/locales/cs.yml
+++ b/config/locales/cs.yml
@@ -249,6 +249,7 @@ cs:
       feature_profile_directory: Adresář profilů
       feature_registrations: Registrace
       feature_relay: Federovací most
+      feature_timeline_preview: Náhled časové osy
       features: Vlastnosti
       hidden_service: Federace se skrytými službami
       open_reports: otevřená hlášení
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 252f8a8c5..682f85406 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -652,10 +652,13 @@ en:
       keybase:
         invalid_token: Keybase tokens are hashes of signatures and must be 66 hex characters
         verification_failed: Keybase does not recognize this token as a signature of Keybase user %{kb_username}. Please retry from Keybase.
+      wrong_user: Cannot create a proof for %{proving} while logged in as %{current}. Log in as %{proving} and try again.
     explanation_html: Here you can cryptographically connect your other identities, such as a Keybase profile. This lets other people send you encrypted messages and trust content you send them.
     i_am_html: I am %{username} on %{service}.
     identity: Identity
     inactive: Inactive
+    publicize_checkbox: 'And toot this:'
+    publicize_toot: 'It is proven! I am %{username} on %{service}: %{url}'
     status: Verification status
     view_proof: View proof
   imports:
diff --git a/config/routes.rb b/config/routes.rb
index 24e1f8e16..5a51cc6e8 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -366,6 +366,7 @@ Rails.application.routes.draw do
         resources :followers, only: :index, controller: 'accounts/follower_accounts'
         resources :following, only: :index, controller: 'accounts/following_accounts'
         resources :lists, only: :index, controller: 'accounts/lists'
+        resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
 
         member do
           post :follow
diff --git a/lib/cli.rb b/lib/cli.rb
index 65a5ae696..b56c6e76f 100644
--- a/lib/cli.rb
+++ b/lib/cli.rb
@@ -41,6 +41,79 @@ module Mastodon
     desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
     subcommand 'domains', Mastodon::DomainsCLI
 
+    option :dry_run, type: :boolean
+    desc 'self-destruct', 'Erase the server from the federation'
+    long_desc <<~LONG_DESC
+      Erase the server from the federation by broadcasting account delete
+      activities to all known other servers. This allows a "clean exit" from
+      running a Mastodon server, as it leaves next to no cache behind on
+      other servers.
+
+      This command is always interactive and requires confirmation twice.
+
+      No local data is actually deleted, because emptying the
+      database or removing files is much faster through other, external
+      means, such as e.g. deleting the entire VPS. However, because other
+      servers will delete data about local users, but no local data will be
+      updated (such as e.g. followers), there will be a state mismatch
+      that will lead to glitches and issues if you then continue to run and use
+      the server.
+
+      So either you know exactly what you are doing, or you are starting
+      from a blank slate afterwards by manually clearing out all the local
+      data!
+    LONG_DESC
+    def self_destruct
+      require 'tty-prompt'
+
+      prompt = TTY::Prompt.new
+
+      exit(1) unless prompt.ask('Type in the domain of the server to confirm:', required: true) == Rails.configuration.x.local_domain
+
+      prompt.warn('This operation WILL NOT be reversible. It can also take a long time.')
+      prompt.warn('While the data won\'t be erased locally, the server will be in a BROKEN STATE afterwards.')
+      prompt.warn('A running Sidekiq process is required. Do not shut it down until queues clear.')
+
+      exit(1) if prompt.no?('Are you sure you want to proceed?')
+
+      inboxes   = Account.inboxes
+      processed = 0
+      dry_run   = options[:dry_run] ? ' (DRY RUN)' : ''
+
+      if inboxes.empty?
+        prompt.ok('It seems like your server has not federated with anything')
+        prompt.ok('You can shut it down and delete it any time')
+        return
+      end
+
+      prompt.warn('Do NOT interrupt this process...')
+
+      Account.local.without_suspended.find_each do |account|
+        payload = ActiveModelSerializers::SerializableResource.new(
+          account,
+          serializer: ActivityPub::DeleteActorSerializer,
+          adapter: ActivityPub::Adapter
+        ).as_json
+
+        json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(account))
+
+        unless options[:dry_run]
+          ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+            [json, account.id, inbox_url]
+          end
+
+          account.update_column(:suspended, true)
+        end
+
+        processed += 1
+      end
+
+      prompt.ok("Queued #{inboxes.size * processed} items into Sidekiq for #{processed} accounts#{dry_run}")
+      prompt.ok('Wait until Sidekiq processes all items, then you can shut everything down and delete the data')
+    rescue TTY::Reader::InputInterrupt
+      exit(1)
+    end
+
     map %w(--version -v) => :version
 
     desc 'version', 'Show version'
diff --git a/spec/controllers/settings/identity_proofs_controller_spec.rb b/spec/controllers/settings/identity_proofs_controller_spec.rb
index 46af3ccf4..5c05eb83c 100644
--- a/spec/controllers/settings/identity_proofs_controller_spec.rb
+++ b/spec/controllers/settings/identity_proofs_controller_spec.rb
@@ -1,6 +1,7 @@
 require 'rails_helper'
 
 describe Settings::IdentityProofsController do
+  include RoutingHelper
   render_views
 
   let(:user) { Fabricate(:user) }
@@ -9,8 +10,15 @@ describe Settings::IdentityProofsController do
   let(:provider) { 'keybase' }
   let(:findable_id) { Faker::Number.number(5) }
   let(:unfindable_id) { Faker::Number.number(5) }
+  let(:new_proof_params) do
+    { provider: provider, provider_username: kbname, token: valid_token, username: user.account.username }
+  end
+  let(:status_text) { "i just proved that i am also #{kbname} on #{provider}." }
+  let(:status_posting_params) do
+    { post_status: '0', status_text: status_text }
+  end
   let(:postable_params) do
-    { account_identity_proof: { provider: provider, provider_username: kbname, token: valid_token } }
+    { account_identity_proof: new_proof_params.merge(status_posting_params) }
   end
 
   before do
@@ -19,10 +27,32 @@ describe Settings::IdentityProofsController do
   end
 
   describe 'new proof creation' do
-    context 'GET #new with no existing proofs' do
-      it 'redirects to :index' do
-        get :new
-        expect(response).to redirect_to settings_identity_proofs_path
+    context 'GET #new' do
+      context 'with all of the correct params' do
+        before do
+          allow_any_instance_of(ProofProvider::Keybase::Badge).to receive(:avatar_url) { full_pack_url('media/images/void.png') }
+        end
+
+        it 'renders the template' do
+          get :new, params: new_proof_params
+          expect(response).to render_template(:new)
+        end
+      end
+
+      context 'without any params' do
+        it 'redirects to :index' do
+          get :new, params: {}
+          expect(response).to redirect_to settings_identity_proofs_path
+        end
+      end
+
+      context 'with params to prove a different, not logged-in user' do
+        let(:wrong_user_params) { new_proof_params.merge(username: 'someone_else') }
+
+        it 'shows a helpful alert' do
+          get :new, params: wrong_user_params
+          expect(flash[:alert]).to eq I18n.t('identity_proofs.errors.wrong_user', proving: 'someone_else', current: user.account.username)
+        end
       end
     end
 
@@ -44,6 +74,23 @@ describe Settings::IdentityProofsController do
           post :create, params: postable_params
           expect(response).to redirect_to root_url
         end
+
+        it 'does not post a status' do
+          expect(PostStatusService).not_to receive(:new)
+          post :create, params: postable_params
+        end
+
+        context 'and the user has requested to post a status' do
+          let(:postable_params_with_status) do
+            postable_params.tap { |p| p[:account_identity_proof][:post_status] = '1' }
+          end
+
+          it 'posts a status' do
+            expect_any_instance_of(PostStatusService).to receive(:call).with(user.account, text: status_text)
+
+            post :create, params: postable_params_with_status
+          end
+        end
       end
 
       context 'when saving fails' do