about summary refs log tree commit diff
path: root/app/controllers
diff options
context:
space:
mode:
Diffstat (limited to 'app/controllers')
-rw-r--r--app/controllers/accounts_controller.rb2
-rw-r--r--app/controllers/activitypub/collections_controller.rb2
-rw-r--r--app/controllers/admin/base_controller.rb5
-rw-r--r--app/controllers/admin/custom_emojis_controller.rb3
-rw-r--r--app/controllers/admin/settings/other_controller.rb9
-rw-r--r--app/controllers/api/v1/accounts/relationships_controller.rb2
-rw-r--r--app/controllers/api/v1/notifications_controller.rb13
-rw-r--r--app/controllers/api/v1/statuses_controller.rb5
-rw-r--r--app/controllers/api/v1/timelines/direct_controller.rb65
-rw-r--r--app/controllers/api/v1/timelines/public_controller.rb7
-rw-r--r--app/controllers/api/v1/trends/tags_controller.rb2
-rw-r--r--app/controllers/api/v2/search_controller.rb2
-rw-r--r--app/controllers/application_controller.rb14
-rw-r--r--app/controllers/auth/challenges_controller.rb7
-rw-r--r--app/controllers/auth/confirmations_controller.rb49
-rw-r--r--app/controllers/auth/passwords_controller.rb5
-rw-r--r--app/controllers/auth/registrations_controller.rb5
-rw-r--r--app/controllers/auth/sessions_controller.rb5
-rw-r--r--app/controllers/auth/setup_controller.rb5
-rw-r--r--app/controllers/authorize_interactions_controller.rb5
-rw-r--r--app/controllers/concerns/captcha_concern.rb59
-rw-r--r--app/controllers/concerns/theming_concern.rb89
-rw-r--r--app/controllers/concerns/two_factor_authentication_concern.rb2
-rw-r--r--app/controllers/concerns/web_app_controller_concern.rb7
-rw-r--r--app/controllers/disputes/base_controller.rb5
-rw-r--r--app/controllers/filters/statuses_controller.rb5
-rw-r--r--app/controllers/filters_controller.rb5
-rw-r--r--app/controllers/follower_accounts_controller.rb12
-rw-r--r--app/controllers/invites_controller.rb5
-rw-r--r--app/controllers/media_controller.rb5
-rw-r--r--app/controllers/oauth/authorizations_controller.rb5
-rw-r--r--app/controllers/oauth/authorized_applications_controller.rb5
-rw-r--r--app/controllers/relationships_controller.rb5
-rw-r--r--app/controllers/settings/base_controller.rb5
-rw-r--r--app/controllers/settings/flavours_controller.rb38
-rw-r--r--app/controllers/settings/login_activities_controller.rb6
-rw-r--r--app/controllers/settings/preferences_controller.rb7
-rw-r--r--app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb4
-rw-r--r--app/controllers/shares_controller.rb5
-rw-r--r--app/controllers/statuses_cleanup_controller.rb5
-rw-r--r--app/controllers/statuses_controller.rb1
41 files changed, 466 insertions, 26 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 56229fd05..4d03a04b7 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -47,7 +47,7 @@ class AccountsController < ApplicationController
   end
 
   def default_statuses
-    @account.statuses.where(visibility: [:public, :unlisted])
+    @account.statuses.not_local_only.where(visibility: [:public, :unlisted])
   end
 
   def only_media_scope
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index d94a285ea..23d874071 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -20,7 +20,7 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
   def set_items
     case params[:id]
     when 'featured'
-      @items = for_signed_account { cache_collection(@account.pinned_statuses, Status) }
+      @items = for_signed_account { cache_collection(@account.pinned_statuses.not_local_only, Status) }
       @items = @items.map { |item| item.distributable? ? item : ActivityPub::TagManager.instance.uri_for(item) }
     when 'tags'
       @items = for_signed_account { @account.featured_tags }
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
index 5b7a7ec11..c645ce12b 100644
--- a/app/controllers/admin/base_controller.rb
+++ b/app/controllers/admin/base_controller.rb
@@ -7,6 +7,7 @@ module Admin
 
     layout 'admin'
 
+    before_action :set_pack
     before_action :set_body_classes
     after_action :verify_authorized
 
@@ -16,6 +17,10 @@ module Admin
       @body_classes = 'admin'
     end
 
+    def set_pack
+      use_pack 'admin'
+    end
+
     def set_user
       @user = Account.find(params[:account_id]).user || raise(ActiveRecord::RecordNotFound)
     end
diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb
index 00d069cdf..431dc1524 100644
--- a/app/controllers/admin/custom_emojis_controller.rb
+++ b/app/controllers/admin/custom_emojis_controller.rb
@@ -37,6 +37,9 @@ module Admin
       flash[:alert] = I18n.t('admin.custom_emojis.no_emoji_selected')
     rescue Mastodon::NotPermittedError
       flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
+    rescue ActiveRecord::RecordInvalid => e
+      error_message = action_from_button == 'copy' ? 'admin.custom_emojis.batch_copy_error' : 'admin.custom_emojis.batch_error'
+      flash[:alert] = I18n.t(error_message, message: e.message)
     ensure
       redirect_to admin_custom_emojis_path(filter_params)
     end
diff --git a/app/controllers/admin/settings/other_controller.rb b/app/controllers/admin/settings/other_controller.rb
new file mode 100644
index 000000000..c7bfa3d58
--- /dev/null
+++ b/app/controllers/admin/settings/other_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Admin::Settings::OtherController < Admin::SettingsController
+  private
+
+  def after_update_redirect_path
+    admin_settings_other_path
+  end
+end
diff --git a/app/controllers/api/v1/accounts/relationships_controller.rb b/app/controllers/api/v1/accounts/relationships_controller.rb
index 503f85c97..1d3992a28 100644
--- a/app/controllers/api/v1/accounts/relationships_controller.rb
+++ b/app/controllers/api/v1/accounts/relationships_controller.rb
@@ -5,7 +5,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
   before_action :require_user!
 
   def index
-    accounts = Account.without_suspended.where(id: account_ids).select('id')
+    accounts = Account.where(id: account_ids).select('id')
     # .where doesn't guarantee that our results are in the same order
     # we requested them, so return the "right" order to the requestor.
     @accounts = accounts.index_by(&:id).values_at(*account_ids).compact
diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb
index 41f7f93af..93785b14a 100644
--- a/app/controllers/api/v1/notifications_controller.rb
+++ b/app/controllers/api/v1/notifications_controller.rb
@@ -1,8 +1,8 @@
 # frozen_string_literal: true
 
 class Api::V1::NotificationsController < Api::BaseController
-  before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss]
-  before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss]
+  before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, except: [:clear, :dismiss, :destroy, :destroy_multiple]
+  before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: [:clear, :dismiss, :destroy, :destroy_multiple]
   before_action :require_user!
   after_action :insert_pagination_headers, only: :index
 
@@ -23,11 +23,20 @@ class Api::V1::NotificationsController < Api::BaseController
     render_empty
   end
 
+  def destroy
+    dismiss
+  end
+
   def dismiss
     current_account.notifications.find_by!(id: params[:id]).destroy!
     render_empty
   end
 
+  def destroy_multiple
+    current_account.notifications.where(id: params[:ids]).destroy_all
+    render_empty
+  end
+
   private
 
   def load_notifications
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index fadd1b045..8dcf6331e 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -63,6 +63,7 @@ class Api::V1::StatusesController < Api::BaseController
       scheduled_at: status_params[:scheduled_at],
       application: doorkeeper_token.application,
       poll: status_params[:poll],
+      content_type: status_params[:content_type],
       allowed_mentions: status_params[:allowed_mentions],
       idempotency: request.headers['Idempotency-Key'],
       with_rate_limit: true
@@ -90,7 +91,8 @@ class Api::V1::StatusesController < Api::BaseController
       sensitive: status_params[:sensitive],
       language: status_params[:language],
       spoiler_text: status_params[:spoiler_text],
-      poll: status_params[:poll]
+      poll: status_params[:poll],
+      content_type: status_params[:content_type]
     )
 
     render json: @status, serializer: REST::StatusSerializer
@@ -135,6 +137,7 @@ class Api::V1::StatusesController < Api::BaseController
       :visibility,
       :language,
       :scheduled_at,
+      :content_type,
       allowed_mentions: [],
       media_ids: [],
       media_attributes: [
diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb
new file mode 100644
index 000000000..6e98e9cac
--- /dev/null
+++ b/app/controllers/api/v1/timelines/direct_controller.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class Api::V1::Timelines::DirectController < Api::BaseController
+  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
+  before_action :require_user!, only: [:show]
+  after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
+
+  respond_to :json
+
+  def show
+    @statuses = load_statuses
+    render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
+  end
+
+  private
+
+  def load_statuses
+    cached_direct_statuses
+  end
+
+  def cached_direct_statuses
+    cache_collection direct_statuses, Status
+  end
+
+  def direct_statuses
+    direct_timeline_statuses
+  end
+
+  def direct_timeline_statuses
+    account_direct_feed.get(
+      limit_param(DEFAULT_STATUSES_LIMIT),
+      params[:max_id],
+      params[:since_id],
+      params[:min_id]
+    )
+  end
+
+  def account_direct_feed
+    DirectFeed.new(current_account)
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def pagination_params(core_params)
+    params.permit(:local, :limit).merge(core_params)
+  end
+
+  def next_path
+    api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id)
+  end
+
+  def prev_path
+    api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id)
+  end
+
+  def pagination_max_id
+    @statuses.last.id
+  end
+
+  def pagination_since_id
+    @statuses.first.id
+  end
+end
diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb
index d253b744f..493fe4776 100644
--- a/app/controllers/api/v1/timelines/public_controller.rb
+++ b/app/controllers/api/v1/timelines/public_controller.rb
@@ -37,7 +37,10 @@ class Api::V1::Timelines::PublicController < Api::BaseController
       current_account,
       local: truthy_param?(:local),
       remote: truthy_param?(:remote),
-      only_media: truthy_param?(:only_media)
+      only_media: truthy_param?(:only_media),
+      allow_local_only: truthy_param?(:allow_local_only),
+      with_replies: Setting.show_replies_in_public_timelines,
+      with_reblogs: Setting.show_reblogs_in_public_timelines,
     )
   end
 
@@ -46,7 +49,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
   end
 
   def pagination_params(core_params)
-    params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params)
+    params.slice(:local, :remote, :limit, :only_media, :allow_local_only).permit(:local, :remote, :limit, :only_media, :allow_local_only).merge(core_params)
   end
 
   def next_path
diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb
index 21adfa2a1..885a4ad7e 100644
--- a/app/controllers/api/v1/trends/tags_controller.rb
+++ b/app/controllers/api/v1/trends/tags_controller.rb
@@ -5,7 +5,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
 
   after_action :insert_pagination_headers
 
-  DEFAULT_TAGS_LIMIT = 10
+  DEFAULT_TAGS_LIMIT = (ENV['MAX_TRENDING_TAGS'] || 10).to_i
 
   def index
     render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id)
diff --git a/app/controllers/api/v2/search_controller.rb b/app/controllers/api/v2/search_controller.rb
index 4d20aeb10..b084eae42 100644
--- a/app/controllers/api/v2/search_controller.rb
+++ b/app/controllers/api/v2/search_controller.rb
@@ -3,7 +3,7 @@
 class Api::V2::SearchController < Api::BaseController
   include Authorization
 
-  RESULTS_LIMIT = 20
+  RESULTS_LIMIT = (ENV['MAX_SEARCH_RESULTS'] || 20).to_i
 
   before_action -> { authorize_if_got_token! :read, :'read:search' }
   before_action :validate_search_params!
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 615536b96..ee3c5204d 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -10,10 +10,12 @@ class ApplicationController < ActionController::Base
   include SessionTrackingConcern
   include CacheConcern
   include DomainControlHelper
+  include ThemingConcern
 
   helper_method :current_account
   helper_method :current_session
-  helper_method :current_theme
+  helper_method :current_flavour
+  helper_method :current_skin
   helper_method :single_user_mode?
   helper_method :use_seamless_external_login?
   helper_method :whitelist_mode?
@@ -126,14 +128,12 @@ class ApplicationController < ActionController::Base
     @current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
   end
 
-  def current_theme
-    return Setting.theme unless Themes.instance.names.include? current_user&.setting_theme
-    current_user.setting_theme
-  end
-
   def respond_with_error(code)
     respond_to do |format|
-      format.any  { render "errors/#{code}", layout: 'error', status: code, formats: [:html] }
+      format.any do
+        use_pack 'error'
+        render "errors/#{code}", layout: 'error', status: code, formats: [:html]
+      end
       format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[code] }, status: code }
     end
   end
diff --git a/app/controllers/auth/challenges_controller.rb b/app/controllers/auth/challenges_controller.rb
index 060944240..41827b21c 100644
--- a/app/controllers/auth/challenges_controller.rb
+++ b/app/controllers/auth/challenges_controller.rb
@@ -5,6 +5,7 @@ class Auth::ChallengesController < ApplicationController
 
   layout 'auth'
 
+  before_action :set_pack
   before_action :authenticate_user!
 
   skip_before_action :require_functional!
@@ -19,4 +20,10 @@ class Auth::ChallengesController < ApplicationController
       render_challenge
     end
   end
+
+  private
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb
index 010fd3755..0817a905c 100644
--- a/app/controllers/auth/confirmations_controller.rb
+++ b/app/controllers/auth/confirmations_controller.rb
@@ -1,11 +1,18 @@
 # frozen_string_literal: true
 
 class Auth::ConfirmationsController < Devise::ConfirmationsController
+  include CaptchaConcern
+
   layout 'auth'
 
   before_action :set_body_classes
+  before_action :set_pack
+  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 new
@@ -14,8 +21,50 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
     resource.email = current_user.unconfirmed_email || current_user.email if user_signed_in?
   end
 
+  def show
+    old_session_values = session.to_hash
+    reset_session
+    session.update old_session_values.except('session_id')
+
+    super
+  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?
+
+    invite = Invite.find(@confirmation_user.invite_id) if @confirmation_user.invite_id.present?
+    invite.present? && !invite.max_uses.nil?
+  end
+
+  def set_pack
+    use_pack 'auth'
+  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)
diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb
index a8ad66929..576c3e7bc 100644
--- a/app/controllers/auth/passwords_controller.rb
+++ b/app/controllers/auth/passwords_controller.rb
@@ -2,6 +2,7 @@
 
 class Auth::PasswordsController < Devise::PasswordsController
   before_action :check_validity_of_reset_password_token, only: :edit
+  before_action :set_pack
   before_action :set_body_classes
 
   layout 'auth'
@@ -32,4 +33,8 @@ class Auth::PasswordsController < Devise::PasswordsController
   def reset_password_token_is_valid?
     resource_class.with_reset_password_token(params[:reset_password_token]).present?
   end
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 71c0cd827..40c38bc6d 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -8,6 +8,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :set_invite, only: [:new, :create]
   before_action :check_enabled_registrations, only: [:new, :create]
   before_action :configure_sign_up_params, only: [:create]
+  before_action :set_pack
   before_action :set_sessions, only: [:edit, :update]
   before_action :set_strikes, only: [:edit, :update]
   before_action :set_instance_presenter, only: [:new, :create, :update]
@@ -109,6 +110,10 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 
   private
 
+  def set_pack
+    use_pack %w(edit update).include?(action_name) ? 'admin' : 'auth'
+  end
+
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
diff --git a/app/controllers/auth/sessions_controller.rb b/app/controllers/auth/sessions_controller.rb
index afcf8b24b..16c18baa2 100644
--- a/app/controllers/auth/sessions_controller.rb
+++ b/app/controllers/auth/sessions_controller.rb
@@ -7,6 +7,7 @@ class Auth::SessionsController < Devise::SessionsController
   skip_before_action :require_functional!
   skip_before_action :update_user_sign_in
 
+  prepend_before_action :set_pack
   prepend_before_action :check_suspicious!, only: [:create]
 
   include TwoFactorAuthenticationConcern
@@ -99,6 +100,10 @@ class Auth::SessionsController < Devise::SessionsController
 
   private
 
+  def set_pack
+    use_pack 'auth'
+  end
+
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb
index 46c5f2958..db5a866f2 100644
--- a/app/controllers/auth/setup_controller.rb
+++ b/app/controllers/auth/setup_controller.rb
@@ -3,6 +3,7 @@
 class Auth::SetupController < ApplicationController
   layout 'auth'
 
+  before_action :set_pack
   before_action :authenticate_user!
   before_action :require_unconfirmed_or_pending!
   before_action :set_body_classes
@@ -55,4 +56,8 @@ class Auth::SetupController < ApplicationController
   def missing_email?
     truthy_param?(:missing_email)
   end
+
+  def set_pack
+    use_pack 'auth'
+  end
 end
diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb
index 02a6b6d06..97fe4a9ab 100644
--- a/app/controllers/authorize_interactions_controller.rb
+++ b/app/controllers/authorize_interactions_controller.rb
@@ -8,6 +8,7 @@ class AuthorizeInteractionsController < ApplicationController
   before_action :authenticate_user!
   before_action :set_body_classes
   before_action :set_resource
+  before_action :set_pack
 
   def show
     if @resource.is_a?(Account)
@@ -65,4 +66,8 @@ class AuthorizeInteractionsController < ApplicationController
   def set_body_classes
     @body_classes = 'modal-layout'
   end
+
+  def set_pack
+    use_pack 'modal'
+  end
 end
diff --git a/app/controllers/concerns/captcha_concern.rb b/app/controllers/concerns/captcha_concern.rb
new file mode 100644
index 000000000..538c1ffb1
--- /dev/null
+++ b/app/controllers/concerns/captcha_concern.rb
@@ -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
diff --git a/app/controllers/concerns/theming_concern.rb b/app/controllers/concerns/theming_concern.rb
new file mode 100644
index 000000000..f993a81d7
--- /dev/null
+++ b/app/controllers/concerns/theming_concern.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+module ThemingConcern
+  extend ActiveSupport::Concern
+
+  def use_pack(pack_name)
+    @core = resolve_pack_with_common(Themes.instance.core, pack_name)
+    @theme = resolve_pack_with_common(Themes.instance.flavour(current_flavour), pack_name, current_skin)
+  end
+
+  private
+
+  def current_flavour
+    [current_user&.setting_flavour, Setting.flavour, 'glitch', 'vanilla'].find { |flavour| Themes.instance.flavours.include?(flavour) }
+  end
+
+  def current_skin
+    skins = Themes.instance.skins_for(current_flavour)
+    [current_user&.setting_skin, Setting.skin, 'default'].find { |skin| skins.include?(skin) }
+  end
+
+  def valid_pack_data?(data, pack_name)
+    data['pack'].is_a?(Hash) && data['pack'][pack_name].present?
+  end
+
+  def nil_pack(data)
+    {
+      use_common: true,
+      flavour: data['name'],
+      pack: nil,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+  end
+
+  def pack(data, pack_name, skin)
+    pack_data = {
+      use_common: true,
+      flavour: data['name'],
+      pack: pack_name,
+      preload: nil,
+      skin: nil,
+      supported_locales: data['locales'],
+    }
+
+    return pack_data unless data['pack'][pack_name].is_a?(Hash)
+
+    pack_data[:use_common] = false if data['pack'][pack_name]['use_common'] == false
+    pack_data[:pack] = nil unless data['pack'][pack_name]['filename']
+
+    preloads = data['pack'][pack_name]['preload']
+    pack_data[:preload] = [preloads] if preloads.is_a?(String)
+    pack_data[:preload] = preloads if preloads.is_a?(Array)
+
+    if skin != 'default' && data['skin'][skin]
+      pack_data[:skin] = skin if data['skin'][skin].include?(pack_name)
+    elsif data['pack'][pack_name]['stylesheet']
+      pack_data[:skin] = 'default'
+    end
+
+    pack_data
+  end
+
+  def resolve_pack(data, pack_name, skin)
+    return pack(data, pack_name, skin) if valid_pack_data?(data, pack_name)
+    return if data['name'].blank?
+
+    fallbacks = []
+    if data.key?('fallback')
+      fallbacks = data['fallback'] if data['fallback'].is_a?(Array)
+      fallbacks = [data['fallback']] if data['fallback'].is_a?(String)
+    elsif data['name'] != Setting.default_settings['flavour']
+      fallbacks = [Setting.default_settings['flavour']]
+    end
+
+    fallbacks.each do |fallback|
+      return resolve_pack(Themes.instance.flavour(fallback), pack_name) if Themes.instance.flavour(fallback)
+    end
+
+    nil
+  end
+
+  def resolve_pack_with_common(data, pack_name, skin = 'default')
+    result = resolve_pack(data, pack_name, skin) || nil_pack(data)
+    result[:common] = resolve_pack(data, 'common', skin) if result.delete(:use_common)
+    result
+  end
+end
diff --git a/app/controllers/concerns/two_factor_authentication_concern.rb b/app/controllers/concerns/two_factor_authentication_concern.rb
index 27f2367a8..c9477a1d4 100644
--- a/app/controllers/concerns/two_factor_authentication_concern.rb
+++ b/app/controllers/concerns/two_factor_authentication_concern.rb
@@ -77,6 +77,8 @@ module TwoFactorAuthenticationConcern
   def prompt_for_two_factor(user)
     set_attempt_session(user)
 
+    use_pack 'auth'
+
     @body_classes     = 'lighter'
     @webauthn_enabled = user.webauthn_enabled?
     @scheme_type      = begin
diff --git a/app/controllers/concerns/web_app_controller_concern.rb b/app/controllers/concerns/web_app_controller_concern.rb
index f28786f63..7ba7a57e3 100644
--- a/app/controllers/concerns/web_app_controller_concern.rb
+++ b/app/controllers/concerns/web_app_controller_concern.rb
@@ -5,6 +5,7 @@ module WebAppControllerConcern
 
   included do
     prepend_before_action :redirect_unauthenticated_to_permalinks!
+    before_action :set_pack
     before_action :set_app_body_class
   end
 
@@ -13,10 +14,14 @@ module WebAppControllerConcern
   end
 
   def redirect_unauthenticated_to_permalinks!
-    return if user_signed_in? && current_account.moved_to_account_id.nil?
+    return if user_signed_in? # NOTE: Different from upstream because we allow moved users to log in
 
     redirect_path = PermalinkRedirector.new(request.path).redirect_path
 
     redirect_to(redirect_path) if redirect_path.present?
   end
+
+  def set_pack
+    use_pack 'home'
+  end
 end
diff --git a/app/controllers/disputes/base_controller.rb b/app/controllers/disputes/base_controller.rb
index 865146b5c..7830c5524 100644
--- a/app/controllers/disputes/base_controller.rb
+++ b/app/controllers/disputes/base_controller.rb
@@ -9,9 +9,14 @@ class Disputes::BaseController < ApplicationController
 
   before_action :set_body_classes
   before_action :authenticate_user!
+  before_action :set_pack
 
   private
 
+  def set_pack
+    use_pack 'admin'
+  end
+
   def set_body_classes
     @body_classes = 'admin'
   end
diff --git a/app/controllers/filters/statuses_controller.rb b/app/controllers/filters/statuses_controller.rb
index cc493c22c..4f63de7b6 100644
--- a/app/controllers/filters/statuses_controller.rb
+++ b/app/controllers/filters/statuses_controller.rb
@@ -6,6 +6,7 @@ class Filters::StatusesController < ApplicationController
   before_action :authenticate_user!
   before_action :set_filter
   before_action :set_status_filters
+  before_action :set_pack
   before_action :set_body_classes
 
   PER_PAGE = 20
@@ -25,6 +26,10 @@ class Filters::StatusesController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'admin'
+  end
+
   def set_filter
     @filter = current_account.custom_filters.find(params[:filter_id])
   end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index cc5cb5d9f..2ab3b0a74 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -5,6 +5,7 @@ class FiltersController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_filter, only: [:edit, :update, :destroy]
+  before_action :set_pack
   before_action :set_body_classes
 
   def index
@@ -43,6 +44,10 @@ class FiltersController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_filter
     @filter = current_account.custom_filters.find(params[:id])
   end
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 9ced18449..1f5ed30de 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -58,22 +58,22 @@ class FollowerAccountsController < ApplicationController
   end
 
   def collection_presenter
+    options = { type: :ordered }
+    options[:size] = @account.followers_count unless Setting.hide_followers_count || @account.user&.setting_hide_followers_count
     if page_requested?
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account, page: params.fetch(:page, 1)),
-        type: :ordered,
-        size: @account.followers_count,
         items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
         part_of: account_followers_url(@account),
         next: next_page_url,
-        prev: prev_page_url
+        prev: prev_page_url,
+        **options
       )
     else
       ActivityPub::CollectionPresenter.new(
         id: account_followers_url(@account),
-        type: :ordered,
-        size: @account.followers_count,
-        first: page_url(1)
+        first: page_url(1),
+        **options
       )
     end
   end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 8d92147e2..0b3c082dc 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -6,6 +6,7 @@ class InvitesController < ApplicationController
   layout 'admin'
 
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_body_classes
 
   def index
@@ -38,6 +39,10 @@ class InvitesController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def invites
     current_user.invites.order(id: :desc)
   end
diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb
index 3cdd97f06..f9160d8c4 100644
--- a/app/controllers/media_controller.rb
+++ b/app/controllers/media_controller.rb
@@ -11,6 +11,7 @@ class MediaController < ApplicationController
   before_action :verify_permitted_status!
   before_action :check_playable, only: :player
   before_action :allow_iframing, only: :player
+  before_action :set_pack, only: :player
 
   content_security_policy only: :player do |policy|
     policy.frame_ancestors(false)
@@ -48,4 +49,8 @@ class MediaController < ApplicationController
   def allow_iframing
     response.headers['X-Frame-Options'] = 'ALLOWALL'
   end
+
+  def set_pack
+    use_pack 'public'
+  end
 end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 5449cfb1a..d6e7d0800 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -5,6 +5,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
 
   before_action :store_current_location
   before_action :authenticate_resource_owner!
+  before_action :set_pack
   before_action :set_cache_headers
 
   content_security_policy do |p|
@@ -19,6 +20,10 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
     store_location_for(:user, request.url)
   end
 
+  def set_pack
+    use_pack 'auth'
+  end
+
   def render_success
     if skip_authorization? || (matching_token? && !truthy_param?('force_login'))
       redirect_or_render authorize_response
diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb
index 45151cdd7..b2564a791 100644
--- a/app/controllers/oauth/authorized_applications_controller.rb
+++ b/app/controllers/oauth/authorized_applications_controller.rb
@@ -5,6 +5,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
 
   before_action :store_current_location
   before_action :authenticate_resource_owner!
+  before_action :set_pack
   before_action :require_not_suspended!, only: :destroy
   before_action :set_body_classes
 
@@ -27,6 +28,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
     store_location_for(:user, request.url)
   end
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def require_not_suspended!
     forbidden if current_account.suspended?
   end
diff --git a/app/controllers/relationships_controller.rb b/app/controllers/relationships_controller.rb
index 96cce55e9..d40770726 100644
--- a/app/controllers/relationships_controller.rb
+++ b/app/controllers/relationships_controller.rb
@@ -5,6 +5,7 @@ class RelationshipsController < ApplicationController
 
   before_action :authenticate_user!
   before_action :set_accounts, only: :show
+  before_action :set_pack
   before_action :set_relationships, only: :show
   before_action :set_body_classes
 
@@ -68,4 +69,8 @@ class RelationshipsController < ApplicationController
   def set_body_classes
     @body_classes = 'admin'
   end
+
+  def set_pack
+    use_pack 'admin'
+  end
 end
diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb
index 8722fd64a..bf17b918c 100644
--- a/app/controllers/settings/base_controller.rb
+++ b/app/controllers/settings/base_controller.rb
@@ -1,6 +1,7 @@
 # frozen_string_literal: true
 
 class Settings::BaseController < ApplicationController
+  before_action :set_pack
   layout 'admin'
 
   before_action :authenticate_user!
@@ -9,6 +10,10 @@ class Settings::BaseController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_body_classes
     @body_classes = 'admin'
   end
diff --git a/app/controllers/settings/flavours_controller.rb b/app/controllers/settings/flavours_controller.rb
new file mode 100644
index 000000000..62c52eee9
--- /dev/null
+++ b/app/controllers/settings/flavours_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class Settings::FlavoursController < Settings::BaseController
+  layout 'admin'
+
+  before_action :authenticate_user!
+
+  skip_before_action :require_functional!
+
+  def index
+    redirect_to action: 'show', flavour: current_flavour
+  end
+
+  def show
+    unless Themes.instance.flavours.include?(params[:flavour]) || (params[:flavour] == current_flavour)
+      redirect_to action: 'show', flavour: current_flavour
+    end
+
+    @listing = Themes.instance.flavours
+    @selected = params[:flavour]
+  end
+
+  def update
+    user_settings.update(user_settings_params)
+    redirect_to action: 'show', flavour: params[:flavour]
+  end
+
+  private
+
+  def user_settings
+    UserSettingsDecorator.new(current_user)
+  end
+
+  def user_settings_params
+    { setting_flavour: params.require(:flavour),
+      setting_skin: params.dig(:user, :setting_skin) }.with_indifferent_access
+  end
+end
diff --git a/app/controllers/settings/login_activities_controller.rb b/app/controllers/settings/login_activities_controller.rb
index 57fa6aef0..ee77524b1 100644
--- a/app/controllers/settings/login_activities_controller.rb
+++ b/app/controllers/settings/login_activities_controller.rb
@@ -4,4 +4,10 @@ class Settings::LoginActivitiesController < Settings::BaseController
   def index
     @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
   end
+
+  private
+
+  def set_pack
+    use_pack 'settings'
+  end
 end
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index f5d5c1244..4c1336436 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -38,6 +38,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_default_language,
       :setting_unfollow_modal,
       :setting_boost_modal,
+      :setting_favourite_modal,
       :setting_delete_modal,
       :setting_auto_play_gif,
       :setting_display_media,
@@ -45,17 +46,19 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_reduce_motion,
       :setting_disable_swiping,
       :setting_system_font_ui,
+      :setting_system_emoji_font,
       :setting_noindex,
-      :setting_theme,
+      :setting_hide_followers_count,
       :setting_aggregate_reblogs,
       :setting_show_application,
       :setting_advanced_layout,
+      :setting_default_content_type,
       :setting_use_blurhash,
       :setting_use_pending_items,
       :setting_trends,
       :setting_crop_images,
       :setting_always_send_emails,
-      notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag appeal),
+      notification_emails: %i(follow follow_request reblog favourite mention report pending_account trending_tag trending_link trending_status appeal),
       interactions: %i(must_be_follower must_be_following must_be_following_dm)
     )
   end
diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
index a50d30f06..7e2d43dcd 100644
--- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
+++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb
@@ -85,6 +85,10 @@ module Settings
 
       private
 
+      def set_pack
+        use_pack 'auth'
+      end
+
       def require_otp_enabled
         unless current_user.otp_enabled?
           flash[:error] = t('webauthn_credentials.otp_required')
diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb
index 6546b8497..e13e7e8b6 100644
--- a/app/controllers/shares_controller.rb
+++ b/app/controllers/shares_controller.rb
@@ -4,12 +4,17 @@ class SharesController < ApplicationController
   layout 'modal'
 
   before_action :authenticate_user!
+  before_action :set_pack
   before_action :set_body_classes
 
   def show; end
 
   private
 
+  def set_pack
+    use_pack 'share'
+  end
+
   def set_body_classes
     @body_classes = 'modal-layout compose-standalone'
   end
diff --git a/app/controllers/statuses_cleanup_controller.rb b/app/controllers/statuses_cleanup_controller.rb
index e912967fd..0e7bb835f 100644
--- a/app/controllers/statuses_cleanup_controller.rb
+++ b/app/controllers/statuses_cleanup_controller.rb
@@ -6,6 +6,7 @@ class StatusesCleanupController < ApplicationController
   before_action :authenticate_user!
   before_action :set_policy
   before_action :set_body_classes
+  before_action :set_pack
 
   def show; end
 
@@ -25,6 +26,10 @@ class StatusesCleanupController < ApplicationController
 
   private
 
+  def set_pack
+    use_pack 'settings'
+  end
+
   def set_policy
     @policy = current_account.statuses_cleanup_policy || current_account.build_statuses_cleanup_policy(enabled: false)
   end
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 0e0783b4b..e5221df3a 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -40,6 +40,7 @@ class StatusesController < ApplicationController
   end
 
   def embed
+    use_pack 'embed'
     return not_found if @status.hidden? || @status.reblog?
 
     expires_in 180, public: true