about summary refs log tree commit diff
path: root/app/controllers/concerns
diff options
context:
space:
mode:
Diffstat (limited to 'app/controllers/concerns')
-rw-r--r--app/controllers/concerns/account_controller_concern.rb36
-rw-r--r--app/controllers/concerns/account_owned_concern.rb34
-rw-r--r--app/controllers/concerns/cache_concern.rb50
-rw-r--r--app/controllers/concerns/challengable_concern.rb65
-rw-r--r--app/controllers/concerns/export_controller_concern.rb7
-rw-r--r--app/controllers/concerns/localized.rb13
-rw-r--r--app/controllers/concerns/signature_verification.rb34
-rw-r--r--app/controllers/concerns/status_controller_concern.rb87
8 files changed, 287 insertions, 39 deletions
diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb
index 1c422096c..11eac0eb6 100644
--- a/app/controllers/concerns/account_controller_concern.rb
+++ b/app/controllers/concerns/account_controller_concern.rb
@@ -3,24 +3,19 @@
 module AccountControllerConcern
   extend ActiveSupport::Concern
 
+  include AccountOwnedConcern
+
   FOLLOW_PER_PAGE = 12
 
   included do
     layout 'public'
 
-    before_action :set_account
-    before_action :check_account_approval
-    before_action :check_account_suspension
     before_action :set_instance_presenter
-    before_action :set_link_headers
+    before_action :set_link_headers, if: -> { request.format.nil? || request.format == :html }
   end
 
   private
 
-  def set_account
-    @account = Account.find_local!(username_param)
-  end
-
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
@@ -29,27 +24,15 @@ module AccountControllerConcern
     response.headers['Link'] = LinkHeader.new(
       [
         webfinger_account_link,
-        atom_account_url_link,
         actor_url_link,
       ]
     )
   end
 
-  def username_param
-    params[:account_username]
-  end
-
   def webfinger_account_link
     [
       webfinger_account_url,
-      [%w(rel lrdd), %w(type application/xrd+xml)],
-    ]
-  end
-
-  def atom_account_url_link
-    [
-      account_url(@account, format: 'atom'),
-      [%w(rel alternate), %w(type application/atom+xml)],
+      [%w(rel lrdd), %w(type application/jrd+json)],
     ]
   end
 
@@ -63,15 +46,4 @@ module AccountControllerConcern
   def webfinger_account_url
     webfinger_url(resource: @account.to_webfinger_s)
   end
-
-  def check_account_approval
-    not_found if @account.user_pending?
-  end
-
-  def check_account_suspension
-    if @account.suspended?
-      expires_in(3.minutes, public: true)
-      gone
-    end
-  end
 end
diff --git a/app/controllers/concerns/account_owned_concern.rb b/app/controllers/concerns/account_owned_concern.rb
new file mode 100644
index 000000000..460f71f65
--- /dev/null
+++ b/app/controllers/concerns/account_owned_concern.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module AccountOwnedConcern
+  extend ActiveSupport::Concern
+
+  included do
+    before_action :authenticate_user!, if: -> { whitelist_mode? && request.format != :json }
+    before_action :set_account, if: :account_required?
+    before_action :check_account_approval, if: :account_required?
+    before_action :check_account_suspension, if: :account_required?
+  end
+
+  private
+
+  def account_required?
+    true
+  end
+
+  def set_account
+    @account = Account.find_local!(username_param)
+  end
+
+  def username_param
+    params[:account_username]
+  end
+
+  def check_account_approval
+    not_found if @account.local? && @account.user_pending?
+  end
+
+  def check_account_suspension
+    expires_in(3.minutes, public: true) && gone if @account.suspended?
+  end
+end
diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb
new file mode 100644
index 000000000..c7d25ae00
--- /dev/null
+++ b/app/controllers/concerns/cache_concern.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+module CacheConcern
+  extend ActiveSupport::Concern
+
+  def render_with_cache(**options)
+    raise ArgumentError, 'only JSON render calls are supported' unless options.key?(:json) || block_given?
+
+    key        = options.delete(:key) || [[params[:controller], params[:action]].join('/'), options[:json].respond_to?(:cache_key) ? options[:json].cache_key : nil, options[:fields].nil? ? nil : options[:fields].join(',')].compact.join(':')
+    expires_in = options.delete(:expires_in) || 3.minutes
+    body       = Rails.cache.read(key, raw: true)
+
+    if body
+      render(options.except(:json, :serializer, :each_serializer, :adapter, :fields).merge(json: body))
+    else
+      if block_given?
+        options[:json] = yield
+      elsif options[:json].is_a?(Symbol)
+        options[:json] = send(options[:json])
+      end
+
+      render(options)
+      Rails.cache.write(key, response.body, expires_in: expires_in, raw: true)
+    end
+  end
+
+  def set_cache_headers
+    response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature'
+  end
+
+  def cache_collection(raw, klass)
+    return raw unless klass.respond_to?(:with_includes)
+
+    raw                    = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
+    cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)
+    uncached_ids           = raw.map(&:id) - cached_keys_with_value.keys
+
+    klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
+
+    unless uncached_ids.empty?
+      uncached = klass.where(id: uncached_ids).with_includes.each_with_object({}) { |item, h| h[item.id] = item }
+
+      uncached.each_value do |item|
+        Rails.cache.write(item, item)
+      end
+    end
+
+    raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
+  end
+end
diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb
new file mode 100644
index 000000000..b29d90b3c
--- /dev/null
+++ b/app/controllers/concerns/challengable_concern.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+# This concern is inspired by "sudo mode" on GitHub. It
+# is a way to re-authenticate a user before allowing them
+# to see or perform an action.
+#
+# Add `before_action :require_challenge!` to actions you
+# want to protect.
+#
+# The user will be shown a page to enter the challenge (which
+# is either the password, or just the username when no
+# password exists). Upon passing, there is a grace period
+# during which no challenge will be asked from the user.
+#
+# Accessing challenge-protected resources during the grace
+# period will refresh the grace period.
+module ChallengableConcern
+  extend ActiveSupport::Concern
+
+  CHALLENGE_TIMEOUT = 1.hour.freeze
+
+  def require_challenge!
+    return if skip_challenge?
+
+    if challenge_passed_recently?
+      session[:challenge_passed_at] = Time.now.utc
+      return
+    end
+
+    @challenge = Form::Challenge.new(return_to: request.url)
+
+    if params.key?(:form_challenge)
+      if challenge_passed?
+        session[:challenge_passed_at] = Time.now.utc
+        return
+      else
+        flash.now[:alert] = I18n.t('challenge.invalid_password')
+        render_challenge
+      end
+    else
+      render_challenge
+    end
+  end
+
+  def render_challenge
+    @body_classes = 'lighter'
+    render template: 'auth/challenges/new', layout: 'auth'
+  end
+
+  def challenge_passed?
+    current_user.valid_password?(challenge_params[:current_password])
+  end
+
+  def skip_challenge?
+    current_user.encrypted_password.blank?
+  end
+
+  def challenge_passed_recently?
+    session[:challenge_passed_at].present? && session[:challenge_passed_at] >= CHALLENGE_TIMEOUT.ago
+  end
+
+  def challenge_params
+    params.require(:form_challenge).permit(:current_password, :return_to)
+  end
+end
diff --git a/app/controllers/concerns/export_controller_concern.rb b/app/controllers/concerns/export_controller_concern.rb
index e20b71a30..bfe990c82 100644
--- a/app/controllers/concerns/export_controller_concern.rb
+++ b/app/controllers/concerns/export_controller_concern.rb
@@ -5,7 +5,10 @@ module ExportControllerConcern
 
   included do
     before_action :authenticate_user!
+    before_action :require_not_suspended!
     before_action :load_export
+
+    skip_before_action :require_functional!
   end
 
   private
@@ -27,4 +30,8 @@ module ExportControllerConcern
   def export_filename
     "#{controller_name}.csv"
   end
+
+  def require_not_suspended!
+    forbidden if current_account.suspended?
+  end
 end
diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb
index 145549bcd..b43859d9d 100644
--- a/app/controllers/concerns/localized.rb
+++ b/app/controllers/concerns/localized.rb
@@ -4,16 +4,19 @@ module Localized
   extend ActiveSupport::Concern
 
   included do
-    before_action :set_locale
+    around_action :set_locale
   end
 
   private
 
   def set_locale
-    I18n.locale = default_locale
-    I18n.locale = current_user.locale if user_signed_in?
-  rescue I18n::InvalidLocale
-    I18n.locale = default_locale
+    locale   = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
+    locale ||= session[:locale] ||= default_locale
+    locale   = default_locale unless I18n.available_locales.include?(locale.to_sym)
+
+    I18n.with_locale(locale) do
+      yield
+    end
   end
 
   def default_locale
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 90a57197c..ce353f1de 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -5,12 +5,35 @@
 module SignatureVerification
   extend ActiveSupport::Concern
 
+  include DomainControlHelper
+
+  def require_signature!
+    render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
+  end
+
   def signed_request?
     request.headers['Signature'].present?
   end
 
   def signature_verification_failure_reason
-    return @signature_verification_failure_reason if defined?(@signature_verification_failure_reason)
+    @signature_verification_failure_reason
+  end
+
+  def signature_verification_failure_code
+    @signature_verification_failure_code || 401
+  end
+
+  def signature_key_id
+    raw_signature    = request.headers['Signature']
+    signature_params = {}
+
+    raw_signature.split(',').each do |part|
+      parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
+      next if parsed_parts.nil? || parsed_parts.size != 3
+      signature_params[parsed_parts[1]] = parsed_parts[2]
+    end
+
+    signature_params['keyId']
   end
 
   def signed_request_account
@@ -123,6 +146,13 @@ module SignatureVerification
   end
 
   def account_from_key_id(key_id)
+    domain = key_id.start_with?('acct:') ? key_id.split('@').last : key_id
+
+    if domain_not_allowed?(domain)
+      @signature_verification_failure_code = 403
+      return
+    end
+
     if key_id.start_with?('acct:')
       stoplight_wrap_request { ResolveAccountService.new.call(key_id.gsub(/\Aacct:/, '')) }
     elsif !ActivityPub::TagManager.instance.local_uri?(key_id)
@@ -137,7 +167,7 @@ module SignatureVerification
       .with_fallback { nil }
       .with_threshold(1)
       .with_cool_off_time(5.minutes.seconds)
-      .with_error_handler { |error, handle| error.is_a?(HTTP::Error) ? handle.call(error) : raise(error) }
+      .with_error_handler { |error, handle| error.is_a?(HTTP::Error) || error.is_a?(OpenSSL::SSL::SSLError) ? handle.call(error) : raise(error) }
       .run
   end
 
diff --git a/app/controllers/concerns/status_controller_concern.rb b/app/controllers/concerns/status_controller_concern.rb
new file mode 100644
index 000000000..62a7cf508
--- /dev/null
+++ b/app/controllers/concerns/status_controller_concern.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+module StatusControllerConcern
+  extend ActiveSupport::Concern
+
+  ANCESTORS_LIMIT         = 40
+  DESCENDANTS_LIMIT       = 60
+  DESCENDANTS_DEPTH_LIMIT = 20
+
+  def create_descendant_thread(starting_depth, statuses)
+    depth = starting_depth + statuses.size
+
+    if depth < DESCENDANTS_DEPTH_LIMIT
+      {
+        statuses: statuses,
+        starting_depth: starting_depth,
+      }
+    else
+      next_status = statuses.pop
+
+      {
+        statuses: statuses,
+        starting_depth: starting_depth,
+        next_status: next_status,
+      }
+    end
+  end
+
+  def set_ancestors
+    @ancestors     = @status.reply? ? cache_collection(@status.ancestors(ANCESTORS_LIMIT, current_account), Status) : []
+    @next_ancestor = @ancestors.size < ANCESTORS_LIMIT ? nil : @ancestors.shift
+  end
+
+  def set_descendants
+    @max_descendant_thread_id   = params[:max_descendant_thread_id]&.to_i
+    @since_descendant_thread_id = params[:since_descendant_thread_id]&.to_i
+
+    descendants = cache_collection(
+      @status.descendants(
+        DESCENDANTS_LIMIT,
+        current_account,
+        @max_descendant_thread_id,
+        @since_descendant_thread_id,
+        DESCENDANTS_DEPTH_LIMIT
+      ),
+      Status
+    )
+
+    @descendant_threads = []
+
+    if descendants.present?
+      statuses       = [descendants.first]
+      starting_depth = 0
+
+      descendants.drop(1).each_with_index do |descendant, index|
+        if descendants[index].id == descendant.in_reply_to_id
+          statuses << descendant
+        else
+          @descendant_threads << create_descendant_thread(starting_depth, statuses)
+
+          # The thread is broken, assume it's a reply to the root status
+          starting_depth = 0
+
+          # ... unless we can find its ancestor in one of the already-processed threads
+          @descendant_threads.reverse_each do |descendant_thread|
+            statuses = descendant_thread[:statuses]
+
+            index = statuses.find_index do |thread_status|
+              thread_status.id == descendant.in_reply_to_id
+            end
+
+            if index.present?
+              starting_depth = descendant_thread[:starting_depth] + index + 1
+              break
+            end
+          end
+
+          statuses = [descendant]
+        end
+      end
+
+      @descendant_threads << create_descendant_thread(starting_depth, statuses)
+    end
+
+    @max_descendant_thread_id = @descendant_threads.pop[:statuses].first.id if descendants.size >= DESCENDANTS_LIMIT
+  end
+end