Merge commit '45ba9ada34' into kb_migration_development

This commit is contained in:
KMY 2023-05-17 20:06:53 +09:00
commit 922c936ef1
26 changed files with 336 additions and 18 deletions

View file

@ -160,3 +160,5 @@ gem 'cocoon', '~> 1.2'
gem 'net-http', '~> 0.3.2'
gem 'rubyzip', '~> 2.3'
gem 'hcaptcha', '~> 7.1'

View file

@ -312,6 +312,8 @@ GEM
sysexits (~> 1.1)
hashdiff (1.0.1)
hashie (5.0.0)
hcaptcha (7.1.0)
json
highline (2.1.0)
hiredis (0.6.3)
hkdf (0.3.0)
@ -696,7 +698,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0)
thor (1.2.1)
thor (1.2.2)
tilt (2.1.0)
timeout (0.3.2)
tpm-key_attestation (0.12.0)
@ -806,6 +808,7 @@ DEPENDENCIES
fuubar (~> 2.5)
haml-rails (~> 2.0)
haml_lint
hcaptcha (~> 7.1)
hiredis (~> 0.6)
htmlentities (~> 4.3)
http (~> 5.1)

View file

@ -1,9 +1,10 @@
# frozen_string_literal: true
class Api::V1::Emails::ConfirmationsController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
before_action :require_user_owned_by_application!
before_action :require_user_not_confirmed!
before_action -> { authorize_if_got_token! :read, :'read:accounts' }, only: :check
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :check
before_action :require_user_owned_by_application!, except: :check
before_action :require_user_not_confirmed!, except: :check
def create
current_user.update!(email: params[:email]) if params.key?(:email)
@ -12,6 +13,10 @@ class Api::V1::Emails::ConfirmationsController < Api::BaseController
render_empty
end
def check
render json: current_user.confirmed?
end
private
def require_user_owned_by_application!

View file

@ -2,6 +2,8 @@
class Api::V1::Statuses::ReblogsController < Api::BaseController
include Authorization
include Redisable
include Lockable
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
before_action :require_user!
@ -10,7 +12,9 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
override_rate_limit_headers :create, family: :statuses
def create
@status = ReblogService.new.call(current_account, @reblog, reblog_params)
with_redis_lock("reblog:#{current_account.id}:#{@reblog.id}") do
@status = ReblogService.new.call(current_account, @reblog, reblog_params)
end
render json: @status, serializer: REST::StatusSerializer
end

View file

@ -1,21 +1,63 @@
# frozen_string_literal: true
class Auth::ConfirmationsController < Devise::ConfirmationsController
include CaptchaConcern
layout 'auth'
before_action :set_body_classes
before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
before_action :require_unconfirmed!
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
before_action :require_captcha_if_needed!, only: [:show]
skip_before_action :require_functional!
def show
old_session_values = session.to_hash
reset_session
session.update old_session_values.except('session_id')
super
end
def new
super
resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
end
def confirm_captcha
check_captcha! do |message|
flash.now[:alert] = message
render :captcha
return
end
show
end
private
def require_captcha_if_needed!
render :captcha if captcha_required?
end
def set_confirmation_user!
# We need to reimplement looking up the user because
# Devise::ConfirmationsController#show looks up and confirms in one
# step.
confirmation_token = params[:confirmation_token]
return if confirmation_token.nil?
@confirmation_user = User.find_first_by_auth_conditions(confirmation_token: confirmation_token)
end
def captcha_user_bypass?
return true if @confirmation_user.nil? || @confirmation_user.confirmed?
end
def require_unconfirmed!
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)

View file

@ -0,0 +1,59 @@
# frozen_string_literal: true
module CaptchaConcern
extend ActiveSupport::Concern
include Hcaptcha::Adapters::ViewMethods
included do
helper_method :render_captcha
end
def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
end
def captcha_enabled?
captcha_available? && Setting.captcha_enabled
end
def captcha_user_bypass?
false
end
def captcha_required?
captcha_enabled? && !captcha_user_bypass?
end
def check_captcha!
return true unless captcha_required?
if verify_hcaptcha
true
else
if block_given?
message = flash[:hcaptcha_error]
flash.delete(:hcaptcha_error)
yield message
end
false
end
end
def extend_csp_for_captcha!
policy = request.content_security_policy
return unless captcha_required? && policy.present?
%w(script_src frame_src style_src connect_src).each do |directive|
values = policy.send(directive)
values << 'https://hcaptcha.com' unless values.include?('https://hcaptcha.com') || values.include?('https:')
values << 'https://*.hcaptcha.com' unless values.include?('https://*.hcaptcha.com') || values.include?('https:')
policy.send(directive, *values)
end
end
def render_captcha
return unless captcha_required?
hcaptcha_tags
end
end

View file

@ -1,4 +1,7 @@
# frozen_string_literal: true
module Admin::SettingsHelper
def captcha_available?
ENV['HCAPTCHA_SECRET_KEY'].present? && ENV['HCAPTCHA_SITE_KEY'].present?
end
end

View file

@ -26,6 +26,7 @@ export default class StatusList extends ImmutablePureComponent {
alwaysPrepend: PropTypes.bool,
withCounters: PropTypes.bool,
timelineId: PropTypes.string,
lastId: PropTypes.string,
};
static defaultProps = {
@ -55,7 +56,8 @@ export default class StatusList extends ImmutablePureComponent {
};
handleLoadOlder = debounce(() => {
this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
const { statusIds, lastId, onLoadMore } = this.props;
onLoadMore(lastId || (statusIds.size > 0 ? statusIds.last() : undefined));
}, 300, { leading: true });
_selectChild (index, align_top) {

View file

@ -37,6 +37,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, { timelineId }) => ({
statusIds: getStatusIds(state, { type: timelineId }),
lastId: state.getIn(['timelines', timelineId, 'items'])?.last(),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),

View file

@ -0,0 +1,15 @@
import './public-path';
import ready from '../mastodon/ready';
import axios from 'axios';
ready(() => {
setInterval(() => {
axios.get('/api/v1/emails/check_confirmation').then((response) => {
if (response.data) {
window.location = '/start';
}
}).catch(error => {
console.error(error);
});
}, 5000);
});

View file

@ -3172,7 +3172,7 @@ $ui-header-height: 55px;
&.active {
transition: none;
box-shadow: 0 0 0 2px rgba(lighten($highlight-text-color, 8%), 0.7);
box-shadow: 0 0 0 6px rgba(lighten($highlight-text-color, 8%), 0.7);
}
}

View file

@ -136,6 +136,10 @@ code {
line-height: 22px;
color: $secondary-text-color;
margin-bottom: 30px;
a {
color: $highlight-text-color;
}
}
.rules-list {
@ -1039,6 +1043,10 @@ code {
}
}
.simple_form .h-captcha {
text-align: center;
}
.permissions-list {
&__item {
padding: 15px;

View file

@ -123,7 +123,18 @@ class Form::AccountBatch
account: current_account,
action: :suspend
)
Admin::SuspensionWorker.perform_async(account.id)
# Suspending a single account closes their associated reports, so
# mass-suspending would be consistent.
Report.where(target_account: account).unresolved.find_each do |report|
authorize(report, :update?)
log_action(:resolve, report)
report.resolve!(current_account)
rescue Mastodon::NotPermittedError
# This should not happen, but just in case, do not fail early
end
end
def approve_account(account)

View file

@ -33,6 +33,7 @@ class Form::AdminSettings
content_cache_retention_period
backups_retention_period
status_page_url
captcha_enabled
).freeze
INTEGER_KEYS = %i(
@ -52,6 +53,7 @@ class Form::AdminSettings
trendable_by_default
noindex
require_invite_text
captcha_enabled
).freeze
UPLOAD_KEYS = %i(

View file

@ -3,8 +3,8 @@
class VoteValidator < ActiveModel::Validator
def validate(vote)
vote.errors.add(:base, I18n.t('polls.errors.expired')) if vote.poll_expired?
vote.errors.add(:base, I18n.t('polls.errors.invalid_choice')) if invalid_choice?(vote)
vote.errors.add(:base, I18n.t('polls.errors.self_vote')) if self_vote?(vote)
vote.errors.add(:base, I18n.t('polls.errors.already_voted')) if additional_voting_not_allowed?(vote)
end
@ -27,6 +27,10 @@ class VoteValidator < ActiveModel::Validator
vote.choice.negative? || vote.choice >= vote.poll.options.size
end
def self_vote?(vote)
vote.account_id == vote.poll.account_id
end
def already_voted_for_same_choice_on_multiple_poll?(vote)
if vote.persisted?
account_votes_on_same_poll(vote).where(choice: vote.choice).where.not(poll_votes: { id: vote }).exists?

View file

@ -20,6 +20,10 @@
.fields-row__column.fields-row__column-6.fields-group
= f.input :require_invite_text, as: :boolean, wrapper: :with_label, disabled: !approved_registrations?
- if captcha_available?
.fields-group
= f.input :captcha_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.captcha_enabled.title'), hint: t('admin.settings.captcha_enabled.desc_html')
.fields-group
= f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, input_html: { rows: 2 }

View file

@ -0,0 +1,15 @@
- content_for :page_title do
= t('auth.captcha_confirmation.title')
= form_tag auth_captcha_confirmation_url, method: 'POST', class: 'simple_form' do
= render 'auth/shared/progress', stage: 'confirm'
= hidden_field_tag :confirmation_token, params[:confirmation_token]
%p.lead= t('auth.captcha_confirmation.hint_html')
.field-group
= render_captcha
.actions
%button.button= t('challenge.confirm')

View file

@ -1,6 +1,8 @@
- content_for :page_title do
= t('auth.setup.title')
= javascript_pack_tag 'sign_up', crossorigin: 'anonymous'
= simple_form_for(@user, url: auth_setup_path) do |f|
= render 'auth/shared/progress', stage: 'confirm'

View file

@ -770,6 +770,9 @@ en:
branding:
preamble: Your server's branding differentiates it from other servers in the network. This information may be displayed across a variety of environments, such as Mastodon's web interface, native applications, in link previews on other websites and within messaging apps, and so on. For this reason, it is best to keep this information clear, short and concise.
title: Branding
captcha_enabled:
desc_html: This relies on external scripts from hCaptcha, which may be a security and privacy concern. In addition, <strong>this can make the registration process significantly less accessible to some (especially disabled) people</strong>. For these reasons, please consider alternative measures such as approval-based or invite-based registration.
title: Require new users to solve a CAPTCHA to confirm their account
content_retention:
preamble: Control how user-generated content is stored in Mastodon.
title: Content retention
@ -1071,6 +1074,9 @@ en:
your_token: Your access token
auth:
apply_for_account: Request an account
captcha_confirmation:
hint_html: Just one more step! To confirm your account, this server requires you to solve a CAPTCHA. You can <a href="/about/more">contact the server administrator</a> if you have questions or need assistance with confirming your account.
title: User verification
change_password: Password
cloudflare_with_registering: With cloudflare on auth
confirmations:
@ -1537,6 +1543,7 @@ en:
expired: The poll has already ended
invalid_choice: The chosen vote option does not exist
over_character_limit: cannot be longer than %{max} characters each
self_vote: You cannot vote in your own polls
too_few_options: must have more than one item
too_many_options: can't contain more than %{max} items
preferences:

View file

@ -72,6 +72,7 @@ Rails.application.routes.draw do
resource :setup, only: [:show, :update], controller: :setup
resource :challenge, only: [:create], controller: :challenges
get 'sessions/security_key_options', to: 'sessions#webauthn_options'
post 'captcha_confirmation', to: 'confirmations#confirm_captcha', as: :captcha_confirmation
end
end

View file

@ -117,6 +117,7 @@ namespace :api, format: false do
namespace :emails do
resources :confirmations, only: [:create]
get :check_confirmation, to: 'confirmations#check'
end
resource :instance, only: [:show] do

View file

@ -37,6 +37,7 @@ defaults: &defaults
show_domain_blocks_rationale: 'disabled'
require_invite_text: false
backups_retention_period: 7
captcha_enabled: false
development:
<<: *defaults

View file

@ -82,7 +82,7 @@
"npmlog": "^7.0.1",
"path-complete-extname": "^1.0.0",
"pg": "^8.5.0",
"pg-connection-string": "^2.5.0",
"pg-connection-string": "^2.6.0",
"postcss": "^8.4.23",
"postcss-loader": "^4.3.0",
"prop-types": "^15.8.1",

View file

@ -63,4 +63,72 @@ RSpec.describe Api::V1::Emails::ConfirmationsController do
end
end
end
describe '#check' do
let(:scopes) { 'read' }
context 'with an oauth token' do
before do
allow(controller).to receive(:doorkeeper_token) { token }
end
context 'when the account is not confirmed' do
it 'returns http success' do
get :check
expect(response).to have_http_status(200)
end
it 'returns false' do
get :check
expect(body_as_json).to be false
end
end
context 'when the account is confirmed' do
let(:confirmed_at) { Time.now.utc }
it 'returns http success' do
get :check
expect(response).to have_http_status(200)
end
it 'returns true' do
get :check
expect(body_as_json).to be true
end
end
end
context 'with an authentication cookie' do
before do
sign_in user, scope: :user
end
context 'when the account is not confirmed' do
it 'returns http success' do
get :check
expect(response).to have_http_status(200)
end
it 'returns false' do
get :check
expect(body_as_json).to be false
end
end
context 'when the account is confirmed' do
let(:confirmed_at) { Time.now.utc }
it 'returns http success' do
get :check
expect(response).to have_http_status(200)
end
it 'returns true' do
get :check
expect(body_as_json).to be true
end
end
end
end
end

View file

@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Form::AccountBatch do
let(:account_batch) { described_class.new }
describe '#save' do
subject { account_batch.save }
let(:account) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
let(:account_ids) { [] }
let(:query) { Account.none }
before do
account_batch.assign_attributes(
action: action,
current_account: account,
account_ids: account_ids,
query: query,
select_all_matching: select_all_matching
)
end
context 'when action is "suspend"' do
let(:action) { 'suspend' }
let(:target_account) { Fabricate(:account) }
let(:target_account2) { Fabricate(:account) }
before do
Fabricate(:report, target_account: target_account)
Fabricate(:report, target_account: target_account2)
end
context 'when accounts are passed as account_ids' do
let(:select_all_matching) { '0' }
let(:account_ids) { [target_account.id, target_account2.id] }
it 'suspends the expected users' do
expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true])
end
it 'closes open reports targeting the suspended users' do
expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0)
end
end
context 'when accounts are passed as a query' do
let(:select_all_matching) { '1' }
let(:query) { Account.where(id: [target_account.id, target_account2.id]) }
it 'suspends the expected users' do
expect { subject }.to change { [target_account.reload.suspended?, target_account2.reload.suspended?] }.from([false, false]).to([true, true])
end
it 'closes open reports targeting the suspended users' do
expect { subject }.to change { Report.unresolved.where(target_account: [target_account, target_account2]).count }.from(2).to(0)
end
end
end
end
end

View file

@ -8902,15 +8902,10 @@ performance-now@^2.1.0:
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
pg-connection-string@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.4.0.tgz#c979922eb47832999a204da5dbe1ebf2341b6a10"
integrity sha512-3iBXuv7XKvxeMrIgym7njT+HlZkwZqqGX4Bu9cci8xHZNT+Um1gWKqCsAzcC0d95rcKMU5WBg6YRUcHyV0HZKQ==
pg-connection-string@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.5.0.tgz#538cadd0f7e603fc09a12590f3b8a452c2c0cf34"
integrity sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
pg-connection-string@^2.4.0, pg-connection-string@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.0.tgz#12a36cc4627df19c25cc1b9b736cc39ee1f73ae8"
integrity sha512-x14ibktcwlHKoHxx9X3uTVW9zIGR41ZB6QNhHb21OPNdCCO3NaRnpJuwKIQSR4u+Yqjx4HCvy7Hh7VSy1U4dGg==
pg-int8@1.0.1:
version "1.0.1"