From 78e45a52850c52ced693420a6142cf847581f563 Mon Sep 17 00:00:00 2001 From: ThibG Date: Wed, 30 Sep 2020 19:31:03 +0200 Subject: Add option to disable swiping motions across the WebUI (#13885) Fixes #13882 --- app/views/settings/preferences/appearance/show.html.haml | 1 + 1 file changed, 1 insertion(+) (limited to 'app/views') diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 10fff406e..14941d5fd 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -30,6 +30,7 @@ .fields-group = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label + = f.input :setting_disable_swiping, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label %h4= t 'appearance.toot_layout' -- cgit From a549415868fe23e0afaf258c17afafac117d0163 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 4 Oct 2020 15:02:36 +0200 Subject: Fix regressions in icon buttons in web UI (#14915) --- app/javascript/mastodon/components/icon_button.js | 1 + app/javascript/styles/mastodon/components.scss | 14 ++++++++++++-- app/views/statuses/_simple_status.html.haml | 13 ++++++------- 3 files changed, 19 insertions(+), 9 deletions(-) (limited to 'app/views') diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 7f83dc1b9..7ec39198a 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -116,6 +116,7 @@ export default class IconButton extends React.PureComponent { activate, deactivate, overlayed: overlay, + 'icon-button--with-counter': typeof counter !== 'undefined', }); if (typeof counter !== 'undefined') { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index a20265bb9..ec49ae120 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -163,8 +163,7 @@ } .icon-button { - display: inline-flex; - align-items: center; + display: inline-block; padding: 0; color: $action-button-color; border: 0; @@ -173,6 +172,7 @@ cursor: pointer; transition: all 100ms ease-in; transition-property: background-color, color; + text-decoration: none; &:hover, &:active, @@ -247,6 +247,12 @@ } } + &--with-counter { + display: inline-flex; + align-items: center; + width: auto !important; + } + &__counter { display: inline-block; width: 14px; @@ -1152,6 +1158,10 @@ .status__action-bar-button { margin-right: 18px; + + &.icon-button--with-counter { + margin-right: 14px; + } } .status__action-bar-dropdown { diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 0c0b57112..336c1f2c8 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -52,13 +52,12 @@ = t 'statuses.show_thread' .status__action-bar - .status__action-bar__counter - = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button modal-button' do - - if status.in_reply_to_id.nil? - = fa_icon 'reply fw' - - else - = fa_icon 'reply-all fw' - .status__action-bar__counter__label= obscured_counter status.replies_count + = link_to remote_interaction_path(status, type: :reply), class: 'status__action-bar-button icon-button icon-button--with-counter modal-button' do + - if status.in_reply_to_id.nil? + = fa_icon 'reply fw' + - else + = fa_icon 'reply-all fw' + %span.icon-button__counter= obscured_counter status.replies_count = link_to remote_interaction_path(status, type: :reblog), class: 'status__action-bar-button icon-button modal-button' do - if status.distributable? = fa_icon 'retweet fw' -- cgit From 7d985f2aac639dc5fae528db2dbc4422ca10f276 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 8 Oct 2020 00:34:57 +0200 Subject: Remove dependency on goldfinger gem (#14919) There are edge cases where requests to certain hosts timeout when using the vanilla HTTP.rb gem, which the goldfinger gem uses. Now that we no longer need to support OStatus servers, webfinger logic is so simple that there is no point encapsulating it in a gem, so we can just use our own Request class. With that, we benefit from more robust timeout code and IPv4/IPv6 resolution. Fix #14091 --- Gemfile | 1 - Gemfile.lock | 6 -- app/helpers/webfinger_helper.rb | 33 +------- app/lib/webfinger.rb | 93 ++++++++++++++++++++++ app/models/account_alias.rb | 2 +- app/models/account_migration.rb | 2 +- app/models/form/redirect.rb | 2 +- app/models/remote_follow.rb | 10 +-- .../activitypub/fetch_remote_account_service.rb | 7 +- app/services/process_mentions_service.rb | 2 +- app/services/resolve_account_service.rb | 9 +-- app/views/well_known/host_meta/show.xml.ruby | 1 - spec/controllers/remote_follow_controller_spec.rb | 10 +-- .../well_known/host_meta_controller_spec.rb | 2 +- spec/lib/feed_manager_spec.rb | 1 + 15 files changed, 114 insertions(+), 67 deletions(-) create mode 100644 app/lib/webfinger.rb (limited to 'app/views') diff --git a/Gemfile b/Gemfile index 3a92b4627..0fc631e2a 100644 --- a/Gemfile +++ b/Gemfile @@ -54,7 +54,6 @@ gem 'doorkeeper', '~> 5.4' gem 'ed25519', '~> 1.2' gem 'fast_blank', '~> 1.0' gem 'fastimage' -gem 'goldfinger', '~> 2.1' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.8' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' diff --git a/Gemfile.lock b/Gemfile.lock index ae304e61e..21f5b1b4c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -240,11 +240,6 @@ GEM ruby-progressbar (~> 1.4) globalid (0.4.2) activesupport (>= 4.2.0) - goldfinger (2.1.1) - addressable (~> 2.5) - http (~> 4.0) - nokogiri (~> 1.8) - oj (~> 3.0) hamlit (2.13.0) temple (>= 0.8.2) thor @@ -714,7 +709,6 @@ DEPENDENCIES fog-core (<= 2.1.0) fog-openstack (~> 0.3) fuubar (~> 2.5) - goldfinger (~> 2.1) hamlit-rails (~> 0.2) health_check! hiredis (~> 0.6) diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb index ab7ca4698..482f4e19e 100644 --- a/app/helpers/webfinger_helper.rb +++ b/app/helpers/webfinger_helper.rb @@ -1,38 +1,7 @@ # frozen_string_literal: true -# Monkey-patch on monkey-patch. -# Because it conflicts with the request.rb patch. -class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation - def connect(socket_class, host, port, nodelay = false) - ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do - @socket = socket_class.open(host, port) - @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay - end - end -end - module WebfingerHelper def webfinger!(uri) - hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri) - - raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri - - opts = { - ssl: !hidden_service_uri, - - headers: { - 'User-Agent': Mastodon::Version.user_agent, - }, - - timeout_class: HTTP::Timeout::PerOperationOriginal, - - timeout_options: { - write_timeout: 10, - connect_timeout: 5, - read_timeout: 10, - }, - } - - Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger + Webfinger.new(uri).perform end end diff --git a/app/lib/webfinger.rb b/app/lib/webfinger.rb new file mode 100644 index 000000000..b2374c494 --- /dev/null +++ b/app/lib/webfinger.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Webfinger + class Error < StandardError; end + + class Response + def initialize(body) + @json = Oj.load(body, mode: :strict) + end + + def subject + @json['subject'] + end + + def link(rel, attribute) + links.dig(rel, attribute) + end + + private + + def links + @links ||= @json['links'].map { |link| [link['rel'], link] }.to_h + end + end + + def initialize(uri) + _, @domain = uri.split('@') + + raise ArgumentError, 'Webfinger requested for local account' if @domain.nil? + + @uri = uri + end + + def perform + Response.new(body_from_webfinger) + rescue Oj::ParseError + raise Webfinger::Error, "Invalid JSON in response for #{@uri}" + rescue Addressable::URI::InvalidURIError + raise Webfinger::Error, "Invalid URI for #{@uri}" + end + + private + + def body_from_webfinger(url = standard_url, use_fallback = true) + webfinger_request(url).perform do |res| + if res.code == 200 + res.body_with_limit + elsif res.code == 404 && use_fallback + body_from_host_meta + else + raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}" + end + end + end + + def body_from_host_meta + host_meta_request.perform do |res| + if res.code == 200 + body_from_webfinger(url_from_template(res.body_with_limit), false) + else + raise Webfinger::Error, "Request for #{@uri} returned HTTP #{res.code}" + end + end + end + + def url_from_template(str) + link = Nokogiri::XML(str).at_xpath('//xmlns:Link[@rel="lrdd"]') + + if link.present? + link['template'].gsub('{uri}', @uri) + else + raise Webfinger::Error, "Request for #{@uri} returned host-meta without link to Webfinger" + end + rescue Nokogiri::XML::XPath::SyntaxError + raise Webfinger::Error, "Invalid XML encountered in host-meta for #{@uri}" + end + + def host_meta_request + Request.new(:get, host_meta_url).add_headers('Accept' => 'application/xrd+xml, application/xml, text/xml') + end + + def webfinger_request(url) + Request.new(:get, url).add_headers('Accept' => 'application/jrd+json, application/json') + end + + def standard_url + "https://#{@domain}/.well-known/webfinger?resource=#{@uri}" + end + + def host_meta_url + "https://#{@domain}/.well-known/host-meta" + end +end diff --git a/app/models/account_alias.rb b/app/models/account_alias.rb index 792e9e8d4..3d659142a 100644 --- a/app/models/account_alias.rb +++ b/app/models/account_alias.rb @@ -33,7 +33,7 @@ class AccountAlias < ApplicationRecord def set_uri target_account = ResolveAccountService.new.call(acct) self.uri = ActivityPub::TagManager.instance.uri_for(target_account) unless target_account.nil? - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/account_migration.rb b/app/models/account_migration.rb index 681b5b2cd..4fae98ed7 100644 --- a/app/models/account_migration.rb +++ b/app/models/account_migration.rb @@ -54,7 +54,7 @@ class AccountMigration < ApplicationRecord def set_target_account self.target_account = ResolveAccountService.new.call(acct) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/form/redirect.rb b/app/models/form/redirect.rb index a7961f8e8..19ee9faed 100644 --- a/app/models/form/redirect.rb +++ b/app/models/form/redirect.rb @@ -32,7 +32,7 @@ class Form::Redirect def set_target_account @target_account = ResolveAccountService.new.call(acct) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::Error # Validation will take care of it end diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 30b84f7d5..911c06713 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -56,7 +56,7 @@ class RemoteFollow if domain.nil? @addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}") - elsif redirect_url_link.nil? || redirect_url_link.template.nil? + elsif redirect_uri_template.nil? missing_resource_error else @addressable_template = Addressable::Template.new(redirect_uri_template) @@ -64,16 +64,12 @@ class RemoteFollow end def redirect_uri_template - redirect_url_link.template - end - - def redirect_url_link - acct_resource&.link('http://ostatus.org/schema/1.0/subscribe') + acct_resource&.link('http://ostatus.org/schema/1.0/subscribe', 'template') end def acct_resource @acct_resource ||= webfinger!("acct:#{acct}") - rescue Goldfinger::Error, HTTP::ConnectionError + rescue Webfinger::Error, HTTP::ConnectionError nil end diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 83fbf6d07..e5bd0c47c 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -39,17 +39,16 @@ class ActivityPub::FetchRemoteAccountService < BaseService webfinger = webfinger!("acct:#{@username}@#{@domain}") confirmed_username, confirmed_domain = split_acct(webfinger.subject) - return webfinger.link('self')&.href == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}") @username, @domain = split_acct(webfinger.subject) - self_reference = webfinger.link('self') return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? - return false if self_reference&.href != @uri + return false if webfinger.link('self', 'href') != @uri true - rescue Goldfinger::Error + rescue Webfinger::Error false end diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 12f0f1b08..0a710f843 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -29,7 +29,7 @@ class ProcessMentionsService < BaseService if mention_undeliverable?(mentioned_account) begin mentioned_account = resolve_account_service.call(Regexp.last_match(1)) - rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError + rescue Webfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError mentioned_account = nil end end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index ba77552c6..3f7bb7cc5 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -26,11 +26,10 @@ class ResolveAccountService < BaseService @account ||= Account.find_remote(@username, @domain) - return @account if @account&.local? || !webfinger_update_due? + return @account if @account&.local? || @domain.nil? || !webfinger_update_due? # At this point we are in need of a Webfinger query, which may # yield us a different username/domain through a redirect - process_webfinger!(@uri) # Because the username/domain pair may be different than what @@ -47,7 +46,7 @@ class ResolveAccountService < BaseService # either needs to be created, or updated from fresh data process_account! - rescue Goldfinger::Error, WebfingerRedirectError, Oj::ParseError => e + rescue Webfinger::Error, WebfingerRedirectError, Oj::ParseError => e Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}" nil end @@ -118,11 +117,11 @@ class ResolveAccountService < BaseService end def activitypub_ready? - !@webfinger.link('self').nil? && ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self').type) + ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@webfinger.link('self', 'type')) end def actor_url - @actor_url ||= @webfinger.link('self').href + @actor_url ||= @webfinger.link('self', 'href') end def actor_json diff --git a/app/views/well_known/host_meta/show.xml.ruby b/app/views/well_known/host_meta/show.xml.ruby index 0a6bdc322..b4e867c5f 100644 --- a/app/views/well_known/host_meta/show.xml.ruby +++ b/app/views/well_known/host_meta/show.xml.ruby @@ -5,7 +5,6 @@ doc << Ox::Element.new('XRD').tap do |xrd| xrd << Ox::Element.new('Link').tap do |link| link['rel'] = 'lrdd' - link['type'] = 'application/xrd+xml' link['template'] = @webfinger_template end end diff --git a/spec/controllers/remote_follow_controller_spec.rb b/spec/controllers/remote_follow_controller_spec.rb index 3ef8f14d9..7312dde58 100644 --- a/spec/controllers/remote_follow_controller_spec.rb +++ b/spec/controllers/remote_follow_controller_spec.rb @@ -43,8 +43,7 @@ describe RemoteFollowController do end it 'renders new when template is nil' do - link_with_nil_template = double(template: nil) - resource_with_link = double(link: link_with_nil_template) + resource_with_link = double(link: nil) allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link) post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } } @@ -55,8 +54,7 @@ describe RemoteFollowController do context 'when webfinger values are good' do before do - link_with_template = double(template: 'http://example.com/follow_me?acct={uri}') - resource_with_link = double(link: link_with_template) + resource_with_link = double(link: 'http://example.com/follow_me?acct={uri}') allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_return(resource_with_link) post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } } end @@ -78,8 +76,8 @@ describe RemoteFollowController do expect(response).to render_template(:new) end - it 'renders new with error when goldfinger fails' do - allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_raise(Goldfinger::Error) + it 'renders new with error when webfinger fails' do + allow_any_instance_of(WebfingerHelper).to receive(:webfinger!).with('acct:user@example.com').and_raise(Webfinger::Error) post :create, params: { account_username: @account.to_param, remote_follow: { acct: 'user@example.com' } } expect(response).to render_template(:new) diff --git a/spec/controllers/well_known/host_meta_controller_spec.rb b/spec/controllers/well_known/host_meta_controller_spec.rb index b43ae19d8..643ba9cd3 100644 --- a/spec/controllers/well_known/host_meta_controller_spec.rb +++ b/spec/controllers/well_known/host_meta_controller_spec.rb @@ -12,7 +12,7 @@ describe WellKnown::HostMetaController, type: :controller do expect(response.body).to eq < - + XML end diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index d9c17470f..7d775a86d 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -108,6 +108,7 @@ RSpec.describe FeedManager do it 'returns false for status by followee mentioning another account' do bob.follow!(alice) + jeff.follow!(alice) status = PostStatusService.new.call(alice, text: 'Hey @jeff') expect(FeedManager.instance.filter?(:home, status, bob)).to be false end -- cgit From 5e1364c448222c964faa469b6b5bfe9adf701c1a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 12 Oct 2020 16:33:49 +0200 Subject: Add IP-based rules (#14963) --- app/controllers/admin/ip_blocks_controller.rb | 56 +++++++++ app/controllers/api/v1/accounts_controller.rb | 2 +- app/controllers/auth/registrations_controller.rb | 6 +- app/helpers/admin/action_logs_helper.rb | 4 + app/lib/fast_ip_map.rb | 32 +++++ app/models/concerns/expireable.rb | 10 +- app/models/form/ip_block_batch.rb | 31 +++++ app/models/ip_block.rb | 41 +++++++ app/models/user.rb | 16 ++- app/policies/ip_block_policy.rb | 15 +++ app/services/app_sign_up_service.rb | 4 +- app/views/admin/ip_blocks/_ip_block.html.haml | 11 ++ app/views/admin/ip_blocks/index.html.haml | 28 +++++ app/views/admin/ip_blocks/new.html.haml | 20 ++++ .../admin/pending_accounts/_account.html.haml | 2 +- app/workers/scheduler/ip_cleanup_scheduler.rb | 18 ++- config/initializers/rack_attack.rb | 4 + config/locales/en.yml | 19 +++ config/locales/simple_form.en.yml | 15 +++ config/navigation.rb | 1 + config/routes.rb | 6 + db/migrate/20201008202037_create_ip_blocks.rb | 12 ++ .../20201008220312_add_sign_up_ip_to_users.rb | 5 + db/schema.rb | 12 +- lib/cli.rb | 4 + lib/mastodon/ip_blocks_cli.rb | 132 +++++++++++++++++++++ spec/fabricators/ip_block_fabricator.rb | 6 + spec/lib/fast_ip_map_spec.rb | 21 ++++ spec/models/ip_block_spec.rb | 5 + spec/services/app_sign_up_service_spec.rb | 13 +- 30 files changed, 530 insertions(+), 21 deletions(-) create mode 100644 app/controllers/admin/ip_blocks_controller.rb create mode 100644 app/lib/fast_ip_map.rb create mode 100644 app/models/form/ip_block_batch.rb create mode 100644 app/models/ip_block.rb create mode 100644 app/policies/ip_block_policy.rb create mode 100644 app/views/admin/ip_blocks/_ip_block.html.haml create mode 100644 app/views/admin/ip_blocks/index.html.haml create mode 100644 app/views/admin/ip_blocks/new.html.haml create mode 100644 db/migrate/20201008202037_create_ip_blocks.rb create mode 100644 db/migrate/20201008220312_add_sign_up_ip_to_users.rb create mode 100644 lib/mastodon/ip_blocks_cli.rb create mode 100644 spec/fabricators/ip_block_fabricator.rb create mode 100644 spec/lib/fast_ip_map_spec.rb create mode 100644 spec/models/ip_block_spec.rb (limited to 'app/views') diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb new file mode 100644 index 000000000..92b8b0d2b --- /dev/null +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Admin + class IpBlocksController < BaseController + def index + authorize :ip_block, :index? + + @ip_blocks = IpBlock.page(params[:page]) + @form = Form::IpBlockBatch.new + end + + def new + authorize :ip_block, :create? + + @ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year) + end + + def create + authorize :ip_block, :create? + + @ip_block = IpBlock.new(resource_params) + + if @ip_block.save + log_action :create, @ip_block + redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg') + else + render :new + end + end + + def batch + @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button)) + @form.save + rescue ActionController::ParameterMissing + flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected') + rescue Mastodon::NotPermittedError + flash[:alert] = I18n.t('admin.custom_emojis.not_permitted') + ensure + redirect_to admin_ip_blocks_path + end + + private + + def resource_params + params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in) + end + + def action_from_button + 'delete' if params[:delete] + end + + def form_ip_block_batch_params + params.require(:form_ip_block_batch).permit(ip_block_ids: []) + end + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index aef51a647..4a97f0251 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController end def create - token = AppSignUpService.new.call(doorkeeper_token.application, account_params) + token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params) response = Doorkeeper::OAuth::TokenResponse.new(token) headers.merge!(response.headers) diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index d31966248..eb0924190 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -45,9 +45,9 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale - resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.current_sign_in_ip = request.remote_ip + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.sign_up_ip = request.remote_ip resource.build_account if resource.account.nil? end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 8e398c3b2..0f3ca36e2 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -29,6 +29,8 @@ module Admin::ActionLogsHelper link_to record.target_account.acct, admin_account_path(record.target_account_id) when 'Announcement' link_to truncate(record.text), edit_admin_announcement_path(record.id) + when 'IpBlock' + "#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})" end end @@ -48,6 +50,8 @@ module Admin::ActionLogsHelper end when 'Announcement' truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) + when 'IpBlock' + "#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})" end end end diff --git a/app/lib/fast_ip_map.rb b/app/lib/fast_ip_map.rb new file mode 100644 index 000000000..ba30b45f3 --- /dev/null +++ b/app/lib/fast_ip_map.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class FastIpMap + MAX_IPV4_PREFIX = 32 + MAX_IPV6_PREFIX = 128 + + # @param [Enumerable] addresses + def initialize(addresses) + @fast_lookup = {} + @ranges = [] + + # Hash look-up is faster but only works for exact matches, so we split + # exact addresses from non-exact ones + addresses.each do |address| + if (address.ipv4? && address.prefix == MAX_IPV4_PREFIX) || (address.ipv6? && address.prefix == MAX_IPV6_PREFIX) + @fast_lookup[address.to_s] = true + else + @ranges << address + end + end + + # We're more likely to hit wider-reaching ranges when checking for + # inclusion, so make sure they're sorted first + @ranges.sort_by!(&:prefix) + end + + # @param [IPAddr] address + # @return [Boolean] + def include?(address) + @fast_lookup[address.to_s] || @ranges.any? { |cidr| cidr.include?(address) } + end +end diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb index f7d2bab49..a66a4661b 100644 --- a/app/models/concerns/expireable.rb +++ b/app/models/concerns/expireable.rb @@ -6,7 +6,15 @@ module Expireable included do scope :expired, -> { where.not(expires_at: nil).where('expires_at < ?', Time.now.utc) } - attr_reader :expires_in + def expires_in + return @expires_in if defined?(@expires_in) + + if expires_at.nil? + nil + else + (expires_at - created_at).to_i + end + end def expires_in=(interval) self.expires_at = interval.to_i.seconds.from_now if interval.present? diff --git a/app/models/form/ip_block_batch.rb b/app/models/form/ip_block_batch.rb new file mode 100644 index 000000000..f6fe9b593 --- /dev/null +++ b/app/models/form/ip_block_batch.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Form::IpBlockBatch + include ActiveModel::Model + include Authorization + include AccountableConcern + + attr_accessor :ip_block_ids, :action, :current_account + + def save + case action + when 'delete' + delete! + end + end + + private + + def ip_blocks + @ip_blocks ||= IpBlock.where(id: ip_block_ids) + end + + def delete! + ip_blocks.each { |ip_block| authorize(ip_block, :destroy?) } + + ip_blocks.each do |ip_block| + ip_block.destroy + log_action :destroy, ip_block + end + end +end diff --git a/app/models/ip_block.rb b/app/models/ip_block.rb new file mode 100644 index 000000000..aedd3ca0d --- /dev/null +++ b/app/models/ip_block.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: ip_blocks +# +# id :bigint(8) not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# expires_at :datetime +# ip :inet default(#), not null +# severity :integer default(NULL), not null +# comment :text default(""), not null +# + +class IpBlock < ApplicationRecord + CACHE_KEY = 'blocked_ips' + + include Expireable + + enum severity: { + sign_up_requires_approval: 5000, + no_access: 9999, + } + + validates :ip, :severity, presence: true + + after_commit :reset_cache + + class << self + def blocked?(remote_ip) + blocked_ips_map = Rails.cache.fetch(CACHE_KEY) { FastIpMap.new(IpBlock.where(severity: :no_access).pluck(:ip)) } + blocked_ips_map.include?(remote_ip) + end + end + + private + + def reset_cache + Rails.cache.delete(CACHE_KEY) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 2c460e1fd..7c8124fed 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,7 @@ # sign_in_token :string # sign_in_token_sent_at :datetime # webauthn_id :string +# sign_up_ip :inet # class User < ApplicationRecord @@ -97,7 +98,7 @@ class User < ApplicationRecord scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) } scope :active, -> { confirmed.where(arel_table[:current_sign_in_at].gteq(ACTIVE_DURATION.ago)).joins(:account).where(accounts: { suspended_at: nil }) } scope :matches_email, ->(value) { where(arel_table[:email].matches("#{value}%")) } - scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } + scope :matches_ip, ->(value) { left_joins(:session_activations).where('users.current_sign_in_ip <<= ?', value).or(left_joins(:session_activations).where('users.sign_up_ip <<= ?', value)).or(left_joins(:session_activations).where('users.last_sign_in_ip <<= ?', value)).or(left_joins(:session_activations).where('session_activations.ip <<= ?', value)) } scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } before_validation :sanitize_languages @@ -331,6 +332,7 @@ class User < ApplicationRecord arr << [current_sign_in_at, current_sign_in_ip] if current_sign_in_ip.present? arr << [last_sign_in_at, last_sign_in_ip] if last_sign_in_ip.present? + arr << [created_at, sign_up_ip] if sign_up_ip.present? arr.sort_by { |pair| pair.first || Time.now.utc }.uniq(&:last).reverse! end @@ -385,7 +387,17 @@ class User < ApplicationRecord end def set_approved - self.approved = open_registrations? || valid_invitation? || external? + self.approved = begin + if sign_up_from_ip_requires_approval? + false + else + open_registrations? || valid_invitation? || external? + end + end + end + + def sign_up_from_ip_requires_approval? + !sign_up_ip.nil? && IpBlock.where(severity: :sign_up_requires_approval).where('ip >>= ?', sign_up_ip.to_s).exists? end def open_registrations? diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb new file mode 100644 index 000000000..34dbd746a --- /dev/null +++ b/app/policies/ip_block_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class IpBlockPolicy < ApplicationPolicy + def index? + admin? + end + + def create? + admin? + end + + def destroy? + admin? + end +end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index c9739c77d..e00694157 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true class AppSignUpService < BaseService - def call(app, params) + def call(app, remote_ip, params) return unless allowed_registrations? user_params = params.slice(:email, :password, :agreement, :locale) account_params = params.slice(:username) invite_request_params = { text: params[:reason] } - user = User.create!(user_params.merge(created_by_application: app, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)) + user = User.create!(user_params.merge(created_by_application: app, sign_up_ip: remote_ip, password_confirmation: user_params[:password], account_attributes: account_params, invite_request_attributes: invite_request_params)) Doorkeeper::AccessToken.create!(application: app, resource_owner_id: user.id, diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml new file mode 100644 index 000000000..e07e2b444 --- /dev/null +++ b/app/views/admin/ip_blocks/_ip_block.html.haml @@ -0,0 +1,11 @@ +.batch-table__row + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :ip_block_ids, { multiple: true, include_hidden: false }, ip_block.id + .batch-table__row__content + .batch-table__row__content__text + %samp= "#{ip_block.ip}/#{ip_block.ip.prefix}" + - if ip_block.comment.present? + • + = ip_block.comment + %br/ + = t("simple_form.labels.ip_block.severities.#{ip_block.severity}") diff --git a/app/views/admin/ip_blocks/index.html.haml b/app/views/admin/ip_blocks/index.html.haml new file mode 100644 index 000000000..a282a4cfe --- /dev/null +++ b/app/views/admin/ip_blocks/index.html.haml @@ -0,0 +1,28 @@ +- content_for :page_title do + = t('admin.ip_blocks.title') + +- content_for :header_tags do + = javascript_pack_tag 'admin', integrity: true, async: true, crossorigin: 'anonymous' + +- if can?(:create, :ip_block) + - content_for :heading_actions do + = link_to t('admin.ip_blocks.add_new'), new_admin_ip_block_path, class: 'button' + += form_for(@form, url: batch_admin_ip_blocks_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + .batch-table + .batch-table__toolbar + %label.batch-table__toolbar__select.batch-checkbox-all + = check_box_tag :batch_checkbox_all, nil, false + .batch-table__toolbar__actions + - if can?(:destroy, :ip_block) + = f.button safe_join([fa_icon('times'), t('admin.ip_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + .batch-table__body + - if @ip_blocks.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'ip_block', collection: @ip_blocks, locals: { f: f } + += paginate @ip_blocks + diff --git a/app/views/admin/ip_blocks/new.html.haml b/app/views/admin/ip_blocks/new.html.haml new file mode 100644 index 000000000..69f6b98b9 --- /dev/null +++ b/app/views/admin/ip_blocks/new.html.haml @@ -0,0 +1,20 @@ +- content_for :page_title do + = t('.title') + += simple_form_for @ip_block, url: admin_ip_blocks_path do |f| + = render 'shared/error_messages', object: @ip_block + + .fields-group + = f.input :ip, as: :string, wrapper: :with_block_label, input_html: { placeholder: '192.0.2.0/24' } + + .fields-group + = f.input :expires_in, wrapper: :with_block_label, collection: [1.day, 2.weeks, 1.month, 6.months, 1.year, 3.years].map(&:to_i), label_method: lambda { |i| I18n.t("admin.ip_blocks.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + + .fields-group + = f.input :severity, as: :radio_buttons, collection: IpBlock.severities.keys, include_blank: false, wrapper: :with_block_label, label_method: lambda { |severity| safe_join([I18n.t("simple_form.labels.ip_block.severities.#{severity}"), content_tag(:span, I18n.t("simple_form.hints.ip_block.severities.#{severity}"), class: 'hint')]) } + + .fields-group + = f.input :comment, as: :string, wrapper: :with_block_label + + .actions + = f.button :button, t('admin.ip_blocks.add_new'), type: :submit diff --git a/app/views/admin/pending_accounts/_account.html.haml b/app/views/admin/pending_accounts/_account.html.haml index 7a9796a67..5b475b59a 100644 --- a/app/views/admin/pending_accounts/_account.html.haml +++ b/app/views/admin/pending_accounts/_account.html.haml @@ -7,7 +7,7 @@ %strong= account.user_email = "(@#{account.username})" %br/ - = account.user_current_sign_in_ip + %samp= account.user_current_sign_in_ip • = t 'admin.accounts.time_in_queue', time: time_ago_in_words(account.user&.created_at) diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb index 6d38b52a2..853f20e25 100644 --- a/app/workers/scheduler/ip_cleanup_scheduler.rb +++ b/app/workers/scheduler/ip_cleanup_scheduler.rb @@ -3,13 +3,23 @@ class Scheduler::IpCleanupScheduler include Sidekiq::Worker - RETENTION_PERIOD = 1.year + IP_RETENTION_PERIOD = 1.year.freeze sidekiq_options lock: :until_executed, retry: 0 def perform - time_ago = RETENTION_PERIOD.ago - SessionActivation.where('updated_at < ?', time_ago).in_batches.destroy_all - User.where('last_sign_in_at < ?', time_ago).where.not(last_sign_in_ip: nil).in_batches.update_all(last_sign_in_ip: nil) + clean_ip_columns! + clean_expired_ip_blocks! + end + + private + + def clean_ip_columns! + SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all + User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_sign_in_ip: nil, current_sign_in_ip: nil, sign_up_ip: nil) + end + + def clean_expired_ip_blocks! + IpBlock.expired.in_batches.destroy_all end end diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb index cd29afac5..6662ef40b 100644 --- a/config/initializers/rack_attack.rb +++ b/config/initializers/rack_attack.rb @@ -42,6 +42,10 @@ class Rack::Attack req.remote_ip == '127.0.0.1' || req.remote_ip == '::1' end + Rack::Attack.blocklist('deny from blocklist') do |req| + IpBlock.blocked?(req.remote_ip) + end + throttle('throttle_authenticated_api', limit: 300, period: 5.minutes) do |req| req.authenticated_user_id if req.api_request? end diff --git a/config/locales/en.yml b/config/locales/en.yml index 427b2c3fc..084006a2a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -223,12 +223,14 @@ en: create_domain_allow: Create Domain Allow create_domain_block: Create Domain Block create_email_domain_block: Create E-mail Domain Block + create_ip_block: Create IP rule demote_user: Demote User destroy_announcement: Delete Announcement destroy_custom_emoji: Delete Custom Emoji destroy_domain_allow: Delete Domain Allow destroy_domain_block: Delete Domain Block destroy_email_domain_block: Delete e-mail domain block + destroy_ip_block: Delete IP rule destroy_status: Delete Status disable_2fa_user: Disable 2FA disable_custom_emoji: Disable Custom Emoji @@ -259,12 +261,14 @@ en: create_domain_allow: "%{name} allowed federation with domain %{target}" create_domain_block: "%{name} blocked domain %{target}" create_email_domain_block: "%{name} blocked e-mail domain %{target}" + create_ip_block: "%{name} created rule for IP %{target}" demote_user: "%{name} demoted user %{target}" destroy_announcement: "%{name} deleted announcement %{target}" destroy_custom_emoji: "%{name} destroyed emoji %{target}" destroy_domain_allow: "%{name} disallowed federation with domain %{target}" destroy_domain_block: "%{name} unblocked domain %{target}" destroy_email_domain_block: "%{name} unblocked e-mail domain %{target}" + destroy_ip_block: "%{name} deleted rule for IP %{target}" destroy_status: "%{name} removed status by %{target}" disable_2fa_user: "%{name} disabled two factor requirement for user %{target}" disable_custom_emoji: "%{name} disabled emoji %{target}" @@ -449,6 +453,21 @@ en: expired: Expired title: Filter title: Invites + ip_blocks: + add_new: Create rule + created_msg: Successfully added new IP rule + delete: Delete + expires_in: + '1209600': 2 weeks + '15778476': 6 months + '2629746': 1 month + '31556952': 1 year + '86400': 1 day + '94670856': 3 years + new: + title: Create new IP rule + no_ip_block_selected: No IP rules were changed as none were selected + title: IP rules pending_accounts: title: Pending accounts (%{count}) relationships: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 9b0af6d24..b69487953 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -65,6 +65,14 @@ en: data: CSV file exported from another Mastodon server invite_request: text: This will help us review your application + ip_block: + comment: Optional. Remember why you added this rule. + expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended. + ip: Enter an IPv4 or IPv6 address. You can block entire ranges using the CIDR syntax. Be careful not to lock yourself out! + severities: + no_access: Block access to all resources + sign_up_requires_approval: New sign-ups will require your approval + severity: Choose what will happen with requests from this IP sessions: otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' webauthn: If it's an USB key be sure to insert it and, if necessary, tap it. @@ -170,6 +178,13 @@ en: comment: Comment invite_request: text: Why do you want to join? + ip_block: + comment: Comment + ip: IP + severities: + no_access: Block access + sign_up_requires_approval: Limit sign-ups + severity: Rule notification_emails: digest: Send digest e-mails favourite: Someone favourited your status diff --git a/config/navigation.rb b/config/navigation.rb index c113a3c3e..4a56abe18 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -41,6 +41,7 @@ SimpleNavigation::Configuration.run do |navigation| s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags} s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } + s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? } end n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s| diff --git a/config/routes.rb b/config/routes.rb index 8d9bc317b..a21dbd45e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -283,6 +283,12 @@ Rails.application.routes.draw do end end + resources :ip_blocks, only: [:index, :new, :create] do + collection do + post :batch + end + end + resources :account_moderation_notes, only: [:create, :destroy] resources :tags, only: [:index, :show, :update] do diff --git a/db/migrate/20201008202037_create_ip_blocks.rb b/db/migrate/20201008202037_create_ip_blocks.rb new file mode 100644 index 000000000..32acd6ede --- /dev/null +++ b/db/migrate/20201008202037_create_ip_blocks.rb @@ -0,0 +1,12 @@ +class CreateIpBlocks < ActiveRecord::Migration[5.2] + def change + create_table :ip_blocks do |t| + t.inet :ip, null: false, default: '0.0.0.0' + t.integer :severity, null: false, default: 0 + t.datetime :expires_at + t.text :comment, null: false, default: '' + + t.timestamps + end + end +end diff --git a/db/migrate/20201008220312_add_sign_up_ip_to_users.rb b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb new file mode 100644 index 000000000..66cd624bb --- /dev/null +++ b/db/migrate/20201008220312_add_sign_up_ip_to_users.rb @@ -0,0 +1,5 @@ +class AddSignUpIpToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :sign_up_ip, :inet + end +end diff --git a/db/schema.rb b/db/schema.rb index 0029d620a..5805f3105 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_09_17_222734) do +ActiveRecord::Schema.define(version: 2020_10_08_220312) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -463,6 +463,15 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do t.index ["user_id"], name: "index_invites_on_user_id" end + create_table "ip_blocks", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "expires_at" + t.inet "ip", default: "0.0.0.0", null: false + t.integer "severity", default: 0, null: false + t.text "comment", default: "", null: false + end + create_table "list_accounts", force: :cascade do |t| t.bigint "list_id", null: false t.bigint "account_id", null: false @@ -891,6 +900,7 @@ ActiveRecord::Schema.define(version: 2020_09_17_222734) do t.string "sign_in_token" t.datetime "sign_in_token_sent_at" t.string "webauthn_id" + t.inet "sign_up_ip" t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id" diff --git a/lib/cli.rb b/lib/cli.rb index 9162144cc..2a4dd11b2 100644 --- a/lib/cli.rb +++ b/lib/cli.rb @@ -13,6 +13,7 @@ require_relative 'mastodon/preview_cards_cli' require_relative 'mastodon/cache_cli' require_relative 'mastodon/upgrade_cli' require_relative 'mastodon/email_domain_blocks_cli' +require_relative 'mastodon/ip_blocks_cli' require_relative 'mastodon/version' module Mastodon @@ -57,6 +58,9 @@ module Mastodon desc 'email_domain_blocks SUBCOMMAND ...ARGS', 'Manage e-mail domain blocks' subcommand 'email_domain_blocks', Mastodon::EmailDomainBlocksCLI + desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks' + subcommand 'ip_blocks', Mastodon::IpBlocksCLI + option :dry_run, type: :boolean desc 'self-destruct', 'Erase the server from the federation' long_desc <<~LONG_DESC diff --git a/lib/mastodon/ip_blocks_cli.rb b/lib/mastodon/ip_blocks_cli.rb new file mode 100644 index 000000000..6aff36d90 --- /dev/null +++ b/lib/mastodon/ip_blocks_cli.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'rubygems/package' +require_relative '../../config/boot' +require_relative '../../config/environment' +require_relative 'cli_helper' + +module Mastodon + class IpBlocksCLI < Thor + def self.exit_on_failure? + true + end + + option :severity, required: true, enum: %w(no_access sign_up_requires_approval), desc: 'Severity of the block' + option :comment, aliases: [:c], desc: 'Optional comment' + option :duration, aliases: [:d], type: :numeric, desc: 'Duration of the block in seconds' + option :force, type: :boolean, aliases: [:f], desc: 'Overwrite existing blocks' + desc 'add IP...', 'Add one or more IP blocks' + long_desc <<-LONG_DESC + Add one or more IP blocks. You can use CIDR syntax to + block IP ranges. You must specify --severity of the block. All + options will be copied for each IP block you create in one command. + + You can add a --comment. If an IP block already exists for one of + the provided IPs, it will be skipped unless you use the --force + option to overwrite it. + LONG_DESC + def add(*addresses) + if addresses.empty? + say('No IP(s) given', :red) + exit(1) + end + + skipped = 0 + processed = 0 + failed = 0 + + addresses.each do |address| + ip_block = IpBlock.find_by(ip: address) + + if ip_block.present? && !options[:force] + say("#{address} is already blocked", :yellow) + skipped += 1 + next + end + + ip_block ||= IpBlock.new(ip: address) + + ip_block.severity = options[:severity] + ip_block.comment = options[:comment] + ip_block.expires_in = options[:duration] + + if ip_block.save + processed += 1 + else + say("#{address} could not be saved", :red) + failed += 1 + end + end + + say("Added #{processed}, skipped #{skipped}, failed #{failed}", color(processed, failed)) + end + + option :force, type: :boolean, aliases: [:f], desc: 'Remove blocks for ranges that cover given IP(s)' + desc 'remove IP...', 'Remove one or more IP blocks' + long_desc <<-LONG_DESC + Remove one or more IP blocks. Normally, only exact matches are removed. If + you want to ensure that all of the given IP addresses are unblocked, you + can use --force which will also remove any blocks for IP ranges that would + cover the given IP(s). + LONG_DESC + def remove(*addresses) + if addresses.empty? + say('No IP(s) given', :red) + exit(1) + end + + processed = 0 + skipped = 0 + + addresses.each do |address| + ip_blocks = begin + if options[:force] + IpBlock.where('ip >>= ?', address) + else + IpBlock.where('ip <<= ?', address) + end + end + + if ip_blocks.empty? + say("#{address} is not yet blocked", :yellow) + skipped += 1 + next + end + + ip_blocks.in_batches.destroy_all + processed += 1 + end + + say("Removed #{processed}, skipped #{skipped}", color(processed, 0)) + end + + option :format, aliases: [:f], enum: %w(plain nginx), desc: 'Format of the output' + desc 'export', 'Export blocked IPs' + long_desc <<-LONG_DESC + Export blocked IPs. Different formats are supported for usage with other + tools. Only blocks with no_access severity are returned. + LONG_DESC + def export + IpBlock.where(severity: :no_access).find_each do |ip_block| + case options[:format] + when 'nginx' + puts "deny #{ip_block.ip}/#{ip_block.ip.prefix};" + else + puts "#{ip_block.ip}/#{ip_block.ip.prefix}" + end + end + end + + private + + def color(processed, failed) + if !processed.zero? && failed.zero? + :green + elsif failed.zero? + :yellow + else + :red + end + end + end +end diff --git a/spec/fabricators/ip_block_fabricator.rb b/spec/fabricators/ip_block_fabricator.rb new file mode 100644 index 000000000..31dc336e6 --- /dev/null +++ b/spec/fabricators/ip_block_fabricator.rb @@ -0,0 +1,6 @@ +Fabricator(:ip_block) do + ip "" + severity "" + expires_at "2020-10-08 22:20:37" + comment "MyText" +end \ No newline at end of file diff --git a/spec/lib/fast_ip_map_spec.rb b/spec/lib/fast_ip_map_spec.rb new file mode 100644 index 000000000..c66f64828 --- /dev/null +++ b/spec/lib/fast_ip_map_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe FastIpMap do + describe '#include?' do + subject { described_class.new([IPAddr.new('20.4.0.0/16'), IPAddr.new('145.22.30.0/24'), IPAddr.new('189.45.86.3')])} + + it 'returns true for an exact match' do + expect(subject.include?(IPAddr.new('189.45.86.3'))).to be true + end + + it 'returns true for a range match' do + expect(subject.include?(IPAddr.new('20.4.45.7'))).to be true + end + + it 'returns false for no match' do + expect(subject.include?(IPAddr.new('145.22.40.64'))).to be false + end + end +end diff --git a/spec/models/ip_block_spec.rb b/spec/models/ip_block_spec.rb new file mode 100644 index 000000000..6603c6417 --- /dev/null +++ b/spec/models/ip_block_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe IpBlock, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb index e7c7f3ba1..e0c83b704 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -3,6 +3,7 @@ require 'rails_helper' RSpec.describe AppSignUpService, type: :service do let(:app) { Fabricate(:application, scopes: 'read write') } let(:good_params) { { username: 'alice', password: '12345678', email: 'good@email.com', agreement: true } } + let(:remote_ip) { IPAddr.new('198.0.2.1') } subject { described_class.new } @@ -10,16 +11,16 @@ RSpec.describe AppSignUpService, type: :service do it 'returns nil when registrations are closed' do tmp = Setting.registrations_mode Setting.registrations_mode = 'none' - expect(subject.call(app, good_params)).to be_nil + expect(subject.call(app, remote_ip, good_params)).to be_nil Setting.registrations_mode = tmp end it 'raises an error when params are missing' do - expect { subject.call(app, {}) }.to raise_error ActiveRecord::RecordInvalid + expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid end it 'creates an unconfirmed user with access token' do - access_token = subject.call(app, good_params) + access_token = subject.call(app, remote_ip, good_params) expect(access_token).to_not be_nil user = User.find_by(id: access_token.resource_owner_id) expect(user).to_not be_nil @@ -27,13 +28,13 @@ RSpec.describe AppSignUpService, type: :service do end it 'creates access token with the app\'s scopes' do - access_token = subject.call(app, good_params) + access_token = subject.call(app, remote_ip, good_params) expect(access_token).to_not be_nil expect(access_token.scopes.to_s).to eq 'read write' end it 'creates an account' do - access_token = subject.call(app, good_params) + access_token = subject.call(app, remote_ip, good_params) expect(access_token).to_not be_nil user = User.find_by(id: access_token.resource_owner_id) expect(user).to_not be_nil @@ -42,7 +43,7 @@ RSpec.describe AppSignUpService, type: :service do end it 'creates an account with invite request text' do - access_token = subject.call(app, good_params.merge(reason: 'Foo bar')) + access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar')) expect(access_token).to_not be_nil user = User.find_by(id: access_token.resource_owner_id) expect(user).to_not be_nil -- cgit From 4c45b43cb8a3d902c130729d36d559ec9de23d3e Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 13 Oct 2020 01:19:35 +0200 Subject: Change how CDN_HOST is passed down to make assets build reproducible (#14381) * Change how CDN_HOST is passed down to make assets build reproducible * Change webpacker/webpack configuration to dynamically load publicPath based on meta header * Fix embedded layout missing the cdn-host meta header --- .../mastodon/components/autosuggest_emoji.js | 3 +-- .../compose/components/emoji_picker_dropdown.js | 2 +- app/javascript/mastodon/features/emoji/emoji.js | 3 +-- .../getting_started/components/announcements.js | 3 +-- .../features/ui/components/focal_point_modal.js | 3 +-- app/javascript/mastodon/utils/config.js | 10 ++++++++++ app/javascript/packs/about.js | 1 + app/javascript/packs/admin.js | 1 + app/javascript/packs/application.js | 1 + app/javascript/packs/error.js | 1 + app/javascript/packs/public-path.js | 21 +++++++++++++++++++++ app/javascript/packs/public.js | 1 + app/javascript/packs/share.js | 1 + app/views/layouts/application.html.haml | 1 + app/views/layouts/embedded.html.haml | 1 + config/webpack/configuration.js | 17 ++--------------- 16 files changed, 46 insertions(+), 24 deletions(-) create mode 100644 app/javascript/mastodon/utils/config.js create mode 100644 app/javascript/packs/public-path.js (limited to 'app/views') diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js index ce4383a60..4937e4d98 100644 --- a/app/javascript/mastodon/components/autosuggest_emoji.js +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -1,8 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; - -const assetHost = process.env.CDN_HOST || ''; +import { assetHost } from 'mastodon/utils/config'; export default class AutosuggestEmoji extends React.PureComponent { diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index e8a36a923..bac136e5e 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -7,6 +7,7 @@ import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; import detectPassiveEvents from 'detect-passive-events'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -25,7 +26,6 @@ const messages = defineMessages({ flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, }); -const assetHost = process.env.CDN_HOST || ''; let EmojiPicker, Emoji; // load asynchronously const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js index 5d9dad097..4e37f3a80 100644 --- a/app/javascript/mastodon/features/emoji/emoji.js +++ b/app/javascript/mastodon/features/emoji/emoji.js @@ -1,11 +1,10 @@ import { autoPlayGif } from '../../initial_state'; import unicodeMapping from './emoji_unicode_mapping_light'; +import { assetHost } from 'mastodon/utils/config'; import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const assetHost = process.env.CDN_HOST || ''; - // Convert to file names from emojis. (For different variation selector emojis) const emojiFilenames = (emojis) => { return emojis.map(v => unicodeMapping[v].filename); diff --git a/app/javascript/mastodon/features/getting_started/components/announcements.js b/app/javascript/mastodon/features/getting_started/components/announcements.js index 8f824f140..4853c3935 100644 --- a/app/javascript/mastodon/features/getting_started/components/announcements.js +++ b/app/javascript/mastodon/features/getting_started/components/announcements.js @@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick import AnimatedNumber from 'mastodon/components/animated_number'; import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent { } -const assetHost = process.env.CDN_HOST || ''; - class Emoji extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index 926a495d1..e19f277d8 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -20,6 +20,7 @@ import GIFV from 'mastodon/components/gifv'; import { me } from 'mastodon/initial_state'; import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js'; import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js'; +import { assetHost } from 'mastodon/utils/config'; const messages = defineMessages({ close: { id: 'lightbox.close', defaultMessage: 'Close' }, @@ -50,8 +51,6 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') .replace(/\n/g, ' ') .replace(/\*\*\*\*\*\*/g, '\n\n'); -const assetHost = process.env.CDN_HOST || ''; - class ImageLoader extends React.PureComponent { static propTypes = { diff --git a/app/javascript/mastodon/utils/config.js b/app/javascript/mastodon/utils/config.js new file mode 100644 index 000000000..932cd0cbf --- /dev/null +++ b/app/javascript/mastodon/utils/config.js @@ -0,0 +1,10 @@ +import ready from '../ready'; + +export let assetHost = ''; + +ready(() => { + const cdnHost = document.querySelector('meta[name=cdn-host]'); + if (cdnHost) { + assetHost = cdnHost.content || ''; + } +}); diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js index 843cb2c87..892d825ec 100644 --- a/app/javascript/packs/about.js +++ b/app/javascript/packs/about.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/admin.js b/app/javascript/packs/admin.js index 51f92de8a..65b8dc040 100644 --- a/app/javascript/packs/admin.js +++ b/app/javascript/packs/admin.js @@ -1,3 +1,4 @@ +import './public-path'; import { delegate } from '@rails/ujs'; import ready from '../mastodon/ready'; diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index c65ebed74..91240aecf 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/javascript/packs/error.js b/app/javascript/packs/error.js index 685c89065..6376dc2f5 100644 --- a/app/javascript/packs/error.js +++ b/app/javascript/packs/error.js @@ -1,3 +1,4 @@ +import './public-path'; import ready from '../mastodon/ready'; ready(() => { diff --git a/app/javascript/packs/public-path.js b/app/javascript/packs/public-path.js new file mode 100644 index 000000000..f96109f4f --- /dev/null +++ b/app/javascript/packs/public-path.js @@ -0,0 +1,21 @@ +// Dynamically set webpack's loading path depending on a meta header, in order +// to share the same assets regardless of instance configuration. +// See https://webpack.js.org/guides/public-path/#on-the-fly + +function removeOuterSlashes(string) { + return string.replace(/^\/*/, '').replace(/\/*$/, ''); +} + +function formatPublicPath(host = '', path = '') { + let formattedHost = removeOuterSlashes(host); + if (formattedHost && !/^http/i.test(formattedHost)) { + formattedHost = `//${formattedHost}`; + } + const formattedPath = removeOuterSlashes(path); + return `${formattedHost}/${formattedPath}/`; +} + +const cdnHost = document.querySelector('meta[name=cdn-host]'); + +// eslint-disable-next-line camelcase, no-undef, no-unused-vars +__webpack_public_path__ = formatPublicPath(cdnHost ? cdnHost.content : '', process.env.PUBLIC_OUTPUT_PATH); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 551e281a8..39defa7ae 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -1,3 +1,4 @@ +import './public-path'; import escapeTextContentForBrowser from 'escape-html'; import loadPolyfills from '../mastodon/load_polyfills'; import ready from '../mastodon/ready'; diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js index 4ef23e1b2..1225d7b52 100644 --- a/app/javascript/packs/share.js +++ b/app/javascript/packs/share.js @@ -1,3 +1,4 @@ +import './public-path'; import loadPolyfills from '../mastodon/load_polyfills'; import { start } from '../mastodon/common'; diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index e32cdcabb..1f10f40c0 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -6,6 +6,7 @@ - if cdn_host? %link{ rel: 'dns-prefetch', href: cdn_host }/ + %meta{ name: 'cdn-host', content: cdn_host }/ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 4a40b8584..37051e70c 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -6,6 +6,7 @@ - if cdn_host? %link{ rel: 'dns-prefetch', href: cdn_host }/ + %meta{ name: 'cdn-host', content: cdn_host }/ - if storage_host? %link{ rel: 'dns-prefetch', href: storage_host }/ diff --git a/config/webpack/configuration.js b/config/webpack/configuration.js index 80a094c72..e4f88a9c4 100644 --- a/config/webpack/configuration.js +++ b/config/webpack/configuration.js @@ -11,30 +11,17 @@ const settings = safeLoad(readFileSync(configPath), 'utf8')[env.RAILS_ENV || env const themePath = resolve('config', 'themes.yml'); const themes = safeLoad(readFileSync(themePath), 'utf8'); -function removeOuterSlashes(string) { - return string.replace(/^\/*/, '').replace(/\/*$/, ''); -} - -function formatPublicPath(host = '', path = '') { - let formattedHost = removeOuterSlashes(host); - if (formattedHost && !/^http/i.test(formattedHost)) { - formattedHost = `//${formattedHost}`; - } - const formattedPath = removeOuterSlashes(path); - return `${formattedHost}/${formattedPath}/`; -} - const output = { path: resolve('public', settings.public_output_path), - publicPath: formatPublicPath(env.CDN_HOST, settings.public_output_path), + publicPath: `/${settings.public_output_path}/`, }; module.exports = { settings, themes, env: { - CDN_HOST: env.CDN_HOST, NODE_ENV: env.NODE_ENV, + PUBLIC_OUTPUT_PATH: settings.public_output_path, }, output, }; -- cgit