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