diff --git a/.github/workflows/build-container-image.yml b/.github/workflows/build-container-image.yml
index 897bb9caaa..a1aeddf201 100644
--- a/.github/workflows/build-container-image.yml
+++ b/.github/workflows/build-container-image.yml
@@ -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 }}
diff --git a/.github/workflows/build-releases.yml b/.github/workflows/build-releases.yml
index fa923e9606..b9728f6a23 100644
--- a/.github/workflows/build-releases.yml
+++ b/.github/workflows/build-releases.yml
@@ -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: |
diff --git a/Gemfile.lock b/Gemfile.lock
index f1a61c5e09..4e30c42222 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb
index 8881b08f66..00db257ac7 100644
--- a/app/chewy/accounts_index.rb
+++ b/app/chewy/accounts_index.rb
@@ -34,7 +34,7 @@ class AccountsIndex < Chewy::Index
},
verbatim: {
- tokenizer: 'uax_url_email',
+ tokenizer: 'standard',
filter: %w(lowercase asciifolding cjk_width),
},
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 9cd7b99046..a79d65c124 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -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
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx
index 1c629bcbb4..848b812632 100644
--- a/app/javascript/mastodon/features/compose/components/search.jsx
+++ b/app/javascript/mastodon/features/compose/components/search.jsx
@@ -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 {
>
)}
-
+ {searchEnabled && (
+ <>
+
-
- {this.defaultOptions.map(({ key, label, action }, i) => (
-
- ))}
-
+
+ {this.defaultOptions.map(({ key, label, action }, i) => (
+
+ ))}
+
+ >
+ )}
);
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
index d36abf8f17..8006ca89a2 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
@@ -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 {
{signedIn && (
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 5871b08def..90bb9616f0 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -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",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 2bef3bb4b3..116ed66d03 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -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",
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index d53dced95a..f298788a52 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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;
diff --git a/app/lib/importer/accounts_index_importer.rb b/app/lib/importer/accounts_index_importer.rb
index fd869c3960..d8b9190275 100644
--- a/app/lib/importer/accounts_index_importer.rb
+++ b/app/lib/importer/accounts_index_importer.rb
@@ -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)
diff --git a/app/lib/importer/base_importer.rb b/app/lib/importer/base_importer.rb
index cc1b7b44d7..a21557d303 100644
--- a/app/lib/importer/base_importer.rb
+++ b/app/lib/importer/base_importer.rb
@@ -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, ...)
diff --git a/app/lib/importer/instances_index_importer.rb b/app/lib/importer/instances_index_importer.rb
index 7318b51b5d..ebdceb72ed 100644
--- a/app/lib/importer/instances_index_importer.rb
+++ b/app/lib/importer/instances_index_importer.rb
@@ -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)
diff --git a/app/lib/importer/public_statuses_index_importer.rb b/app/lib/importer/public_statuses_index_importer.rb
index 72d02318b1..ebaac3794f 100644
--- a/app/lib/importer/public_statuses_index_importer.rb
+++ b/app/lib/importer/public_statuses_index_importer.rb
@@ -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)
diff --git a/app/lib/importer/statuses_index_importer.rb b/app/lib/importer/statuses_index_importer.rb
index 285ddc871a..08ad3e3797 100644
--- a/app/lib/importer/statuses_index_importer.rb
+++ b/app/lib/importer/statuses_index_importer.rb
@@ -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]
diff --git a/app/lib/importer/tags_index_importer.rb b/app/lib/importer/tags_index_importer.rb
index 77710ed7de..067fd8cd2d 100644
--- a/app/lib/importer/tags_index_importer.rb
+++ b/app/lib/importer/tags_index_importer.rb
@@ -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)
diff --git a/app/lib/search_query_parser.rb b/app/lib/search_query_parser.rb
index 5d6ffbf29d..1c57b9b024 100644
--- a/app/lib/search_query_parser.rb
+++ b/app/lib/search_query_parser.rb
@@ -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
diff --git a/app/lib/search_query_transformer.rb b/app/lib/search_query_transformer.rb
index 86e3f50005..e81c0c081e 100644
--- a/app/lib/search_query_transformer.rb
+++ b/app/lib/search_query_transformer.rb
@@ -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
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 23d2a33048..9c299e7ae1 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -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',
diff --git a/app/serializers/webfinger_serializer.rb b/app/serializers/webfinger_serializer.rb
index 3ca3441169..b67cd2771a 100644
--- a/app/serializers/webfinger_serializer.rb
+++ b/app/serializers/webfinger_serializer.rb
@@ -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
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 40d82fc525..9a40d7bdd5 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -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
diff --git a/app/workers/scheduler/indexing_scheduler.rb b/app/workers/scheduler/indexing_scheduler.rb
index 1b09730c7d..ff1b744442 100644
--- a/app/workers/scheduler/indexing_scheduler.rb
+++ b/app/workers/scheduler/indexing_scheduler.rb
@@ -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
diff --git a/db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb b/db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb
index c35ad80028..3e9ab134b7 100644
--- a/db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb
+++ b/db/post_migrate/20230803082451_add_unique_index_on_preview_cards_statuses.rb
@@ -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
diff --git a/spec/controllers/api/v1/timelines/tag_controller_spec.rb b/spec/controllers/api/v1/timelines/tag_controller_spec.rb
index 7189110833..1c60798fcf 100644
--- a/spec/controllers/api/v1/timelines/tag_controller_spec.rb
+++ b/spec/controllers/api/v1/timelines/tag_controller_spec.rb
@@ -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
- 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
+ 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
+
+ expect(response).to have_http_status(200)
+ expect(response.headers['Link'].links.size).to eq(2)
+ end
end
end
end
diff --git a/spec/controllers/well_known/webfinger_controller_spec.rb b/spec/controllers/well_known/webfinger_controller_spec.rb
index 8dc0f329b6..20770a7211 100644
--- a/spec/controllers/well_known/webfinger_controller_spec.rb
+++ b/spec/controllers/well_known/webfinger_controller_spec.rb
@@ -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
diff --git a/spec/lib/search_query_parser_spec.rb b/spec/lib/search_query_parser_spec.rb
new file mode 100644
index 0000000000..66b0e8f9e2
--- /dev/null
+++ b/spec/lib/search_query_parser_spec.rb
@@ -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
diff --git a/spec/lib/search_query_transformer_spec.rb b/spec/lib/search_query_transformer_spec.rb
index 953f9acb2f..17f06d2833 100644
--- a/spec/lib/search_query_transformer_spec.rb
+++ b/spec/lib/search_query_transformer_spec.rb
@@ -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