diff --git a/Gemfile b/Gemfile
index a00b3f5137..7f90ab5aae 100644
--- a/Gemfile
+++ b/Gemfile
@@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.2'
gem 'dotenv-rails', '~> 2.7'
-gem 'aws-sdk-s3', '~> 1.32', require: false
+gem 'aws-sdk-s3', '~> 1.33', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@@ -128,7 +128,7 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
- gem 'rubocop', '~> 0.65', require: false
+ gem 'rubocop', '~> 0.66', require: false
gem 'brakeman', '~> 4.5', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'scss_lint', '~> 0.57', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index 45370d1543..5df1dc0adc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -77,17 +77,16 @@ GEM
cocaine (~> 0.5.3)
aws-eventstream (1.0.2)
aws-partitions (1.144.0)
- aws-sdk-core (3.47.0)
+ aws-sdk-core (3.48.0)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.1)
- http-2 (~> 0.10)
jmespath (~> 1.0)
- aws-sdk-kms (1.14.0)
- aws-sdk-core (~> 3, >= 3.47.0)
+ aws-sdk-kms (1.15.0)
+ aws-sdk-core (~> 3, >= 3.48.0)
aws-sigv4 (~> 1.1)
- aws-sdk-s3 (1.32.0)
- aws-sdk-core (~> 3, >= 3.47.0)
+ aws-sdk-s3 (1.33.0)
+ aws-sdk-core (~> 3, >= 3.48.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
aws-sigv4 (1.1.0)
@@ -261,7 +260,6 @@ GEM
html2text (0.2.1)
nokogiri (~> 1.6)
htmlentities (4.3.4)
- http-2 (0.10.1)
http (3.3.0)
addressable (~> 2.3)
http-cookie (~> 1.0)
@@ -394,7 +392,7 @@ GEM
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
- parallel (1.13.0)
+ parallel (1.14.0)
parallel_tests (2.28.0)
parallel
parser (2.6.0.0)
@@ -406,7 +404,6 @@ GEM
pghero (2.2.0)
activerecord
pkg-config (1.3.7)
- powerpack (0.1.2)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
@@ -531,15 +528,14 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
- rubocop (0.65.0)
+ rubocop (0.66.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.5, != 2.5.1.1)
- powerpack (~> 0.1)
psych (>= 3.1.0)
rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7)
- unicode-display_width (~> 1.4.0)
+ unicode-display_width (>= 1.4.0, < 1.6)
ruby-progressbar (1.10.0)
ruby-saml (1.9.0)
nokogiri (>= 1.5.10)
@@ -634,7 +630,7 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.7.5)
- unicode-display_width (1.4.1)
+ unicode-display_width (1.5.0)
uniform_notifier (1.12.1)
warden (1.2.7)
rack (>= 1.0)
@@ -664,7 +660,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.6)
addressable (~> 2.6)
annotate (~> 2.7)
- aws-sdk-s3 (~> 1.32)
+ aws-sdk-s3 (~> 1.33)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
bootsnap (~> 1.4)
@@ -754,7 +750,7 @@ DEPENDENCIES
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
- rubocop (~> 0.65)
+ rubocop (~> 0.66)
sanitize (~> 5.0)
scss_lint (~> 0.57)
sidekiq (~> 5.2)
diff --git a/app/controllers/activitypub/inboxes_controller.rb b/app/controllers/activitypub/inboxes_controller.rb
index 8f5e1887ea..1501b914ec 100644
--- a/app/controllers/activitypub/inboxes_controller.rb
+++ b/app/controllers/activitypub/inboxes_controller.rb
@@ -2,11 +2,14 @@
class ActivityPub::InboxesController < Api::BaseController
include SignatureVerification
+ include JsonLdHelper
before_action :set_account
def create
- if signed_request_account
+ if unknown_deleted_account?
+ head 202
+ elsif signed_request_account
upgrade_account
process_payload
head 202
@@ -17,12 +20,19 @@ class ActivityPub::InboxesController < Api::BaseController
private
+ def unknown_deleted_account?
+ json = Oj.load(body, mode: :strict)
+ json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
+ rescue Oj::ParseError
+ false
+ end
+
def set_account
@account = Account.find_local!(params[:account_username]) if params[:account_username]
end
def body
- @body ||= request.body.read
+ @body ||= request.body.read.force_encoding('UTF-8')
end
def upgrade_account
@@ -36,6 +46,6 @@ class ActivityPub::InboxesController < Api::BaseController
end
def process_payload
- ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body.force_encoding('UTF-8'), @account&.id)
+ ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
end
end
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index e160c603a8..e7795e95c1 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 0000000000..a84ad2014f
--- /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 0000000000..4a3b89a5e7
--- /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 0000000000..eb41e586f8
--- /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 241addb832..92bc222ea9 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/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index ccd84364e8..a8c3fe16a2 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -69,9 +69,11 @@ export function normalizeStatus(status, normalOldStatus) {
export function normalizePoll(poll) {
const normalPoll = { ...poll };
+ const emojiMap = makeEmojiMap(normalPoll);
+
normalPoll.options = poll.options.map(option => ({
...option,
- title_emojified: emojify(escapeTextContentForBrowser(option.title)),
+ title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
return normalPoll;
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index a1b297ce75..56331cb290 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -44,6 +44,11 @@ const timeRemainingString = (intl, date, now) => {
return relativeTime;
};
+const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
+ obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+ return obj;
+}, {});
+
export default @injectIntl
class Poll extends ImmutablePureComponent {
@@ -99,6 +104,12 @@ class Poll extends ImmutablePureComponent {
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
+ let titleEmojified = option.get('title_emojified');
+ if (!titleEmojified) {
+ const emojiMap = makeEmojiMap(poll);
+ titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+ }
+
return (
{showResults && (
@@ -122,7 +133,7 @@ class Poll extends ImmutablePureComponent {
{!showResults && }
{showResults && {Math.round(percent)}%}
-
+
);
diff --git a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
index 17f0647135..4fbd504efe 100644
--- a/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/timelines';
-import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
import Masonry from 'react-masonry-infinite';
import { List as ImmutableList } from 'immutable';
import DetailedStatusContainer from 'flavours/glitch/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/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
index 5e2b3fc6d7..5f8a369ffe 100644
--- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/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 'flavours/glitch/actions/timelines';
-import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming';
import Masonry from 'react-masonry-infinite';
import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
@@ -42,24 +41,12 @@ class PublicTimeline extends React.PureComponent {
}
}
- 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 = () => {
const { dispatch, statusIds, local } = this.props;
const maxId = statusIds.last();
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
index 7a457600e6..d4ead07a1c 100644
--- a/app/javascript/flavours/glitch/styles/about.scss
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -660,7 +660,7 @@ $small-breakpoint: 960px;
display: flex;
justify-content: center;
align-items: center;
- padding: 100px;
+ padding: 50px;
img {
height: 52px;
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index 6051c1d00c..9ef45e425a 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/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/javascript/images/logo_transparent_black.svg b/app/javascript/images/logo_transparent_black.svg
new file mode 100644
index 0000000000..e44bcf5e14
--- /dev/null
+++ b/app/javascript/images/logo_transparent_black.svg
@@ -0,0 +1 @@
+
diff --git a/app/javascript/images/proof_providers/keybase.png b/app/javascript/images/proof_providers/keybase.png
new file mode 100644
index 0000000000..7e3ac657f4
Binary files /dev/null and b/app/javascript/images/proof_providers/keybase.png differ
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index ea80c0efb3..5badb0c49e 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -71,9 +71,11 @@ export function normalizeStatus(status, normalOldStatus) {
export function normalizePoll(poll) {
const normalPoll = { ...poll };
+ const emojiMap = makeEmojiMap(normalPoll);
+
normalPoll.options = poll.options.map(option => ({
...option,
- title_emojified: emojify(escapeTextContentForBrowser(option.title)),
+ title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap),
}));
return normalPoll;
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index a1b297ce75..56331cb290 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -44,6 +44,11 @@ const timeRemainingString = (intl, date, now) => {
return relativeTime;
};
+const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
+ obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
+ return obj;
+}, {});
+
export default @injectIntl
class Poll extends ImmutablePureComponent {
@@ -99,6 +104,12 @@ class Poll extends ImmutablePureComponent {
const active = !!this.state.selected[`${optionIndex}`];
const showResults = poll.get('voted') || poll.get('expired');
+ let titleEmojified = option.get('title_emojified');
+ if (!titleEmojified) {
+ const emojiMap = makeEmojiMap(poll);
+ titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap);
+ }
+
return (
{showResults && (
@@ -122,7 +133,7 @@ class Poll extends ImmutablePureComponent {
{!showResults && }
{showResults && {Math.round(percent)}%}
-
+
);
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 0880d98c8d..73919c39dd 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 10129e606f..19b0b14be6 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 465ef2c119..d3b4a59098 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 6051c1d00c..9ef45e425a 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/formatter.rb b/app/lib/formatter.rb
index 464e1ee7e8..aadf03b2ac 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -71,6 +71,12 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
+ def format_poll_option(status, option, **options)
+ html = encode(option.title)
+ html = encode_custom_emojis(html, status.emojis, options[:autoplay])
+ html.html_safe # rubocop:disable Rails/OutputSafety
+ end
+
def format_display_name(account, **options)
html = encode(account.display_name.presence || account.username)
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
diff --git a/app/lib/proof_provider.rb b/app/lib/proof_provider.rb
new file mode 100644
index 0000000000..102c50f4f9
--- /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 0000000000..96322a265d
--- /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 0000000000..3aa067ecf4
--- /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 0000000000..474ea74e27
--- /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 0000000000..d29283600e
--- /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 0000000000..86f249dd78
--- /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 0000000000..2872f59c10
--- /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 0000000000..e7a3f97e54
--- /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 1b22f750cf..ecccaf35ef 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/models/poll.rb b/app/models/poll.rb
index 6df2303370..8f72c7b112 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -60,6 +60,10 @@ class Poll < ApplicationRecord
!local?
end
+ def emojis
+ @emojis ||= CustomEmoji.from_text(options.join(' '), account.domain)
+ end
+
class Option < ActiveModelSerializers::Model
attributes :id, :title, :votes_count, :poll
diff --git a/app/models/status.rb b/app/models/status.rb
index 95f3368205..c049401e89 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -218,7 +218,11 @@ class Status < ApplicationRecord
end
def emojis
- @emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
+ return @emojis if defined?(@emojis)
+ fields = [spoiler_text, text]
+ fields += owned_poll.options unless owned_poll.nil?
+ @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
+ @emojis
end
def mark_for_mass_destruction!
diff --git a/app/serializers/rest/poll_serializer.rb b/app/serializers/rest/poll_serializer.rb
index 4dae1c09f4..356c45b838 100644
--- a/app/serializers/rest/poll_serializer.rb
+++ b/app/serializers/rest/poll_serializer.rb
@@ -5,6 +5,7 @@ class REST::PollSerializer < ActiveModel::Serializer
:multiple, :votes_count
has_many :loaded_options, key: :options
+ has_many :emojis, serializer: REST::CustomEmojiSerializer
attribute :voted, if: :current_user?
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 24fa1be696..6c2ecad30f 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 21dcf226d2..45e5f0717b 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 2ea34a0485..efc26d1366 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 0000000000..524827ad74
--- /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 0000000000..d0ea03ecd0
--- /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 0000000000..8ce6e61c9d
--- /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'
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index b19d2452ad..d18ecd37a4 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -24,7 +24,7 @@
- if status.poll
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
- = render partial: 'stream_entries/poll', locals: { poll: status.poll }
+ = render partial: 'stream_entries/poll', locals: { status: status, poll: status.poll, autoplay: autoplay }
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
diff --git a/app/views/stream_entries/_poll.html.haml b/app/views/stream_entries/_poll.html.haml
index d6b2c0cd91..ba34890dfe 100644
--- a/app/views/stream_entries/_poll.html.haml
+++ b/app/views/stream_entries/_poll.html.haml
@@ -10,11 +10,11 @@
%label.poll__text><
%span.poll__number= percent.round
- = option.title
+ = Formatter.instance.format_poll_option(status, option, autoplay: autoplay)
- else
%label.poll__text><
%span.poll__input{ class: poll.multiple? ? 'checkbox' : nil}><
- = option.title
+ = Formatter.instance.format_poll_option(status, option, autoplay: autoplay)
.poll__footer
- unless show_results
%button.button.button-secondary{ disabled: true }
diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml
index d3441ca904..1952128a09 100644
--- a/app/views/stream_entries/_simple_status.html.haml
+++ b/app/views/stream_entries/_simple_status.html.haml
@@ -29,7 +29,7 @@
- if status.poll
= react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
- = render partial: 'stream_entries/poll', locals: { poll: status.poll }
+ = render partial: 'stream_entries/poll', locals: { status: status, poll: status.poll, autoplay: autoplay }
- elsif !status.media_attachments.empty?
- if status.media_attachments.first.video?
- video = status.media_attachments.first
diff --git a/config/application.rb b/config/application.rb
index 5bade72fce..150fdce6c3 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -41,6 +41,7 @@ module Mastodon
:ar,
:ast,
:bg,
+ :bn,
:ca,
:co,
:cs,
@@ -54,8 +55,10 @@ module Mastodon
:fa,
:fi,
:fr,
+ :ga,
:gl,
:he,
+ :hi,
:hr,
:hu,
:hy,
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 81059b163d..7e1d92884f 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -637,6 +637,21 @@ en:
validation_errors:
one: Something isn't quite right yet! Please review the error below
other: Something isn't quite right yet! Please review %{count} errors below
+ identity_proofs:
+ active: Active
+ authorize: Yes, authorize
+ authorize_connection_prompt: Authorize this cryptographic connection?
+ errors:
+ failed: The cryptographic connection failed. Please try again from %{provider}.
+ 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.
+ 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
+ status: Verification status
+ view_proof: View proof
imports:
modes:
merge: Merge
@@ -840,6 +855,7 @@ en:
export: Data export
featured_tags: Featured hashtags
flavours: Flavours
+ identity_proofs: Identity proofs
import: Import
migrate: Account migration
notifications: Notifications
diff --git a/config/navigation.rb b/config/navigation.rb
index e730edfa26..86c2572d79 100644
--- a/config/navigation.rb
+++ b/config/navigation.rb
@@ -14,6 +14,7 @@ SimpleNavigation::Configuration.run do |navigation|
settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url
settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url
settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url
+ settings.item :identity_proofs, safe_join([fa_icon('key fw'), t('settings.identity_proofs')]), settings_identity_proofs_path, highlights_on: %r{/settings/identity_proofs*}
end
primary.item :flavours, safe_join([fa_icon('paint-brush fw'), t('settings.flavours')]), settings_flavours_url do |flavours|
diff --git a/config/routes.rb b/config/routes.rb
index d521d66ca1..24e1f8e160 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -22,6 +22,8 @@ Rails.application.routes.draw do
get '.well-known/host-meta', to: 'well_known/host_meta#show', as: :host_meta, defaults: { format: 'xml' }
get '.well-known/webfinger', to: 'well_known/webfinger#show', as: :webfinger
get '.well-known/change-password', to: redirect('/auth/edit')
+ get '.well-known/keybase-proof-config', to: 'well_known/keybase_proof_config#show'
+
get 'manifest', to: 'manifests#show', defaults: { format: 'json' }
get 'intent', to: 'intents#show'
get 'custom.css', to: 'custom_css#show', as: :custom_css
@@ -107,6 +109,8 @@ Rails.application.routes.draw do
resource :confirmation, only: [:new, :create]
end
+ resources :identity_proofs, only: [:index, :show, :new, :create, :update]
+
resources :applications, except: [:edit] do
member do
post :regenerate
@@ -251,6 +255,9 @@ Rails.application.routes.draw do
# OEmbed
get '/oembed', to: 'oembed#show', as: :oembed
+ # Identity proofs
+ get :proofs, to: 'proofs#index'
+
# JSON / REST API
namespace :v1 do
resources :statuses, only: [:create, :show, :destroy] do
diff --git a/db/migrate/20190316190352_create_account_identity_proofs.rb b/db/migrate/20190316190352_create_account_identity_proofs.rb
new file mode 100644
index 0000000000..ddcbce3f36
--- /dev/null
+++ b/db/migrate/20190316190352_create_account_identity_proofs.rb
@@ -0,0 +1,16 @@
+class CreateAccountIdentityProofs < ActiveRecord::Migration[5.2]
+ def change
+ create_table :account_identity_proofs do |t|
+ t.belongs_to :account, foreign_key: { on_delete: :cascade }
+ t.string :provider, null: false, default: ''
+ t.string :provider_username, null: false, default: ''
+ t.text :token, null: false, default: ''
+ t.boolean :verified, null: false, default: false
+ t.boolean :live, null: false, default: false
+
+ t.timestamps null: false
+ end
+
+ add_index :account_identity_proofs, [:account_id, :provider, :provider_username], unique: true, name: :index_account_proofs_on_account_and_provider_and_username
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 32758da1c9..7e5f06c38b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -36,6 +36,19 @@ ActiveRecord::Schema.define(version: 2019_03_17_135723) do
t.index ["account_id", "domain"], name: "index_account_domain_blocks_on_account_id_and_domain", unique: true
end
+ create_table "account_identity_proofs", force: :cascade do |t|
+ t.bigint "account_id"
+ t.string "provider", default: "", null: false
+ t.string "provider_username", default: "", null: false
+ t.text "token", default: "", null: false
+ t.boolean "verified", default: false, null: false
+ t.boolean "live", default: false, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["account_id", "provider", "provider_username"], name: "index_account_proofs_on_account_and_provider_and_username", unique: true
+ t.index ["account_id"], name: "index_account_identity_proofs_on_account_id"
+ end
+
create_table "account_moderation_notes", force: :cascade do |t|
t.text "content", null: false
t.bigint "account_id", null: false
@@ -744,6 +757,7 @@ ActiveRecord::Schema.define(version: 2019_03_17_135723) do
add_foreign_key "account_conversations", "accounts", on_delete: :cascade
add_foreign_key "account_conversations", "conversations", on_delete: :cascade
add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
+ add_foreign_key "account_identity_proofs", "accounts", on_delete: :cascade
add_foreign_key "account_moderation_notes", "accounts"
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
diff --git a/spec/controllers/activitypub/inboxes_controller_spec.rb b/spec/controllers/activitypub/inboxes_controller_spec.rb
index 4055d93424..eab4b8c3e6 100644
--- a/spec/controllers/activitypub/inboxes_controller_spec.rb
+++ b/spec/controllers/activitypub/inboxes_controller_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
Fabricate(:account)
end
- post :create
+ post :create, body: '{}'
expect(response).to have_http_status(202)
end
end
@@ -21,7 +21,7 @@ RSpec.describe ActivityPub::InboxesController, type: :controller do
false
end
- post :create
+ post :create, body: '{}'
expect(response).to have_http_status(401)
end
end
diff --git a/spec/controllers/api/proofs_controller_spec.rb b/spec/controllers/api/proofs_controller_spec.rb
new file mode 100644
index 0000000000..dbde4927f1
--- /dev/null
+++ b/spec/controllers/api/proofs_controller_spec.rb
@@ -0,0 +1,96 @@
+require 'rails_helper'
+
+describe Api::ProofsController do
+ let(:alice) { Fabricate(:account, username: 'alice') }
+
+ before do
+ stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":false}')
+ stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=crypto_alice&sig_hash=111111111111111111111111111111111111111111111111111111111111111111&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}')
+ stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_valid.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}')
+ stub_request(:get, 'https://keybase.io/_/api/1.0/sig/proof_live.json?domain=cb6e6126.ngrok.io&kb_username=hidden_alice&sig_hash=222222222222222222222222222222222222222222222222222222222222222222&username=alice').to_return(status: 200, body: '{"proof_valid":true,"proof_live":true}')
+ end
+
+ describe 'GET #index' do
+ describe 'with a non-existent username' do
+ it '404s' do
+ get :index, params: { username: 'nonexistent', provider: 'keybase' }
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ describe 'with a user that has no proofs' do
+ it 'is an empty list of signatures' do
+ get :index, params: { username: alice.username, provider: 'keybase' }
+
+ expect(body_as_json[:signatures]).to eq []
+ end
+ end
+
+ describe 'with a user that has a live, valid proof' do
+ let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' }
+ let(:kb_name1) { 'crypto_alice' }
+
+ before do
+ Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1)
+ end
+
+ it 'is a list with that proof in it' do
+ get :index, params: { username: alice.username, provider: 'keybase' }
+
+ expect(body_as_json[:signatures]).to eq [
+ { kb_username: kb_name1, sig_hash: token1 },
+ ]
+ end
+
+ describe 'add one that is neither live nor valid' do
+ let(:token2) { '222222222222222222222222222222222222222222222222222222222222222222' }
+ let(:kb_name2) { 'hidden_alice' }
+
+ before do
+ Fabricate(:account_identity_proof, account: alice, verified: false, live: false, token: token2, provider_username: kb_name2)
+ end
+
+ it 'is a list with both proofs' do
+ get :index, params: { username: alice.username, provider: 'keybase' }
+
+ expect(body_as_json[:signatures]).to eq [
+ { kb_username: kb_name1, sig_hash: token1 },
+ { kb_username: kb_name2, sig_hash: token2 },
+ ]
+ end
+ end
+ end
+
+ describe 'a user that has an avatar' do
+ let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('avatar.gif')) }
+
+ context 'and a proof' do
+ let(:token1) { '111111111111111111111111111111111111111111111111111111111111111111' }
+ let(:kb_name1) { 'crypto_alice' }
+
+ before do
+ Fabricate(:account_identity_proof, account: alice, verified: true, live: true, token: token1, provider_username: kb_name1)
+ get :index, params: { username: alice.username, provider: 'keybase' }
+ end
+
+ it 'has two keys: signatures and avatar' do
+ expect(body_as_json.keys).to match_array [:signatures, :avatar]
+ end
+
+ it 'has the correct signatures' do
+ expect(body_as_json[:signatures]).to eq [
+ { kb_username: kb_name1, sig_hash: token1 },
+ ]
+ end
+
+ it 'has the correct avatar url' do
+ first_part = 'https://cb6e6126.ngrok.io/system/accounts/avatars/'
+ last_part = 'original/avatar.gif'
+
+ expect(body_as_json[:avatar]).to match /#{Regexp.quote(first_part)}(?:\d{3,5}\/){3}#{Regexp.quote(last_part)}/
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/settings/identity_proofs_controller_spec.rb b/spec/controllers/settings/identity_proofs_controller_spec.rb
new file mode 100644
index 0000000000..46af3ccf46
--- /dev/null
+++ b/spec/controllers/settings/identity_proofs_controller_spec.rb
@@ -0,0 +1,112 @@
+require 'rails_helper'
+
+describe Settings::IdentityProofsController do
+ render_views
+
+ let(:user) { Fabricate(:user) }
+ let(:valid_token) { '1'*66 }
+ let(:kbname) { 'kbuser' }
+ let(:provider) { 'keybase' }
+ let(:findable_id) { Faker::Number.number(5) }
+ let(:unfindable_id) { Faker::Number.number(5) }
+ let(:postable_params) do
+ { account_identity_proof: { provider: provider, provider_username: kbname, token: valid_token } }
+ end
+
+ before do
+ allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:status) { { 'proof_valid' => true, 'proof_live' => true } }
+ sign_in user, scope: :user
+ 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
+ end
+ end
+
+ context 'POST #create' do
+ context 'when saving works' do
+ before do
+ allow(ProofProvider::Keybase::Worker).to receive(:perform_async)
+ allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true }
+ allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url }
+ end
+
+ it 'serializes a ProofProvider::Keybase::Worker' do
+ expect(ProofProvider::Keybase::Worker).to receive(:perform_async)
+ post :create, params: postable_params
+ end
+
+ it 'delegates redirection to the proof provider' do
+ expect_any_instance_of(AccountIdentityProof).to receive(:on_success_path)
+ post :create, params: postable_params
+ expect(response).to redirect_to root_url
+ end
+ end
+
+ context 'when saving fails' do
+ before do
+ allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { false }
+ end
+
+ it 'redirects to :index' do
+ post :create, params: postable_params
+ expect(response).to redirect_to settings_identity_proofs_path
+ end
+
+ it 'flashes a helpful message' do
+ post :create, params: postable_params
+ expect(flash[:alert]).to eq I18n.t('identity_proofs.errors.failed', provider: 'Keybase')
+ end
+ end
+
+ context 'it can also do an update if the provider and username match an existing proof' do
+ before do
+ allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true }
+ allow(ProofProvider::Keybase::Worker).to receive(:perform_async)
+ Fabricate(:account_identity_proof, account: user.account, provider: provider, provider_username: kbname)
+ allow_any_instance_of(AccountIdentityProof).to receive(:on_success_path) { root_url }
+ end
+
+ it 'calls update with the new token' do
+ expect_any_instance_of(AccountIdentityProof).to receive(:save) do |proof|
+ expect(proof.token).to eq valid_token
+ end
+
+ post :create, params: postable_params
+ end
+ end
+ end
+ end
+
+ describe 'GET #index' do
+ context 'with no existing proofs' do
+ it 'shows the helpful explanation' do
+ get :index
+ expect(response.body).to match I18n.t('identity_proofs.explanation_html')
+ end
+ end
+
+ context 'with two proofs' do
+ before do
+ allow_any_instance_of(ProofProvider::Keybase::Verifier).to receive(:valid?) { true }
+ @proof1 = Fabricate(:account_identity_proof, account: user.account)
+ @proof2 = Fabricate(:account_identity_proof, account: user.account)
+ allow_any_instance_of(AccountIdentityProof).to receive(:badge) { double(avatar_url: '', profile_url: '', proof_url: '') }
+ allow_any_instance_of(AccountIdentityProof).to receive(:refresh!) { }
+ end
+
+ it 'has the first proof username on the page' do
+ get :index
+ expect(response.body).to match /#{Regexp.quote(@proof1.provider_username)}/
+ end
+
+ it 'has the second proof username on the page' do
+ get :index
+ expect(response.body).to match /#{Regexp.quote(@proof2.provider_username)}/
+ end
+ end
+ end
+end
diff --git a/spec/controllers/well_known/keybase_proof_config_controller_spec.rb b/spec/controllers/well_known/keybase_proof_config_controller_spec.rb
new file mode 100644
index 0000000000..9067e676de
--- /dev/null
+++ b/spec/controllers/well_known/keybase_proof_config_controller_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+describe WellKnown::KeybaseProofConfigController, type: :controller do
+ render_views
+
+ describe 'GET #show' do
+ it 'renders json' do
+ get :show
+
+ expect(response).to have_http_status(200)
+ expect(response.content_type).to eq 'application/json'
+ expect { JSON.parse(response.body) }.not_to raise_exception
+ end
+ end
+end
diff --git a/spec/fabricators/account_identity_proof_fabricator.rb b/spec/fabricators/account_identity_proof_fabricator.rb
new file mode 100644
index 0000000000..94f40dfd6b
--- /dev/null
+++ b/spec/fabricators/account_identity_proof_fabricator.rb
@@ -0,0 +1,8 @@
+Fabricator(:account_identity_proof) do
+ account
+ provider 'keybase'
+ provider_username { sequence(:provider_username) { |i| "#{Faker::Lorem.characters(15)}" } }
+ token { sequence(:token) { |i| "#{i}#{Faker::Crypto.sha1()*2}"[0..65] } }
+ verified false
+ live false
+end
diff --git a/spec/lib/proof_provider/keybase/verifier_spec.rb b/spec/lib/proof_provider/keybase/verifier_spec.rb
new file mode 100644
index 0000000000..4ce67da9c5
--- /dev/null
+++ b/spec/lib/proof_provider/keybase/verifier_spec.rb
@@ -0,0 +1,82 @@
+require 'rails_helper'
+
+describe ProofProvider::Keybase::Verifier do
+ let(:my_domain) { Rails.configuration.x.local_domain }
+
+ let(:keybase_proof) do
+ local_proof = AccountIdentityProof.new(
+ provider: 'Keybase',
+ provider_username: 'cryptoalice',
+ token: '11111111111111111111111111'
+ )
+
+ described_class.new('alice', 'cryptoalice', '11111111111111111111111111')
+ end
+
+ let(:query_params) do
+ "domain=#{my_domain}&kb_username=cryptoalice&sig_hash=11111111111111111111111111&username=alice"
+ end
+
+ describe '#valid?' do
+ let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_valid.json' }
+
+ context 'when valid' do
+ before do
+ json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":true}'
+ stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body)
+ end
+
+ it 'calls out to keybase and returns true' do
+ expect(keybase_proof.valid?).to eq true
+ end
+ end
+
+ context 'when invalid' do
+ before do
+ json_response_body = '{"status":{"code":0,"name":"OK"},"proof_valid":false}'
+ stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body)
+ end
+
+ it 'calls out to keybase and returns false' do
+ expect(keybase_proof.valid?).to eq false
+ end
+ end
+
+ context 'with an unexpected api response' do
+ before do
+ json_response_body = '{"status":{"code":100,"desc":"wrong size hex_id","fields":{"sig_hash":"wrong size hex_id"},"name":"INPUT_ERROR"}}'
+ stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body)
+ end
+
+ it 'swallows the error and returns false' do
+ expect(keybase_proof.valid?).to eq false
+ end
+ end
+ end
+
+ describe '#status' do
+ let(:base_url) { 'https://keybase.io/_/api/1.0/sig/proof_live.json' }
+
+ context 'with a normal response' do
+ before do
+ json_response_body = '{"status":{"code":0,"name":"OK"},"proof_live":false,"proof_valid":true}'
+ stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body)
+ end
+
+ it 'calls out to keybase and returns the status fields as proof_valid and proof_live' do
+ expect(keybase_proof.status).to include({ 'proof_valid' => true, 'proof_live' => false })
+ end
+ end
+
+ context 'with an unexpected keybase response' do
+ before do
+ json_response_body = '{"status":{"code":100,"desc":"missing non-optional field sig_hash","fields":{"sig_hash":"missing non-optional field sig_hash"},"name":"INPUT_ERROR"}}'
+ stub_request(:get, "#{base_url}?#{query_params}").to_return(status: 200, body: json_response_body)
+ end
+
+ it 'raises a ProofProvider::Keybase::UnexpectedResponseError' do
+ expect { keybase_proof.status }.to raise_error ProofProvider::Keybase::UnexpectedResponseError
+ end
+ end
+ end
+end