diff --git a/Gemfile b/Gemfile index a00b3f51373..7f90ab5aaed 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 45370d15434..5df1dc0adcd 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 8f5e1887ea4..1501b914ec3 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 e160c603a82..e7795e95c15 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 00000000000..a84ad2014fe --- /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 00000000000..4a3b89a5e7f --- /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 00000000000..eb41e586f84 --- /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 241addb832c..92bc222ea9d 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 ccd84364e83..a8c3fe16a2a 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 a1b297ce750..56331cb2903 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 17f06471359..4fbd504efe3 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 5e2b3fc6d76..5f8a369ffe4 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 7a457600e67..d4ead07a1c1 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 6051c1d00c2..9ef45e425a7 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 00000000000..e44bcf5e14b --- /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 00000000000..7e3ac657f41 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 ea80c0efb39..5badb0c49ea 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 a1b297ce750..56331cb2903 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 0880d98c8d7..73919c39dd4 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 10129e606f6..19b0b14be6a 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 465ef2c1198..d3b4a59098d 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 6051c1d00c2..9ef45e425a7 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 464e1ee7e87..aadf03b2ac1 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 00000000000..102c50f4f9e --- /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 00000000000..96322a265d7 --- /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 00000000000..3aa067ecf40 --- /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 00000000000..474ea74e270 --- /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 00000000000..d29283600ef --- /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 00000000000..86f249dd784 --- /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 00000000000..2872f59c10d --- /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 00000000000..e7a3f97e548 --- /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 1b22f750cfd..ecccaf35ef4 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 6df23033706..8f72c7b1124 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 95f3368205e..c049401e894 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 4dae1c09f43..356c45b8386 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 24fa1be696c..6c2ecad30f6 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 21dcf226d23..45e5f0717b6 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 2ea34a0485f..efc26d1366c 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 00000000000..524827ad749 --- /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 00000000000..d0ea03ecd0e --- /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 00000000000..8ce6e61c9d0 --- /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 b19d2452ad9..d18ecd37a4b 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 d6b2c0cd918..ba34890dfee 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 d3441ca904e..1952128a093 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 5bade72fcef..150fdce6c38 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 81059b163d4..7e1d92884fa 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 e730edfa260..86c2572d79b 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 d521d66ca16..24e1f8e160d 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 00000000000..ddcbce3f369 --- /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 32758da1c95..7e5f06c38b0 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 4055d93424c..eab4b8c3e65 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 00000000000..dbde4927f14 --- /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 00000000000..46af3ccf468 --- /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 00000000000..9067e676deb --- /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 00000000000..94f40dfd6b7 --- /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 00000000000..4ce67da9c56 --- /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