diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 07cb1d41f8..ff135867f9 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -107,6 +107,10 @@ jobs: PAM_ENABLED: true PAM_DEFAULT_SERVICE: pam_test PAM_CONTROLLED_SERVICE: pam_test_controlled + OIDC_ENABLED: true + OIDC_SCOPE: read + SAML_ENABLED: true + CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' CI_JOBS: ${{ matrix.ci_job }}/4 @@ -149,3 +153,100 @@ jobs: run: './bin/rails db:create db:schema:load db:seed' - run: bundle exec rake rspec_chunked + + test-e2e: + name: End to End testing + runs-on: ubuntu-latest + + needs: + - build + + services: + postgres: + image: postgres:14-alpine + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + env: + DB_HOST: localhost + DB_USER: postgres + DB_PASS: postgres + DISABLE_SIMPLECOV: true + RAILS_ENV: test + BUNDLE_WITH: test + + strategy: + fail-fast: false + matrix: + ruby-version: + - '3.0' + - '3.1' + - '.ruby-version' + + steps: + - uses: actions/checkout@v3 + + - uses: actions/download-artifact@v3 + with: + path: './public' + name: ${{ github.sha }} + + - name: Update package index + run: sudo apt-get update + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + cache: yarn + node-version-file: '.nvmrc' + + - name: Install native Ruby dependencies + run: sudo apt-get install -y libicu-dev libidn11-dev + + - name: Install additional system dependencies + run: sudo apt-get install -y ffmpeg imagemagick + + - name: Set up bundler cache + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version}} + bundler-cache: true + + - run: yarn --frozen-lockfile + + - name: Load database schema + run: './bin/rails db:create db:schema:load db:seed' + + - run: bundle exec rake spec:system + + - name: Archive logs + uses: actions/upload-artifact@v3 + if: failure() + with: + name: e2e-logs-${{ matrix.ruby-version }} + path: log/ + + - name: Archive test screenshots + uses: actions/upload-artifact@v3 + if: failure() + with: + name: e2e-screenshots + path: tmp/screenshots/ diff --git a/.rubocop.yml b/.rubocop.yml index 051ad9fa97..e022754eab 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -38,14 +38,7 @@ Layout/FirstHashElementIndentation: # Reason: Currently disabled in .rubocop_todo.yml # https://docs.rubocop.org/rubocop/cops_layout.html#layoutlinelength Layout/LineLength: - AllowedPatterns: - # Allow comments to be long lines - - !ruby/regexp / \# .*$/ - - !ruby/regexp /^\# .*$/ - Exclude: - - 'lib/mastodon/cli/*.rb' - - db/*migrate/**/* - - db/seeds/**/* + Max: 320 # Default of 120 causes a duplicate entry in generated todo file # Reason: # https://docs.rubocop.org/rubocop/cops_lint.html#lintuselessaccessmodifier @@ -141,12 +134,6 @@ RSpec/FilePath: Exclude: - 'spec/config/initializers/rack_attack_spec.rb' # namespaces usually have separate folder - 'spec/lib/sanitize_config_spec.rb' # namespaces usually have separate folder - - 'spec/controllers/concerns/account_controller_concern_spec.rb' # Concerns describe ApplicationController and don't fit naming - - 'spec/controllers/concerns/export_controller_concern_spec.rb' - - 'spec/controllers/concerns/localized_spec.rb' - - 'spec/controllers/concerns/rate_limit_headers_spec.rb' - - 'spec/controllers/concerns/signature_verification_spec.rb' - - 'spec/controllers/concerns/user_tracking_concern_spec.rb' # Reason: # https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnamedsubject diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 5ebeca8802..cabfcd92e0 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -39,6 +39,13 @@ Layout/LeadingCommentSpace: - 'config/application.rb' - 'config/initializers/omniauth.rb' +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Max, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. +# URISchemes: http, https +Layout/LineLength: + Exclude: + - 'app/models/account.rb' + # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyle. # SupportedStyles: require_no_space, require_space @@ -112,7 +119,6 @@ Lint/UselessAssignment: - 'config/initializers/omniauth.rb' - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - 'db/post_migrate/20190511152737_remove_suspended_silenced_account_fields.rb' - - 'spec/controllers/api/v1/bookmarks_controller_spec.rb' - 'spec/controllers/api/v1/favourites_controller_spec.rb' - 'spec/controllers/concerns/account_controller_concern_spec.rb' - 'spec/helpers/jsonld_helper_spec.rb' @@ -129,7 +135,7 @@ Lint/UselessAssignment: # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 150 + Max: 143 # Configuration parameters: CountBlocks, Max. Metrics/BlockNesting: @@ -144,13 +150,6 @@ Metrics/CyclomaticComplexity: Metrics/PerceivedComplexity: Max: 27 -# Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. -# CheckDefinitionPathHierarchyRoots: lib, spec, test, src -# AllowedAcronyms: CLI, DSL, ACL, API, ASCII, CPU, CSS, DNS, EOF, GUID, HTML, HTTP, HTTPS, ID, IP, JSON, LHS, QPS, RAM, RHS, RPC, SLA, SMTP, SQL, SSH, TCP, TLS, TTL, UDP, UI, UID, UUID, URI, URL, UTF8, VM, XML, XMPP, XSRF, XSS -Naming/FileName: - Exclude: - - 'config/locales/sr-Latn.rb' - # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. # SupportedStyles: snake_case, normalcase, non_integer # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 @@ -165,14 +164,6 @@ Naming/VariableNumber: - 'spec/models/domain_block_spec.rb' - 'spec/models/user_spec.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -Performance/UnfreezeString: - Exclude: - - 'app/lib/rss/builder.rb' - - 'app/lib/text_formatter.rb' - - 'app/validators/status_length_validator.rb' - - 'lib/tasks/mastodon.rake' - RSpec/AnyInstance: Exclude: - 'spec/controllers/activitypub/inboxes_controller_spec.rb' @@ -192,41 +183,6 @@ RSpec/AnyInstance: - 'spec/workers/activitypub/delivery_worker_spec.rb' - 'spec/workers/web/push_notification_worker_spec.rb' -# This cop supports unsafe autocorrection (--autocorrect-all). -RSpec/EmptyExampleGroup: - Exclude: - - 'spec/helpers/admin/action_logs_helper_spec.rb' - - 'spec/models/account_alias_spec.rb' - - 'spec/models/account_deletion_request_spec.rb' - - 'spec/models/account_moderation_note_spec.rb' - - 'spec/models/announcement_mute_spec.rb' - - 'spec/models/announcement_reaction_spec.rb' - - 'spec/models/announcement_spec.rb' - - 'spec/models/backup_spec.rb' - - 'spec/models/conversation_mute_spec.rb' - - 'spec/models/custom_filter_keyword_spec.rb' - - 'spec/models/custom_filter_spec.rb' - - 'spec/models/device_spec.rb' - - 'spec/models/encrypted_message_spec.rb' - - 'spec/models/featured_tag_spec.rb' - - 'spec/models/follow_recommendation_suppression_spec.rb' - - 'spec/models/list_account_spec.rb' - - 'spec/models/list_spec.rb' - - 'spec/models/login_activity_spec.rb' - - 'spec/models/mute_spec.rb' - - 'spec/models/preview_card_spec.rb' - - 'spec/models/preview_card_trend_spec.rb' - - 'spec/models/relay_spec.rb' - - 'spec/models/scheduled_status_spec.rb' - - 'spec/models/status_stat_spec.rb' - - 'spec/models/status_trend_spec.rb' - - 'spec/models/system_key_spec.rb' - - 'spec/models/tag_follow_spec.rb' - - 'spec/models/unavailable_domain_spec.rb' - - 'spec/models/user_invite_request_spec.rb' - - 'spec/models/web/setting_spec.rb' - - 'spec/services/unmute_service_spec.rb' - # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 22 @@ -279,7 +235,6 @@ RSpec/LetSetup: - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' - 'spec/controllers/api/v2/filters/keywords_controller_spec.rb' - 'spec/controllers/api/v2/filters/statuses_controller_spec.rb' - - 'spec/controllers/api/v2/filters_controller_spec.rb' - 'spec/controllers/auth/confirmations_controller_spec.rb' - 'spec/controllers/auth/passwords_controller_spec.rb' - 'spec/controllers/auth/sessions_controller_spec.rb' @@ -360,43 +315,6 @@ Rails/ApplicationController: Exclude: - 'app/controllers/health_controller.rb' -# Configuration parameters: Database, Include. -# SupportedDatabases: mysql, postgresql -# Include: db/**/*.rb -Rails/BulkChangeTable: - Exclude: - - 'db/migrate/20160222143943_add_profile_fields_to_accounts.rb' - - 'db/migrate/20160223162837_add_metadata_to_statuses.rb' - - 'db/migrate/20160305115639_add_devise_to_users.rb' - - 'db/migrate/20160314164231_add_owner_to_application.rb' - - 'db/migrate/20160926213048_remove_owner_from_application.rb' - - 'db/migrate/20161003142332_add_confirmable_to_users.rb' - - 'db/migrate/20170112154826_migrate_settings.rb' - - 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb' - - 'db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb' - - 'db/migrate/20170330021336_add_counter_caches.rb' - - 'db/migrate/20170425202925_add_oembed_to_preview_cards.rb' - - 'db/migrate/20170427011934_re_add_owner_to_application.rb' - - 'db/migrate/20170520145338_change_language_filter_to_opt_out.rb' - - 'db/migrate/20170624134742_add_description_to_session_activations.rb' - - 'db/migrate/20170718211102_add_activitypub_to_accounts.rb' - - 'db/migrate/20171006142024_add_uri_to_custom_emojis.rb' - - 'db/migrate/20180812123222_change_relays_enabled.rb' - - 'db/migrate/20190511134027_add_silenced_at_suspended_at_to_accounts.rb' - - 'db/migrate/20190805123746_add_capabilities_to_tags.rb' - - 'db/migrate/20190807135426_add_comments_to_domain_blocks.rb' - - 'db/migrate/20190815225426_add_last_status_at_to_tags.rb' - - 'db/migrate/20190901035623_add_max_score_to_tags.rb' - - 'db/migrate/20200417125749_add_storage_schema_version.rb' - - 'db/migrate/20200608113046_add_sign_in_token_to_users.rb' - - 'db/migrate/20211112011713_add_language_to_preview_cards.rb' - - 'db/migrate/20211231080958_add_category_to_reports.rb' - - 'db/migrate/20220202200743_add_trendable_to_accounts.rb' - - 'db/migrate/20220224010024_add_ips_to_email_domain_blocks.rb' - - 'db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb' - - 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb' - - 'db/migrate/20220824164433_add_human_identifier_to_admin_action_logs.rb' - # Configuration parameters: Include. # Include: db/**/*.rb Rails/CreateTableWithTimestamps: @@ -839,8 +757,6 @@ Style/RedundantConstantBase: Exclude: - 'config/environments/production.rb' - 'config/initializers/sidekiq.rb' - - 'config/locales/sr-Latn.rb' - - 'config/locales/sr.rb' # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: SafeForConstants. @@ -943,9 +859,3 @@ Style/WordArray: - 'config/initializers/cors.rb' - 'spec/controllers/settings/imports_controller_spec.rb' - 'spec/models/form/import_spec.rb' - -# This cop supports safe autocorrection (--autocorrect). -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, AllowedPatterns. -# URISchemes: http, https -Layout/LineLength: - Max: 701 diff --git a/CHANGELOG.md b/CHANGELOG.md index c49b192735..9fe1ac3803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project will be documented in this file. +## [4.1.6] - 2023-07-31 + +### Fixed + +- Fix memory leak in streaming server ([ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26228)) +- Fix wrong filters sometimes applying in streaming ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26159), [ThisIsMissEm](https://github.com/mastodon/mastodon/pull/26213), [renchap](https://github.com/mastodon/mastodon/pull/26233)) +- Fix incorrect connect timeout in outgoing requests ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/26116)) + ## [4.1.5] - 2023-07-21 ### Added diff --git a/Gemfile b/Gemfile index fcd10c5f9b..478d2cfe99 100644 --- a/Gemfile +++ b/Gemfile @@ -99,9 +99,6 @@ gem 'rdf-normalize', '~> 0.5' gem 'private_address_check', '~> 0.5' group :test do - # RSpec runner for rails - gem 'rspec-rails', '~> 6.0' - # Used to split testing into chunks in CI gem 'rspec_chunked', '~> 0.6' @@ -113,6 +110,10 @@ group :test do # Browser integration testing gem 'capybara', '~> 3.39' + gem 'selenium-webdriver' + + # Used to reset the database between system tests + gem 'database_cleaner-active_record' # Used to mock environment variables gem 'climate_control', '~> 0.2' @@ -173,10 +174,19 @@ group :development do # Validate missing i18n keys gem 'i18n-tasks', '~> 1.0', require: false +end +group :development, :test do # Profiling tools gem 'memory_profiler', require: false + gem 'ruby-prof', require: false gem 'stackprof', require: false + gem 'test-prof' +end + +group :development, :test do + # RSpec runner for rails + gem 'rspec-rails', '~> 6.0' end group :production do diff --git a/Gemfile.lock b/Gemfile.lock index d308192e8b..151a372017 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -199,6 +199,10 @@ GEM crass (1.0.6) css_parser (1.14.0) addressable + database_cleaner-active_record (2.1.0) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0.0) + database_cleaner-core (2.0.1) date (3.3.3) debug_inspector (1.1.0) devise (4.9.2) @@ -307,7 +311,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.49.1) + haml_lint (0.49.2) haml (>= 4.0, < 6.2) parallel (~> 1.10) rainbow @@ -583,7 +587,7 @@ GEM responders (3.1.0) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.5) + rexml (3.2.6) rotp (6.2.2) rouge (4.1.2) rpam2 (4.0.2) @@ -640,6 +644,7 @@ GEM rubocop (~> 1.33) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) + ruby-prof (1.6.3) ruby-progressbar (1.13.0) ruby-saml (1.15.0) nokogiri (>= 1.13.10) @@ -656,6 +661,10 @@ GEM scenic (1.7.0) activerecord (>= 4.0.0) railties (>= 4.0.0) + selenium-webdriver (4.9.1) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) semantic_range (3.0.0) sidekiq (6.5.9) connection_pool (>= 2.2.5, < 3) @@ -710,6 +719,7 @@ GEM unicode-display_width (>= 1.1.1, < 3) terrapin (0.6.0) climate_control (>= 0.0.3, < 1.0) + test-prof (1.2.1) thor (1.2.2) tilt (2.2.0) timeout (0.4.0) @@ -768,6 +778,7 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) + websocket (1.2.9) websocket-driver (0.7.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -804,6 +815,7 @@ DEPENDENCIES color_diff (~> 0.1) concurrent-ruby connection_pool + database_cleaner-active_record devise (~> 4.9) devise-two-factor (~> 4.1) devise_pam_authenticatable2 (~> 9.2) @@ -881,10 +893,12 @@ DEPENDENCIES rubocop-performance rubocop-rails rubocop-rspec + ruby-prof ruby-progressbar (~> 1.13) rubyzip (~> 2.3) sanitize (~> 6.0) scenic (~> 1.7) + selenium-webdriver sidekiq (~> 6.5) sidekiq-bulk (~> 0.2.0) sidekiq-scheduler (~> 5.0) @@ -897,6 +911,7 @@ DEPENDENCIES stackprof stoplight (~> 3.0.1) strong_migrations (~> 0.8) + test-prof thor (~> 1.2) tty-prompt (~> 0.23) twitter-text (~> 3.1.0) diff --git a/app/chewy/instances_index.rb b/app/chewy/instances_index.rb new file mode 100644 index 0000000000..2bce4043c9 --- /dev/null +++ b/app/chewy/instances_index.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class InstancesIndex < Chewy::Index + settings index: { refresh_interval: '30s' } + + index_scope ::Instance.searchable + + root date_detection: false do + field :domain, type: 'text', index_prefixes: { min_chars: 1 } + field :accounts_count, type: 'long' + end +end diff --git a/app/controllers/api/v1/instances/peers_controller.rb b/app/controllers/api/v1/instances/peers_controller.rb index 70281362a8..23096650e6 100644 --- a/app/controllers/api/v1/instances/peers_controller.rb +++ b/app/controllers/api/v1/instances/peers_controller.rb @@ -15,7 +15,7 @@ class Api::V1::Instances::PeersController < Api::BaseController def index cache_even_if_authenticated! - render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) } + render_with_cache(expires_in: 1.day) { Instance.searchable.pluck(:domain) } end private diff --git a/app/controllers/api/v1/peers/search_controller.rb b/app/controllers/api/v1/peers/search_controller.rb new file mode 100644 index 0000000000..50a342cde3 --- /dev/null +++ b/app/controllers/api/v1/peers/search_controller.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class Api::V1::Peers::SearchController < Api::BaseController + before_action :require_enabled_api! + before_action :set_domains + + skip_before_action :require_authenticated_user!, unless: :whitelist_mode? + skip_around_action :set_locale + + vary_by '' + + def index + cache_even_if_authenticated! + render json: @domains + end + + private + + def require_enabled_api! + head 404 unless Setting.peers_api_enabled && !whitelist_mode? + end + + def set_domains + return if params[:q].blank? + + if Chewy.enabled? + @domains = InstancesIndex.query(function_score: { + query: { + prefix: { + domain: params[:q], + }, + }, + + field_value_factor: { + field: 'accounts_count', + modifier: 'log2p', + }, + }).limit(10).pluck(:domain) + else + domain = params[:q].strip + domain = TagManager.instance.normalize_domain(domain) + @domains = Instance.searchable.where(Instance.arel_table[:domain].matches("#{Instance.sanitize_sql_like(domain)}%", false, true)).limit(10).pluck(:domain) + end + end +end diff --git a/app/controllers/auth/omniauth_callbacks_controller.rb b/app/controllers/auth/omniauth_callbacks_controller.rb index 9e0fb942aa..4723806b92 100644 --- a/app/controllers/auth/omniauth_callbacks_controller.rb +++ b/app/controllers/auth/omniauth_callbacks_controller.rb @@ -5,21 +5,13 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController def self.provides_callback_for(provider) define_method provider do + @provider = provider @user = User.find_for_oauth(request.env['omniauth.auth'], current_user) if @user.persisted? - LoginActivity.create( - user: @user, - success: true, - authentication_method: :omniauth, - provider: provider, - ip: request.remote_ip, - user_agent: request.user_agent - ) - + record_login_activity sign_in_and_redirect @user, event: :authentication - label = Devise.omniauth_configs[provider]&.strategy&.display_name.presence || I18n.t("auth.providers.#{provider}", default: provider.to_s.chomp('_oauth2').capitalize) - set_flash_message(:notice, :success, kind: label) if is_navigational_format? + set_flash_message(:notice, :success, kind: label_for_provider) if is_navigational_format? else session["devise.#{provider}_data"] = request.env['omniauth.auth'] redirect_to new_user_registration_url @@ -38,4 +30,29 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController auth_setup_path(missing_email: '1') end end + + private + + def record_login_activity + LoginActivity.create( + user: @user, + success: true, + authentication_method: :omniauth, + provider: @provider, + ip: request.remote_ip, + user_agent: request.user_agent + ) + end + + def label_for_provider + provider_display_name || configured_provider_name + end + + def provider_display_name + Devise.omniauth_configs[@provider]&.strategy&.display_name.presence + end + + def configured_provider_name + I18n.t("auth.providers.#{@provider}", default: @provider.to_s.chomp('_oauth2').capitalize) + end end diff --git a/app/controllers/authorize_interactions_controller.rb b/app/controllers/authorize_interactions_controller.rb index bf28d18423..99eed018b0 100644 --- a/app/controllers/authorize_interactions_controller.rb +++ b/app/controllers/authorize_interactions_controller.rb @@ -3,32 +3,19 @@ class AuthorizeInteractionsController < ApplicationController include Authorization - layout 'modal' - before_action :authenticate_user! - before_action :set_body_classes before_action :set_resource def show if @resource.is_a?(Account) - render :show + redirect_to web_url("@#{@resource.pretty_acct}") elsif @resource.is_a?(Status) redirect_to web_url("@#{@resource.account.pretty_acct}/#{@resource.id}") else - render :error + not_found end end - def create - if @resource.is_a?(Account) && FollowService.new.call(current_account, @resource, with_rate_limit: true) - render :success - else - render :error - end - rescue ActiveRecord::RecordNotFound - render :error - end - private def set_resource @@ -61,8 +48,4 @@ class AuthorizeInteractionsController < ApplicationController def uri_param params[:uri] || params.fetch(:acct, '').delete_prefix('acct:') end - - def set_body_classes - @body_classes = 'modal-layout' - end end diff --git a/app/controllers/backups_controller.rb b/app/controllers/backups_controller.rb index 205df48d44..db23fefbbc 100644 --- a/app/controllers/backups_controller.rb +++ b/app/controllers/backups_controller.rb @@ -10,7 +10,7 @@ class BackupsController < ApplicationController def download case Paperclip::Attachment.default_options[:storage] - when :s3 + when :s3, :azure redirect_to @backup.dump.expiring_url(10), allow_other_host: true when :fog if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? diff --git a/app/controllers/remote_interaction_helper_controller.rb b/app/controllers/remote_interaction_helper_controller.rb new file mode 100644 index 0000000000..90c853f47b --- /dev/null +++ b/app/controllers/remote_interaction_helper_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class RemoteInteractionHelperController < ApplicationController + vary_by '' + + skip_before_action :require_functional! + skip_around_action :set_locale + skip_before_action :update_user_sign_in + + content_security_policy do |p| + # We inherit the normal `script-src` + + # Set every directive that does not have a fallback + p.default_src :none + p.form_action :none + p.base_uri :none + + # Disable every directive with a fallback to cut on response size + p.base_uri false + p.font_src false + p.img_src false + p.style_src false + p.media_src false + p.frame_src false + p.manifest_src false + p.connect_src false + p.child_src false + p.worker_src false + + # Widen the directives that we do need + p.frame_ancestors :self + p.connect_src :https + end + + def index + expires_in(5.minutes, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) + + response.headers['X-Frame-Options'] = 'SAMEORIGIN' + response.headers['Referrer-Policy'] = 'no-referrer' + + render layout: 'helper_frame' + end +end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 0d897e8e24..4748940f7c 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -19,6 +19,7 @@ module WellKnown def set_account username = username_from_resource + @account = begin if username == Rails.configuration.x.local_domain Account.representative diff --git a/app/helpers/context_helper.rb b/app/helpers/context_helper.rb index aa9636db9e..51338688f4 100644 --- a/app/helpers/context_helper.rb +++ b/app/helpers/context_helper.rb @@ -26,7 +26,14 @@ module ContextHelper subscribable_by: { 'kmyblue' => 'http://kmy.blue/ns#', 'subscribableBy' => { '@id' => 'kmyblue:subscribableBy', '@type' => '@id' } }, other_setting: { 'fedibird' => 'http://fedibird.com/ns#', 'otherSetting' => 'fedibird:otherSetting' }, references: { 'fedibird' => 'http://fedibird.com/ns#', 'references' => { '@id' => 'fedibird:references', '@type' => '@id' } }, - olm: { 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' }, + olm: { + 'toot' => 'http://joinmastodon.org/ns#', 'Device' => 'toot:Device', 'Ed25519Signature' => 'toot:Ed25519Signature', 'Ed25519Key' => 'toot:Ed25519Key', 'Curve25519Key' => 'toot:Curve25519Key', 'EncryptedMessage' => 'toot:EncryptedMessage', 'publicKeyBase64' => 'toot:publicKeyBase64', 'deviceId' => 'toot:deviceId', + 'claim' => { '@type' => '@id', '@id' => 'toot:claim' }, + 'fingerprintKey' => { '@type' => '@id', '@id' => 'toot:fingerprintKey' }, + 'identityKey' => { '@type' => '@id', '@id' => 'toot:identityKey' }, + 'devices' => { '@type' => '@id', '@id' => 'toot:devices' }, + 'messageFranking' => 'toot:messageFranking', 'messageType' => 'toot:messageType', 'cipherText' => 'toot:cipherText' + }, suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' }, }.freeze diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 840a18d3e6..1f400b477a 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -204,7 +204,17 @@ module LanguagesHelper zgh: ['Standard Moroccan Tamazight', 'ⵜⴰⵎⴰⵣⵉⵖⵜ'].freeze, }.freeze - SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_3).freeze + # e.g. For Chinese, which is not a language, + # but a language family in spite of sharing the main locale code + # We need to be able to filter these + ISO_639_1_REGIONAL = { + 'zh-CN': ['Chinese (China)', '简体中文'].freeze, + 'zh-HK': ['Chinese (Hong Kong)', '繁體中文(香港)'].freeze, + 'zh-TW': ['Chinese (Taiwan)', '繁體中文(臺灣)'].freeze, + 'zh-YUE': ['Cantonese', '廣東話'].freeze, + }.freeze + + SUPPORTED_LOCALES = {}.merge(ISO_639_1).merge(ISO_639_1_REGIONAL).merge(ISO_639_3).freeze # For ISO-639-1 and ISO-639-3 language codes, we have their official # names, but for some translations, we need the names of the @@ -217,9 +227,6 @@ module LanguagesHelper 'pt-BR': 'Português (Brasil)', 'pt-PT': 'Português (Portugal)', 'sr-Latn': 'Srpski (latinica)', - 'zh-CN': '简体中文', - 'zh-HK': '繁體中文(香港)', - 'zh-TW': '繁體中文(臺灣)', }.freeze def native_locale_name(locale) diff --git a/app/javascript/mastodon/actions/importer/index.js b/app/javascript/mastodon/actions/importer/index.js index 9c69be601e..369be6b8fb 100644 --- a/app/javascript/mastodon/actions/importer/index.js +++ b/app/javascript/mastodon/actions/importer/index.js @@ -81,7 +81,7 @@ export function importFetchedStatuses(statuses) { } if (status.poll && status.poll.id) { - pushUnique(polls, normalizePoll(status.poll)); + pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); } } @@ -95,7 +95,7 @@ export function importFetchedStatuses(statuses) { } export function importFetchedPoll(poll) { - return dispatch => { - dispatch(importPolls([normalizePoll(poll)])); + return (dispatch, getState) => { + dispatch(importPolls([normalizePoll(poll, getState().getIn(['polls', poll.id]))])); }; } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 7ad5992ada..7fcd0e5f11 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -84,6 +84,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.translation = normalOldStatus.get('translation'); } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. @@ -106,6 +107,18 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; } + if (normalOldStatus) { + const list = normalOldStatus.get('media_attachments'); + if (normalStatus.media_attachments && list) { + normalStatus.media_attachments.forEach(item => { + const oldItem = list.find(i => i.get('id') === item.id); + if (oldItem && oldItem.get('description') === item.description) { + item.translation = oldItem.get('translation') + } + }); + } + } + return normalStatus; } @@ -135,15 +148,23 @@ export function normalizeStatusTranslation(translation, status) { return normalTranslation; } -export function normalizePoll(poll) { +export function normalizePoll(poll, normalOldPoll) { const normalPoll = { ...poll }; const emojiMap = makeEmojiMap(poll.emojis); - normalPoll.options = poll.options.map((option, index) => ({ - ...option, - voted: poll.own_votes && poll.own_votes.includes(index), - titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), - })); + normalPoll.options = poll.options.map((option, index) => { + const normalOption = { + ...option, + voted: poll.own_votes && poll.own_votes.includes(index), + titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap), + } + + if (normalOldPoll && normalOldPoll.getIn(['options', index, 'title']) === option.title) { + normalOption.translation = normalOldPoll.getIn(['options', index, 'translation']); + } + + return normalOption + }); return normalPoll; } diff --git a/app/javascript/mastodon/containers/status_container.jsx b/app/javascript/mastodon/containers/status_container.jsx index 93356f1ca3..18c99385f5 100644 --- a/app/javascript/mastodon/containers/status_container.jsx +++ b/app/javascript/mastodon/containers/status_container.jsx @@ -297,7 +297,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ modalProps: { type, accountId: status.getIn(['account', 'id']), - url: status.get('url'), + url: status.get('uri'), }, })); }, diff --git a/app/javascript/mastodon/features/account/components/header.jsx b/app/javascript/mastodon/features/account/components/header.jsx index fbb5e4c142..2513c533df 100644 --- a/app/javascript/mastodon/features/account/components/header.jsx +++ b/app/javascript/mastodon/features/account/components/header.jsx @@ -373,16 +373,33 @@ class Header extends ImmutablePureComponent { const acct = isLocal && domain ? `${account.get('acct')}@${domain}` : account.get('acct'); const isIndexable = !account.get('noindex'); - let badge; + const badges = []; if (account.get('bot')) { - badge = (
); + badges.push( +
+ { ' ' } + +
+ ); } else if (account.get('group')) { - badge = (
); - } else { - badge = null; + badges.push( +
+ { ' ' } + +
+ ); } + account.get('roles', []).forEach((role) => { + badges.push( +
+ { ' ' } + {role.get('name')} ({domain}) +
+ ); + }); + return (
{!(suspended || hidden || account.get('moved')) && account.getIn(['relationship', 'requested_by']) && } @@ -417,13 +434,19 @@ class Header extends ImmutablePureComponent {

- {badge} + @{acct} {lockedIcon}

+ {badges.length > 0 && ( +
+ {badges} +
+ )} + {!(suspended || hidden) && (
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.jsx b/app/javascript/mastodon/features/account_gallery/components/media_item.jsx index f60be5d54d..63fbac6799 100644 --- a/app/javascript/mastodon/features/account_gallery/components/media_item.jsx +++ b/app/javascript/mastodon/features/account_gallery/components/media_item.jsx @@ -128,7 +128,11 @@ export default class MediaItem extends ImmutablePureComponent {
{content} - {label && {label}} + {label && ( +
+ {label} +
+ )}
); } diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx index 491ce9dba5..64c5c0ce6b 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.jsx @@ -83,7 +83,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ modalProps: { type: 'follow', accountId: account.get('id'), - url: account.get('url'), + url: account.get('uri'), }, })); }, diff --git a/app/javascript/mastodon/features/compose/components/compose_form.jsx b/app/javascript/mastodon/features/compose/components/compose_form.jsx index f366ef5c6e..962402ad77 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.jsx +++ b/app/javascript/mastodon/features/compose/components/compose_form.jsx @@ -291,14 +291,14 @@ class ComposeForm extends ImmutablePureComponent { autoFocus={autoFocus} lang={this.props.lang} > - -
+ +
diff --git a/app/javascript/mastodon/features/compose/components/search.jsx b/app/javascript/mastodon/features/compose/components/search.jsx index 7badb0774f..682f8d3c8c 100644 --- a/app/javascript/mastodon/features/compose/components/search.jsx +++ b/app/javascript/mastodon/features/compose/components/search.jsx @@ -139,10 +139,6 @@ class Search extends PureComponent { this.setState({ expanded: false, selectedOption: -1 }); }; - findTarget = () => { - return this.searchForm; - }; - handleHashtagClick = () => { const { router } = this.context; const { value, onClickSearchResult } = this.props; diff --git a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx index 47113d9b8e..9eeec00e34 100644 --- a/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/explore_prompt.tsx @@ -22,7 +22,7 @@ export const ExplorePrompt = () => (

diff --git a/app/javascript/mastodon/features/interaction_modal/index.jsx b/app/javascript/mastodon/features/interaction_modal/index.jsx index b728760ceb..78bd1dff73 100644 --- a/app/javascript/mastodon/features/interaction_modal/index.jsx +++ b/app/javascript/mastodon/features/interaction_modal/index.jsx @@ -1,95 +1,296 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import React from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { connect } from 'react-redux'; +import { throttle, escapeRegExp } from 'lodash'; + import { openModal, closeModal } from 'mastodon/actions/modal'; +import api from 'mastodon/api'; +import Button from 'mastodon/components/button'; import { Icon } from 'mastodon/components/icon'; import { registrationsOpen } from 'mastodon/initial_state'; +const messages = defineMessages({ + loginPrompt: { id: 'interaction_modal.login.prompt', defaultMessage: 'Domain of your home server, e.g. mastodon.social' }, +}); + const mapStateToProps = (state, { accountId }) => ({ displayNameHtml: state.getIn(['accounts', accountId, 'display_name_html']), - signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up', }); const mapDispatchToProps = (dispatch) => ({ onSignupClick() { - dispatch(closeModal({ - modalType: undefined, - ignoreFocus: false, - })); - dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' })); + dispatch(closeModal()); + dispatch(openModal('CLOSED_REGISTRATIONS')); }, }); -class Copypaste extends PureComponent { +const PERSISTENCE_KEY = 'mastodon_home'; + +const isValidDomain = value => { + const url = new URL('https:///path'); + url.hostname = value; + return url.hostname === value; +}; + +const valueToDomain = value => { + // If the user starts typing an URL + if (/^https?:\/\//.test(value)) { + try { + const url = new URL(value); + + // Consider that if there is a path, the URL is more meaningful than a bare domain + if (url.pathname.length > 1) { + return ''; + } + + return url.host; + } catch { + return undefined; + } + // If the user writes their full handle including username + } else if (value.includes('@')) { + if (value.replace(/^@/, '').split('@').length > 2) { + return undefined; + } + return ''; + } + + return value; +}; + +const addInputToOptions = (value, options) => { + value = value.trim(); + + if (value.includes('.') && isValidDomain(value)) { + return [value].concat(options.filter((x) => x !== value)); + } + + return options; +}; + +class LoginForm extends React.PureComponent { static propTypes = { - value: PropTypes.string, + resourceUrl: PropTypes.string, + intl: PropTypes.object.isRequired, }; state = { - copied: false, + value: localStorage ? (localStorage.getItem(PERSISTENCE_KEY) || '') : '', + expanded: false, + selectedOption: -1, + isLoading: false, + isSubmitting: false, + error: false, + options: [], + networkOptions: [], }; setRef = c => { this.input = c; }; - handleInputClick = () => { - this.setState({ copied: false }); - this.input.focus(); - this.input.select(); - this.input.setSelectionRange(0, this.input.value.length); + handleChange = ({ target }) => { + this.setState(state => ({ value: target.value, isLoading: true, error: false, options: addInputToOptions(target.value, state.networkOptions) }), () => this._loadOptions()); }; - handleButtonClick = () => { - const { value } = this.props; - navigator.clipboard.writeText(value); - this.input.blur(); - this.setState({ copied: true }); - this.timeout = setTimeout(() => this.setState({ copied: false }), 700); + handleMessage = (event) => { + const { resourceUrl } = this.props; + + if (event.origin !== window.origin || event.source !== this.iframeRef.contentWindow) { + return; + } + + if (event.data?.type === 'fetchInteractionURL-failure') { + this.setState({ isSubmitting: false, error: true }); + } else if (event.data?.type === 'fetchInteractionURL-success') { + if (/^https?:\/\//.test(event.data.template)) { + if (localStorage) { + localStorage.setItem(PERSISTENCE_KEY, event.data.uri_or_domain); + } + + window.location.href = event.data.template.replace('{uri}', encodeURIComponent(resourceUrl)); + } else { + this.setState({ isSubmitting: false, error: true }); + } + } }; - componentWillUnmount () { - if (this.timeout) clearTimeout(this.timeout); + componentDidMount () { + window.addEventListener('message', this.handleMessage); } + componentWillUnmount () { + window.removeEventListener('message', this.handleMessage); + } + + handleSubmit = () => { + const { value } = this.state; + + this.setState({ isSubmitting: true }); + + this.iframeRef.contentWindow.postMessage({ + type: 'fetchInteractionURL', + uri_or_domain: value.trim(), + }, window.origin); + }; + + setIFrameRef = (iframe) => { + this.iframeRef = iframe; + } + + handleFocus = () => { + this.setState({ expanded: true }); + }; + + handleBlur = () => { + this.setState({ expanded: false }); + }; + + handleKeyDown = (e) => { + const { options, selectedOption } = this.state; + + switch(e.key) { + case 'ArrowDown': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.min(selectedOption + 1, options.length - 1) }); + } + + break; + case 'ArrowUp': + e.preventDefault(); + + if (options.length > 0) { + this.setState({ selectedOption: Math.max(selectedOption - 1, -1) }); + } + + break; + case 'Enter': + e.preventDefault(); + + if (selectedOption === -1) { + this.handleSubmit(); + } else if (options.length > 0) { + this.setState({ value: options[selectedOption], error: false }, () => this.handleSubmit()); + } + + break; + } + }; + + handleOptionClick = e => { + const index = Number(e.currentTarget.getAttribute('data-index')); + const option = this.state.options[index]; + + e.preventDefault(); + this.setState({ selectedOption: index, value: option, error: false }, () => this.handleSubmit()); + }; + + _loadOptions = throttle(() => { + const { value } = this.state; + + const domain = valueToDomain(value.trim()); + + if (typeof domain === 'undefined') { + this.setState({ options: [], networkOptions: [], isLoading: false, error: true }); + return; + } + + if (domain.length === 0) { + this.setState({ options: [], networkOptions: [], isLoading: false }); + return; + } + + api().get('/api/v1/peers/search', { params: { q: domain } }).then(({ data }) => { + if (!data) { + data = []; + } + + this.setState((state) => ({ networkOptions: data, options: addInputToOptions(state.value, data), isLoading: false })); + }).catch(() => { + this.setState({ isLoading: false }); + }); + }, 200, { leading: true, trailing: true }); + render () { - const { value } = this.props; - const { copied } = this.state; + const { intl } = this.props; + const { value, expanded, options, selectedOption, error, isSubmitting } = this.state; + const domain = (valueToDomain(value) || '').trim(); + const domainRegExp = new RegExp(`(${escapeRegExp(domain)})`, 'gi'); + const hasPopOut = domain.length > 0 && options.length > 0; return ( -
- + +