~cytrogen/masto-fe

ac2dae0d113f0135151d4709a31fbb83fa1670a0 — Claire 2 years ago f5bd201 + 6c4c724
Merge commit '6c4c72497a5722870e4432ef41dd4c9ec36a8928' into glitch-soc/merge-upstream

Conflicts:
- `.github/workflows/build-releases.yml`:
  Upstream changed comments close to a line we modified to account for
  different container image repositories.
  Updated the comments as upstream did.
M .github/workflows/build-container-image.yml => .github/workflows/build-container-image.yml +0 -2
@@ 76,8 76,6 @@ jobs:
        if: ${{ inputs.push_to_images != '' }}
        with:
          images: ${{ inputs.push_to_images }}
          # Only tag with latest when ran against the latest stable branch
          # This needs to be updated after each minor version release
          flavor: ${{ inputs.flavor }}
          tags: ${{ inputs.tags }}
          labels: ${{ inputs.labels }}

M .github/workflows/build-releases.yml => .github/workflows/build-releases.yml +2 -0
@@ 16,6 16,8 @@ jobs:
      use_native_arm64_builder: false
      push_to_images: |
        ghcr.io/${{ github.repository_owner }}/mastodon
      # Only tag with latest when ran against the latest stable branch
      # This needs to be updated after each minor version release
      flavor: |
        latest=${{ startsWith(github.ref, 'refs/tags/v4.1.') }}
      tags: |

M Gemfile.lock => Gemfile.lock +2 -2
@@ 482,7 482,7 @@ GEM
    nokogiri (1.15.4)
      mini_portile2 (~> 2.8.2)
      racc (~> 1.4)
    oj (3.16.0)
    oj (3.16.1)
    omniauth (2.1.1)
      hashie (>= 3.4.6)
      rack (>= 2.2.3)


@@ 519,7 519,7 @@ GEM
    parslet (2.0.0)
    pastel (0.8.0)
      tty-color (~> 0.5)
    pg (1.5.3)
    pg (1.5.4)
    pghero (3.3.3)
      activerecord (>= 6)
    posix-spawn (0.3.15)

M app/chewy/accounts_index.rb => app/chewy/accounts_index.rb +1 -1
@@ 34,7 34,7 @@ class AccountsIndex < Chewy::Index
      },

      verbatim: {
        tokenizer: 'uax_url_email',
        tokenizer: 'standard',
        filter: %w(lowercase asciifolding cjk_width),
      },


M app/controllers/api/v1/timelines/tag_controller.rb => app/controllers/api/v1/timelines/tag_controller.rb +5 -0
@@ 1,6 1,7 @@
# frozen_string_literal: true

class Api::V1::Timelines::TagController < Api::BaseController
  before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
  before_action :load_tag
  after_action :insert_pagination_headers, unless: -> { @statuses.empty? }



@@ 12,6 13,10 @@ class Api::V1::Timelines::TagController < Api::BaseController

  private

  def require_auth?
    !Setting.timeline_preview
  end

  def load_tag
    @tag = Tag.find_normalized(params[:id])
  end

M app/javascript/mastodon/features/compose/components/search.jsx => app/javascript/mastodon/features/compose/components/search.jsx +13 -9
@@ 80,7 80,7 @@ class Search extends PureComponent {

  handleKeyDown = (e) => {
    const { selectedOption } = this.state;
    const options = this._getOptions().concat(this.defaultOptions);
    const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();

    switch(e.key) {
    case 'Escape':


@@ 353,15 353,19 @@ class Search extends PureComponent {
            </>
          )}

          <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
          {searchEnabled && (
            <>
              <h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>

          <div className='search__popout__menu'>
            {this.defaultOptions.map(({ key, label, action }, i) => (
              <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
                {label}
              </button>
            ))}
          </div>
              <div className='search__popout__menu'>
                {this.defaultOptions.map(({ key, label, action }, i) => (
                  <button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
                    {label}
                  </button>
                ))}
              </div>
            </>
          )}
        </div>
      </div>
    );

M app/javascript/mastodon/features/ui/components/navigation_panel.jsx => app/javascript/mastodon/features/ui/components/navigation_panel.jsx +11 -5
@@ 31,6 31,7 @@ const messages = defineMessages({
  about: { id: 'navigation_bar.about', defaultMessage: 'About' },
  search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
  advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
  openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
});

class NavigationPanel extends Component {


@@ 57,12 58,17 @@ class NavigationPanel extends Component {
        <div className='navigation-panel__logo'>
          <Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>

          {transientSingleColumn && (
            <a href={`/deck${location.pathname}`} className='button button--block'>
              {intl.formatMessage(messages.advancedInterface)}
            </a>
          {transientSingleColumn ? (
            <div class='switch-to-advanced'>
              {intl.formatMessage(messages.openedInClassicInterface)}
              {" "}
              <a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
                {intl.formatMessage(messages.advancedInterface)}
              </a>
            </div>
          ) : (
            <hr />
          )}
          <hr />
        </div>

        {signedIn && (

M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +1 -0
@@ 411,6 411,7 @@
  "navigation_bar.lists": "Lists",
  "navigation_bar.logout": "Logout",
  "navigation_bar.mutes": "Muted users",
  "navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
  "navigation_bar.personal": "Personal",
  "navigation_bar.pins": "Pinned posts",
  "navigation_bar.preferences": "Preferences",

M app/javascript/mastodon/locales/fr.json => app/javascript/mastodon/locales/fr.json +1 -0
@@ 409,6 409,7 @@
  "navigation_bar.lists": "Listes",
  "navigation_bar.logout": "Déconnexion",
  "navigation_bar.mutes": "Comptes masqués",
  "navigation_bar.opened_in_classic_interface": "Les messages, les comptes et les pages spécifiques sont ouvertes dans l’interface classique.",
  "navigation_bar.personal": "Personnel",
  "navigation_bar.pins": "Messages épinglés",
  "navigation_bar.preferences": "Préférences",

M app/javascript/styles/mastodon/components.scss => app/javascript/styles/mastodon/components.scss +17 -0
@@ 2381,6 2381,7 @@ $ui-header-height: 55px;

  .filter-form {
    display: flex;
    flex-wrap: wrap;
  }

  .autosuggest-textarea__textarea {


@@ 3270,6 3271,22 @@ $ui-header-height: 55px;
  border-color: $ui-highlight-color;
}

.switch-to-advanced {
  color: $classic-primary-color;
  background-color: $classic-base-color;
  padding: 15px;
  border-radius: 4px;
  margin-top: 4px;
  margin-bottom: 12px;
  font-size: 13px;
  line-height: 18px;

  .switch-to-advanced__toggle {
    color: $ui-button-tertiary-color;
    font-weight: bold;
  }
}

.column-link {
  background: lighten($ui-base-color, 8%);
  color: $primary-text-color;

M app/lib/importer/accounts_index_importer.rb => app/lib/importer/accounts_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::AccountsIndexImporter < Importer::BaseImporter
  def import!
    scope.includes(:account_stat).find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |accounts|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: accounts).bulk_body
        bulk = build_bulk_body(accounts)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/importer/base_importer.rb => app/lib/importer/base_importer.rb +8 -0
@@ 68,6 68,14 @@ class Importer::BaseImporter

  protected

  def build_bulk_body(to_import)
    # Specialize `Chewy::Index::Import::BulkBuilder#bulk_body` to avoid a few
    # inefficiencies, as none of our fields or join fields and we do not need
    # `BulkBuilder`'s versatility.
    crutches = Chewy::Index::Crutch::Crutches.new index, to_import
    to_import.map { |object| { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } } }
  end

  def in_work_unit(...)
    work_unit = Concurrent::Promises.future_on(@executor, ...)


M app/lib/importer/instances_index_importer.rb => app/lib/importer/instances_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::InstancesIndexImporter < Importer::BaseImporter
  def import!
    index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |instances|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: instances).bulk_body
        bulk = build_bulk_body(instances)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/importer/public_statuses_index_importer.rb => app/lib/importer/public_statuses_index_importer.rb +3 -3
@@ 5,11 5,11 @@ class Importer::PublicStatusesIndexImporter < Importer::BaseImporter
    scope.select(:id).find_in_batches(batch_size: @batch_size) do |batch|
      in_work_unit(batch.pluck(:id)) do |status_ids|
        bulk = ActiveRecord::Base.connection_pool.with_connection do
          Chewy::Index::Import::BulkBuilder.new(index, to_index: Status.includes(:media_attachments, :preloadable_poll, :preview_cards).where(id: status_ids)).bulk_body
          build_bulk_body(index.adapter.default_scope.where(id: status_ids))
        end

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/importer/statuses_index_importer.rb => app/lib/importer/statuses_index_importer.rb +14 -21
@@ 13,32 13,25 @@ class Importer::StatusesIndexImporter < Importer::BaseImporter

      scope.find_in_batches(batch_size: @batch_size) do |tmp|
        in_work_unit(tmp.map(&:status_id)) do |status_ids|
          bulk = ActiveRecord::Base.connection_pool.with_connection do
            Chewy::Index::Import::BulkBuilder.new(index, to_index: index.adapter.default_scope.where(id: status_ids)).bulk_body
          end

          indexed = 0
          deleted = 0

          # We can't use the delete_if proc to do the filtering because delete_if
          # is called before rendering the data and we need to filter based
          # on the results of the filter, so this filtering happens here instead
          bulk.map! do |entry|
            new_entry = if entry[:index] && entry.dig(:index, :data, 'searchable_by').blank?
                          { delete: entry[:index].except(:data) }
                        else
                          entry
                        end

            if new_entry[:index]
              indexed += 1
            else
              deleted += 1
          bulk = ActiveRecord::Base.connection_pool.with_connection do
            to_index = index.adapter.default_scope.where(id: status_ids)
            crutches = Chewy::Index::Crutch::Crutches.new index, to_index
            to_index.map do |object|
              # This is unlikely to happen, but the post may have been
              # un-interacted with since it was queued for indexing
              if object.searchable_by.empty?
                deleted += 1
                { delete: { _id: object.id } }
              else
                { index: { _id: object.id, data: index.compose(object, crutches, fields: []) } }
              end
            end

            new_entry
          end

          indexed = bulk.size - deleted

          Chewy::Index::Import::BulkRequest.new(index).perform(bulk)

          [indexed, deleted]

M app/lib/importer/tags_index_importer.rb => app/lib/importer/tags_index_importer.rb +3 -3
@@ 4,10 4,10 @@ class Importer::TagsIndexImporter < Importer::BaseImporter
  def import!
    index.adapter.default_scope.find_in_batches(batch_size: @batch_size) do |tmp|
      in_work_unit(tmp) do |tags|
        bulk = Chewy::Index::Import::BulkBuilder.new(index, to_index: tags).bulk_body
        bulk = build_bulk_body(tags)

        indexed = bulk.count { |entry| entry[:index] }
        deleted = bulk.count { |entry| entry[:delete] }
        indexed = bulk.size
        deleted = 0

        Chewy::Index::Import::BulkRequest.new(index).perform(bulk)


M app/lib/search_query_parser.rb => app/lib/search_query_parser.rb +2 -2
@@ 6,10 6,10 @@ class SearchQueryParser < Parslet::Parser
  rule(:colon)     { str(':') }
  rule(:space)     { match('\s').repeat(1) }
  rule(:operator)  { (str('+') | str('-')).as(:operator) }
  rule(:prefix)    { (term >> colon).as(:prefix) }
  rule(:prefix)    { term >> colon }
  rule(:shortcode) { (colon >> term >> colon.maybe).as(:shortcode) }
  rule(:phrase)    { (quote >> (term >> space.maybe).repeat >> quote).as(:phrase) }
  rule(:clause)    { (operator.maybe >> prefix.maybe >> (phrase | term | shortcode)).as(:clause) }
  rule(:clause)    { (operator.maybe >> prefix.maybe.as(:prefix) >> (phrase | term | shortcode)).as(:clause) | prefix.as(:clause) | quote.as(:junk) }
  rule(:query)     { (clause >> space.maybe).repeat.as(:query) }
  root(:query)
end

M app/lib/search_query_transformer.rb => app/lib/search_query_transformer.rb +51 -50
@@ 1,50 1,32 @@
# frozen_string_literal: true

class SearchQueryTransformer < Parslet::Transform
  SUPPORTED_PREFIXES = %w(
    has
    is
    language
    from
    before
    after
    during
  ).freeze

  class Query
    attr_reader :should_clauses, :must_not_clauses, :must_clauses, :filter_clauses
    attr_reader :must_not_clauses, :must_clauses, :filter_clauses

    def initialize(clauses)
      grouped = clauses.chunk(&:operator).to_h
      @should_clauses = grouped.fetch(:should, [])
      grouped = clauses.compact.chunk(&:operator).to_h
      @must_not_clauses = grouped.fetch(:must_not, [])
      @must_clauses = grouped.fetch(:must, [])
      @filter_clauses = grouped.fetch(:filter, [])
    end

    def apply(search)
      should_clauses.each { |clause| search = search.query.should(clause_to_query(clause)) }
      must_clauses.each { |clause| search = search.query.must(clause_to_query(clause)) }
      must_not_clauses.each { |clause| search = search.query.must_not(clause_to_query(clause)) }
      filter_clauses.each { |clause| search = search.filter(**clause_to_filter(clause)) }
      must_clauses.each { |clause| search = search.query.must(clause.to_query) }
      must_not_clauses.each { |clause| search = search.query.must_not(clause.to_query) }
      filter_clauses.each { |clause| search = search.filter(**clause.to_query) }
      search.query.minimum_should_match(1)
    end

    private

    def clause_to_query(clause)
      case clause
      when TermClause
        { multi_match: { type: 'most_fields', query: clause.term, fields: ['text', 'text.stemmed'] } }
      when PhraseClause
        { match_phrase: { text: { query: clause.phrase } } }
      else
        raise "Unexpected clause type: #{clause}"
      end
    end

    def clause_to_filter(clause)
      case clause
      when PrefixClause
        if clause.negated?
          { bool: { must_not: { clause.type => { clause.filter => clause.term } } } }
        else
          { clause.type => { clause.filter => clause.term } }
        end
      else
        raise "Unexpected clause type: #{clause}"
      end
    end
  end

  class Operator


@@ 63,31 45,38 @@ class SearchQueryTransformer < Parslet::Transform
  end

  class TermClause
    attr_reader :prefix, :operator, :term
    attr_reader :operator, :term

    def initialize(prefix, operator, term)
      @prefix = prefix
    def initialize(operator, term)
      @operator = Operator.symbol(operator)
      @term = term
    end

    def to_query
      { multi_match: { type: 'most_fields', query: @term, fields: ['text', 'text.stemmed'], operator: 'and' } }
    end
  end

  class PhraseClause
    attr_reader :prefix, :operator, :phrase
    attr_reader :operator, :phrase

    def initialize(prefix, operator, phrase)
      @prefix = prefix
    def initialize(operator, phrase)
      @operator = Operator.symbol(operator)
      @phrase = phrase
    end

    def to_query
      { match_phrase: { text: { query: @phrase } } }
    end
  end

  class PrefixClause
    attr_reader :type, :filter, :operator, :term
    attr_reader :operator, :prefix, :term

    def initialize(prefix, operator, term, options = {})
      @negated  = operator == '-'
      @options  = options
      @prefix = prefix
      @negated = operator == '-'
      @options = options
      @operator = :filter

      case prefix


@@ 116,12 105,16 @@ class SearchQueryTransformer < Parslet::Transform
        @type = :range
        @term = { gte: term, lte: term, time_zone: @options[:current_account]&.user_time_zone || 'UTC' }
      else
        raise Mastodon::SyntaxError
        raise "Unknown prefix: #{prefix}"
      end
    end

    def negated?
      @negated
    def to_query
      if @negated
        { bool: { must_not: { @type => { @filter => @term } } } }
      else
        { @type => { @filter => @term } }
      end
    end

    private


@@ 159,18 152,26 @@ class SearchQueryTransformer < Parslet::Transform
    prefix   = clause[:prefix][:term].to_s if clause[:prefix]
    operator = clause[:operator]&.to_s

    if clause[:prefix]
    if clause[:prefix] && SUPPORTED_PREFIXES.include?(prefix)
      PrefixClause.new(prefix, operator, clause[:term].to_s, current_account: current_account)
    elsif clause[:prefix]
      TermClause.new(operator, "#{prefix} #{clause[:term]}")
    elsif clause[:term]
      TermClause.new(prefix, operator, clause[:term].to_s)
      TermClause.new(operator, clause[:term].to_s)
    elsif clause[:shortcode]
      TermClause.new(prefix, operator, ":#{clause[:term]}:")
      TermClause.new(operator, ":#{clause[:term]}:")
    elsif clause[:phrase]
      PhraseClause.new(prefix, operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
      PhraseClause.new(operator, clause[:phrase].is_a?(Array) ? clause[:phrase].map { |p| p[:term].to_s }.join(' ') : clause[:phrase].to_s)
    else
      raise "Unexpected clause type: #{clause}"
    end
  end

  rule(query: sequence(:clauses)) { Query.new(clauses) }
  rule(junk: subtree(:junk)) do
    nil
  end

  rule(query: sequence(:clauses)) do
    Query.new(clauses)
  end
end

M app/models/media_attachment.rb => app/models/media_attachment.rb +2 -0
@@ 100,6 100,8 @@ class MediaAttachment < ApplicationRecord
      output: {
        'loglevel' => 'fatal',
        'preset' => 'veryfast',
        'movflags' => 'faststart', # Move metadata to start of file so playback can begin before download finishes
        'pix_fmt' => 'yuv420p', # Ensure color space for cross-browser compatibility
        'c:v' => 'h264',
        'c:a' => 'aac',
        'b:a' => '192k',

M app/serializers/webfinger_serializer.rb => app/serializers/webfinger_serializer.rb +25 -12
@@ 18,18 18,31 @@ class WebfingerSerializer < ActiveModel::Serializer
  end

  def links
    if object.instance_actor?
      [
        { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: about_more_url(instance_actor: true) },
        { rel: 'self', type: 'application/activity+json', href: instance_actor_url },
        { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
      ]
    else
      [
        { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) },
        { rel: 'self', type: 'application/activity+json', href: account_url(object) },
        { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
      ]
    [
      { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profile_page_href },
      { rel: 'self', type: 'application/activity+json', href: self_href },
      { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" },
    ].tap do |x|
      x << { rel: 'http://webfinger.net/rel/avatar', type: object.avatar.content_type, href: full_asset_url(object.avatar_original_url) } if show_avatar?
    end
  end

  private

  def show_avatar?
    media_present = object.avatar.present? && object.avatar.content_type.present?

    # Show avatar only if an instance shows profiles to logged out users
    allowed_by_config = ENV['DISALLOW_UNAUTHENTICATED_API_ACCESS'] != 'true' && !Rails.configuration.x.limited_federation_mode

    media_present && allowed_by_config
  end

  def profile_page_href
    object.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(object)
  end

  def self_href
    object.instance_actor? ? instance_actor_url : account_url(object)
  end
end

M app/services/search_service.rb => app/services/search_service.rb +3 -1
@@ 1,8 1,10 @@
# frozen_string_literal: true

class SearchService < BaseService
  QUOTE_EQUIVALENT_CHARACTERS = /[“”„«»「」『』《》]/

  def call(query, account, limit, options = {})
    @query     = query&.strip
    @query     = query&.strip&.gsub(QUOTE_EQUIVALENT_CHARACTERS, '"')
    @account   = account
    @options   = options
    @limit     = limit.to_i

M app/workers/scheduler/indexing_scheduler.rb => app/workers/scheduler/indexing_scheduler.rb +1 -3
@@ 16,9 16,7 @@ class Scheduler::IndexingScheduler
    indexes.each do |type|
      with_redis do |redis|
        redis.sscan_each("chewy:queue:#{type.name}", count: SCAN_BATCH_SIZE).each_slice(IMPORT_BATCH_SIZE) do |ids|
          with_read_replica do
            type.import!(ids)
          end
          type.import!(ids)

          redis.srem("chewy:queue:#{type.name}", ids)
        end

M db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb => db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb +13 -1
@@ 15,10 15,22 @@ class AddUniqueIndexOnPreviewCardsStatuses < ActiveRecord::Migration[6.1]

  private

  def supports_concurrent_reindex?
    @supports_concurrent_reindex ||= begin
      version = select_one("SELECT current_setting('server_version_num') AS v")['v'].to_i
      version >= 12_000
    end
  end

  def deduplicate_and_reindex!
    deduplicate_preview_cards!

    safety_assured { execute 'REINDEX INDEX CONCURRENTLY preview_cards_statuses_pkey' }
    if supports_concurrent_reindex?
      safety_assured { execute 'REINDEX INDEX CONCURRENTLY preview_cards_statuses_pkey' }
    else
      remove_index :preview_cards_statuses, name: :preview_cards_statuses_pkey
      add_index :preview_cards_statuses, [:status_id, :preview_card_id], name: :preview_cards_statuses_pkey, algorithm: :concurrently, unique: true
    end
  rescue ActiveRecord::RecordNotUnique
    retry
  end

M spec/controllers/api/v1/timelines/tag_controller_spec.rb => spec/controllers/api/v1/timelines/tag_controller_spec.rb +48 -18
@@ 5,36 5,66 @@ require 'rails_helper'
describe Api::V1::Timelines::TagController do
  render_views

  let(:user) { Fabricate(:user) }
  let(:user)   { Fabricate(:user) }
  let(:token)  { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read:statuses') }

  before do
    allow(controller).to receive(:doorkeeper_token) { token }
  end

  context 'with a user context' do
    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id) }
  describe 'GET #show' do
    subject do
      get :show, params: { id: 'test' }
    end

    describe 'GET #show' do
      before do
        PostStatusService.new.call(user.account, text: 'It is a #test')
    before do
      PostStatusService.new.call(user.account, text: 'It is a #test')
    end

    context 'when the instance allows public preview' do
      context 'when the user is not authenticated' do
        let(:token) { nil }

        it 'returns http success', :aggregate_failures do
          subject

          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end

      it 'returns http success' do
        get :show, params: { id: 'test' }
        expect(response).to have_http_status(200)
        expect(response.headers['Link'].links.size).to eq(2)
      context 'when the user is authenticated' do
        it 'returns http success', :aggregate_failures do
          subject

          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end
    end
  end

  context 'without a user context' do
    let(:token) { Fabricate(:accessible_access_token, resource_owner_id: nil) }
    context 'when the instance does not allow public preview' do
      before do
        Form::AdminSettings.new(timeline_preview: false).save
      end

      context 'when the user is not authenticated' do
        let(:token) { nil }

        it 'returns http unauthorized' do
          subject

          expect(response).to have_http_status(401)
        end
      end

      context 'when the user is authenticated' do
        it 'returns http success', :aggregate_failures do
          subject

    describe 'GET #show' do
      it 'returns http success' do
        get :show, params: { id: 'test' }
        expect(response).to have_http_status(200)
        expect(response.headers['Link']).to be_nil
          expect(response).to have_http_status(200)
          expect(response.headers['Link'].links.size).to eq(2)
        end
      end
    end
  end

M spec/controllers/well_known/webfinger_controller_spec.rb => spec/controllers/well_known/webfinger_controller_spec.rb +64 -0
@@ 3,6 3,8 @@
require 'rails_helper'

describe WellKnown::WebfingerController do
  include RoutingHelper

  render_views

  describe 'GET #show' do


@@ 167,5 169,67 @@ describe WellKnown::WebfingerController do
        expect(response).to have_http_status(400)
      end
    end

    context 'when an account has an avatar' do
      let(:alice) { Fabricate(:account, username: 'alice', avatar: attachment_fixture('attachment.jpg')) }
      let(:resource) { alice.to_webfinger_s }

      it 'returns avatar in response' do
        perform_show!

        avatar_link = get_avatar_link(body_as_json)
        expect(avatar_link).to_not be_nil
        expect(avatar_link[:type]).to eq alice.avatar.content_type
        expect(avatar_link[:href]).to eq full_asset_url(alice.avatar)
      end

      context 'with limited federation mode' do
        before do
          allow(Rails.configuration.x).to receive(:limited_federation_mode).and_return(true)
        end

        it 'does not return avatar in response' do
          perform_show!

          avatar_link = get_avatar_link(body_as_json)
          expect(avatar_link).to be_nil
        end
      end

      context 'when enabling DISALLOW_UNAUTHENTICATED_API_ACCESS' do
        around do |example|
          ClimateControl.modify DISALLOW_UNAUTHENTICATED_API_ACCESS: 'true' do
            example.run
          end
        end

        it 'does not return avatar in response' do
          perform_show!

          avatar_link = get_avatar_link(body_as_json)
          expect(avatar_link).to be_nil
        end
      end
    end

    context 'when an account does not have an avatar' do
      let(:alice) { Fabricate(:account, username: 'alice', avatar: nil) }
      let(:resource) { alice.to_webfinger_s }

      before do
        perform_show!
      end

      it 'does not return avatar in response' do
        avatar_link = get_avatar_link(body_as_json)
        expect(avatar_link).to be_nil
      end
    end
  end

  private

  def get_avatar_link(json)
    json[:links].find { |link| link[:rel] == 'http://webfinger.net/rel/avatar' }
  end
end

A spec/lib/search_query_parser_spec.rb => spec/lib/search_query_parser_spec.rb +98 -0
@@ 0,0 1,98 @@
# frozen_string_literal: true

require 'rails_helper'
require 'parslet/rig/rspec'

describe SearchQueryParser do
  let(:parser) { described_class.new }

  context 'with term' do
    it 'consumes "hello"' do
      expect(parser.term).to parse('hello')
    end
  end

  context 'with prefix' do
    it 'consumes "foo:"' do
      expect(parser.prefix).to parse('foo:')
    end
  end

  context 'with operator' do
    it 'consumes "+"' do
      expect(parser.operator).to parse('+')
    end

    it 'consumes "-"' do
      expect(parser.operator).to parse('-')
    end
  end

  context 'with shortcode' do
    it 'consumes ":foo:"' do
      expect(parser.shortcode).to parse(':foo:')
    end
  end

  context 'with phrase' do
    it 'consumes "hello world"' do
      expect(parser.phrase).to parse('"hello world"')
    end
  end

  context 'with clause' do
    it 'consumes "foo"' do
      expect(parser.clause).to parse('foo')
    end

    it 'consumes "-foo"' do
      expect(parser.clause).to parse('-foo')
    end

    it 'consumes "foo:bar"' do
      expect(parser.clause).to parse('foo:bar')
    end

    it 'consumes "-foo:bar"' do
      expect(parser.clause).to parse('-foo:bar')
    end

    it 'consumes \'foo:"hello world"\'' do
      expect(parser.clause).to parse('foo:"hello world"')
    end

    it 'consumes \'-foo:"hello world"\'' do
      expect(parser.clause).to parse('-foo:"hello world"')
    end

    it 'consumes "foo:"' do
      expect(parser.clause).to parse('foo:')
    end

    it 'consumes \'"\'' do
      expect(parser.clause).to parse('"')
    end
  end

  context 'with query' do
    it 'consumes "hello -world"' do
      expect(parser.query).to parse('hello -world')
    end

    it 'consumes \'foo "hello world"\'' do
      expect(parser.query).to parse('foo "hello world"')
    end

    it 'consumes "foo:bar hello"' do
      expect(parser.query).to parse('foo:bar hello')
    end

    it 'consumes \'"hello" world "\'' do
      expect(parser.query).to parse('"hello" world "')
    end

    it 'consumes "foo:bar bar: hello"' do
      expect(parser.query).to parse('foo:bar bar: hello')
    end
  end
end

M spec/lib/search_query_transformer_spec.rb => spec/lib/search_query_transformer_spec.rb +49 -8
@@ 3,16 3,57 @@
require 'rails_helper'

describe SearchQueryTransformer do
  describe 'initialization' do
    let(:parser) { SearchQueryParser.new.parse('query') }
  subject { described_class.new.apply(parser, current_account: nil) }

    it 'sets attributes' do
      transformer = described_class.new.apply(parser)
  let(:parser) { SearchQueryParser.new.parse(query) }

      expect(transformer.should_clauses.first).to be_nil
      expect(transformer.must_clauses.first).to be_a(SearchQueryTransformer::TermClause)
      expect(transformer.must_not_clauses.first).to be_nil
      expect(transformer.filter_clauses.first).to be_nil
  context 'with "hello world"' do
    let(:query) { 'hello world' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello world)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "hello -world"' do
    let(:query) { 'hello -world' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello)
      expect(subject.must_not_clauses.map(&:term)).to match_array %w(world)
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "hello is:reply"' do
    let(:query) { 'hello is:reply' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(hello)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses.map(&:term)).to match_array %w(reply)
    end
  end

  context 'with "foo: bar"' do
    let(:query) { 'foo: bar' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to match_array %w(foo bar)
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end

  context 'with "foo:bar"' do
    let(:query) { 'foo:bar' }

    it 'transforms clauses' do
      expect(subject.must_clauses.map(&:term)).to contain_exactly('foo bar')
      expect(subject.must_not_clauses).to be_empty
      expect(subject.filter_clauses).to be_empty
    end
  end
end