diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f1945f8a64..137bebc599 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -70,7 +70,7 @@ services: hard: -1 libretranslate: - image: libretranslate/libretranslate:v1.4.0 + image: libretranslate/libretranslate:v1.4.1 restart: unless-stopped volumes: - lt-data:/home/libretranslate/.local diff --git a/.env.test b/.env.test index 761d0d9210..2f8c1afd6e 100644 --- a/.env.test +++ b/.env.test @@ -1,5 +1,5 @@ -# Node.js -NODE_ENV=tests +# In test, compile the NodeJS code as if we are in production +NODE_ENV=production # Federation LOCAL_DOMAIN=cb6e6126.ngrok.io LOCAL_HTTPS=true diff --git a/.github/workflows/test-ruby.yml b/.github/workflows/test-ruby.yml index 117e751454..07fd25fb1b 100644 --- a/.github/workflows/test-ruby.yml +++ b/.github/workflows/test-ruby.yml @@ -48,12 +48,15 @@ jobs: run: |- ./bin/rails assets:precompile + - name: Archive asset artifacts + run: | + tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* + - uses: actions/upload-artifact@v3 if: matrix.mode == 'test' with: path: |- - ./public/assets - ./public/packs-test + ./artifacts.tar.gz name: ${{ github.sha }} retention-days: 0 @@ -102,7 +105,6 @@ jobs: SAML_ENABLED: true CAS_ENABLED: true BUNDLE_WITH: 'pam_authentication test' - CI_JOBS: ${{ matrix.ci_job }}/4 GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }} strategy: @@ -112,19 +114,18 @@ jobs: - '3.0' - '3.1' - '.ruby-version' - ci_job: - - 1 - - 2 - - 3 - - 4 steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: - path: './public' + path: './' name: ${{ github.sha }} + - name: Expand archived asset artifacts + run: | + tar xvzf artifacts.tar.gz + - name: Set up Ruby environment uses: ./.github/actions/setup-ruby with: @@ -134,7 +135,7 @@ jobs: - name: Load database schema run: './bin/rails db:create db:schema:load db:seed' - - run: bundle exec rake rspec_chunked + - run: bin/rspec test-e2e: name: End to End testing diff --git a/.haml-lint.yml b/.haml-lint.yml index d1ed30b260..8cfcaec8d9 100644 --- a/.haml-lint.yml +++ b/.haml-lint.yml @@ -12,3 +12,5 @@ linters: enabled: true MiddleDot: enabled: true + LineLength: + max: 320 diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml index ff66764e07..9686c177bd 100644 --- a/.haml-lint_todo.yml +++ b/.haml-lint_todo.yml @@ -1,17 +1,31 @@ # This configuration was generated by # `haml-lint --auto-gen-config` -# on 2023-10-25 08:29:48 -0400 using Haml-Lint version 0.51.0. +# on 2023-10-26 09:32:34 -0400 using Haml-Lint version 0.51.0. # The point is for the user to remove these configuration records # one by one as the lints are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of Haml-Lint, may require this file to be generated again. linters: - # Offense count: 945 + # Offense count: 16 LineLength: - enabled: false + exclude: + - 'app/views/admin/account_actions/new.html.haml' + - 'app/views/admin/accounts/index.html.haml' + - 'app/views/admin/ip_blocks/new.html.haml' + - 'app/views/admin/roles/_form.html.haml' + - 'app/views/admin/settings/discovery/show.html.haml' + - 'app/views/auth/registrations/edit.html.haml' + - 'app/views/auth/registrations/new.html.haml' + - 'app/views/filters/_filter_fields.html.haml' + - 'app/views/media/player.html.haml' + - 'app/views/settings/applications/_fields.html.haml' + - 'app/views/settings/imports/index.html.haml' + - 'app/views/settings/preferences/appearance/show.html.haml' + - 'app/views/settings/preferences/notifications/show.html.haml' + - 'app/views/settings/preferences/other/show.html.haml' - # Offense count: 10 + # Offense count: 9 RuboCop: exclude: - 'app/views/admin/accounts/_buttons.html.haml' diff --git a/.rubocop.yml b/.rubocop.yml index 64ec766b22..63de5e17c7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -27,7 +27,7 @@ AllCops: - 'node_modules/**/*' - 'Vagrantfile' - 'vendor/**/*' - - 'lib/json_ld/*' # Generated files + - 'config/initializers/json_ld*' # Generated files - 'lib/mastodon/migration_helpers.rb' # Vendored from GitLab - 'lib/templates/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 969aa53e80..c0afd7e1d8 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -132,11 +132,6 @@ RSpec/InstanceVariable: RSpec/LetSetup: Exclude: - - 'spec/controllers/admin/accounts_controller_spec.rb' - - 'spec/controllers/admin/action_logs_controller_spec.rb' - - 'spec/controllers/admin/instances_controller_spec.rb' - - 'spec/controllers/admin/reports/actions_controller_spec.rb' - - 'spec/controllers/admin/statuses_controller_spec.rb' - 'spec/controllers/api/v1/accounts/statuses_controller_spec.rb' - 'spec/controllers/api/v1/filters_controller_spec.rb' - 'spec/controllers/api/v2/admin/accounts_controller_spec.rb' diff --git a/Gemfile b/Gemfile index 4eaaa113f9..2c355b4160 100644 --- a/Gemfile +++ b/Gemfile @@ -23,7 +23,7 @@ gem 'blurhash', '~> 0.1' gem 'active_model_serializers', '~> 0.10' gem 'addressable', '~> 2.8' -gem 'bootsnap', '~> 1.16.0', require: false +gem 'bootsnap', '~> 1.17.0', require: false gem 'browser' gem 'charlock_holmes', '~> 0.7.7' gem 'chewy', '~> 7.3' @@ -88,7 +88,7 @@ gem 'simple-navigation', '~> 4.4' gem 'simple_form', '~> 5.2' gem 'sprockets-rails', '~> 3.4', require: 'sprockets/railtie' gem 'stoplight', '~> 3.0.1' -gem 'strong_migrations', '~> 0.8' +gem 'strong_migrations', '1.3.0' gem 'tty-prompt', '~> 0.23', require: false gem 'twitter-text', '~> 3.1.0' gem 'tzinfo-data', '~> 1.2023' @@ -103,9 +103,6 @@ gem 'rdf-normalize', '~> 0.5' gem 'private_address_check', '~> 0.5' group :test do - # Used to split testing into chunks in CI - gem 'rspec_chunked', '~> 0.6' - # Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab gem 'rspec-github', '~> 2.4', require: false diff --git a/Gemfile.lock b/Gemfile.lock index fd9d47fbe4..c38666f91e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -172,7 +172,7 @@ GEM binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) blurhash (0.1.7) - bootsnap (1.16.0) + bootsnap (1.17.0) msgpack (~> 1.2) brakeman (6.0.1) browser (5.3.1) @@ -236,7 +236,7 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.5.0) - discard (1.2.1) + discard (1.3.0) activerecord (>= 4.2, < 8) docile (1.4.0) domain_name (0.5.20190701) @@ -265,7 +265,7 @@ GEM tzinfo excon (0.100.0) fabrication (2.30.0) - faker (3.2.1) + faker (3.2.2) i18n (>= 1.8.11, < 2) faraday (1.10.3) faraday-em_http (~> 1.0) @@ -456,7 +456,7 @@ GEM mini_mime (1.1.5) mini_portile2 (2.8.4) minitest (5.20.0) - msgpack (1.7.1) + msgpack (1.7.2) multi_json (1.15.0) multipart-post (2.3.0) mutex_m (0.1.2) @@ -536,7 +536,7 @@ GEM pundit (2.3.1) activesupport (>= 3.0.0) raabro (1.4.0) - racc (1.7.1) + racc (1.7.3) rack (2.2.8) rack-attack (6.7.0) rack (>= 1.0, < 4) @@ -633,7 +633,7 @@ GEM rspec-support (~> 3.12.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.12.5) + rspec-mocks (3.12.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.12.0) rspec-rails (6.0.3) @@ -644,15 +644,13 @@ GEM rspec-expectations (~> 3.12) rspec-mocks (~> 3.12) rspec-support (~> 3.12) - rspec-sidekiq (4.0.1) + rspec-sidekiq (4.1.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) rspec-mocks (~> 3.0) sidekiq (>= 5, < 8) rspec-support (3.12.1) - rspec_chunked (0.6) - rubocop (1.57.1) - base64 (~> 0.1.1) + rubocop (1.57.2) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) @@ -742,7 +740,7 @@ GEM stoplight (3.0.2) redlock (~> 1.0) stringio (3.0.8) - strong_migrations (0.8.0) + strong_migrations (1.3.0) activerecord (>= 5.2) swd (1.3.0) activesupport (>= 3) @@ -835,7 +833,7 @@ DEPENDENCIES better_errors (~> 2.9) binding_of_caller (~> 1.0) blurhash (~> 0.1) - bootsnap (~> 1.16.0) + bootsnap (~> 1.17.0) brakeman (~> 6.0) browser bundler-audit (~> 0.9) @@ -921,7 +919,6 @@ DEPENDENCIES rspec-github (~> 2.4) rspec-rails (~> 6.0) rspec-sidekiq (~> 4.0) - rspec_chunked (~> 0.6) rubocop rubocop-capybara rubocop-performance @@ -944,7 +941,7 @@ DEPENDENCIES sprockets-rails (~> 3.4) stackprof stoplight (~> 3.0.1) - strong_migrations (~> 0.8) + strong_migrations (= 1.3.0) test-prof thor (~> 1.2) tty-prompt (~> 0.23) diff --git a/SECURITY.md b/SECURITY.md index 3e13377db6..954ff73a24 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,6 +17,6 @@ A "vulnerability in Mastodon" is a vulnerability in the code distributed through | ------- | ---------------- | | 4.2.x | Yes | | 4.1.x | Yes | -| 4.0.x | Until 2023-10-31 | +| 4.0.x | No | | 3.5.x | Until 2023-12-31 | | < 3.5 | No | diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 96c31a38fd..325b33df80 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -33,7 +33,7 @@ module Admin # Disallow accidentally downgrading a domain block if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) - @domain_block.save + @domain_block.validate flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe @domain_block.errors.delete(:domain) return render :new diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 55ebe1bd64..2dfe5e263a 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -92,18 +92,10 @@ module CacheConcern arguments end - if Rails.gem_version >= Gem::Version.new('7.0') - def attributes_for_database(record) - attributes = record.attributes_for_database - attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } - attributes - end - else - def attributes_for_database(record) - attributes = record.instance_variable_get(:@attributes).send(:attributes).transform_values(&:value_for_database) - attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } - attributes - end + def attributes_for_database(record) + attributes = record.attributes_for_database + attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr } + attributes end def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter diff --git a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx index c023b99f81..28384075c3 100644 --- a/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx +++ b/app/javascript/flavours/glitch/components/autosuggest_textarea.jsx @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; +import { useCallback, useRef, useState, useEffect, forwardRef } from 'react'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; @@ -37,54 +37,46 @@ const textAtCursorMatchesToken = (str, caretPosition) => { } }; -export default class AutosuggestTextarea extends ImmutablePureComponent { +const AutosuggestTextarea = forwardRef(({ + value, + suggestions, + disabled, + placeholder, + onSuggestionSelected, + onSuggestionsClearRequested, + onSuggestionsFetchRequested, + onChange, + onKeyUp, + onKeyDown, + onPaste, + onFocus, + autoFocus = true, + lang, + children, +}, textareaRef) => { - static propTypes = { - value: PropTypes.string, - suggestions: ImmutablePropTypes.list, - disabled: PropTypes.bool, - placeholder: PropTypes.string, - onSuggestionSelected: PropTypes.func.isRequired, - onSuggestionsClearRequested: PropTypes.func.isRequired, - onSuggestionsFetchRequested: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onKeyUp: PropTypes.func, - onKeyDown: PropTypes.func, - onPaste: PropTypes.func.isRequired, - autoFocus: PropTypes.bool, - lang: PropTypes.string, - }; + const [suggestionsHidden, setSuggestionsHidden] = useState(true); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const lastTokenRef = useRef(null); + const tokenStartRef = useRef(0); - static defaultProps = { - autoFocus: true, - }; - - state = { - suggestionsHidden: true, - focused: false, - selectedSuggestion: 0, - lastToken: null, - tokenStart: 0, - }; - - onChange = (e) => { + const handleChange = useCallback((e) => { const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart); - if (token !== null && this.state.lastToken !== token) { - this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart }); - this.props.onSuggestionsFetchRequested(token); + if (token !== null && lastTokenRef.current !== token) { + tokenStartRef.current = tokenStart; + lastTokenRef.current = token; + setSelectedSuggestion(0); + onSuggestionsFetchRequested(token); } else if (token === null) { - this.setState({ lastToken: null }); - this.props.onSuggestionsClearRequested(); + lastTokenRef.current = null; + onSuggestionsClearRequested(); } - this.props.onChange(e); - }; - - onKeyDown = (e) => { - const { suggestions, disabled } = this.props; - const { selectedSuggestion, suggestionsHidden } = this.state; + onChange(e); + }, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]); + const handleKeyDown = useCallback((e) => { if (disabled) { e.preventDefault(); return; @@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { document.querySelector('.ui').parentElement.focus(); } else { e.preventDefault(); - this.setState({ suggestionsHidden: true }); + setSuggestionsHidden(true); } break; case 'ArrowDown': if (suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); - this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) }); + setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1)); } break; case 'ArrowUp': if (suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); - this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) }); + setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0)); } break; case 'Enter': case 'Tab': // Select suggestion - if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) { + if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) { e.preventDefault(); e.stopPropagation(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion)); + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion)); } break; } - if (e.defaultPrevented || !this.props.onKeyDown) { + if (e.defaultPrevented || !onKeyDown) { return; } - this.props.onKeyDown(e); - }; + onKeyDown(e); + }, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]); - onBlur = () => { - this.setState({ suggestionsHidden: true, focused: false }); - }; + const handleBlur = useCallback(() => { + setSuggestionsHidden(true); + }, [setSuggestionsHidden]); - onFocus = (e) => { - this.setState({ focused: true }); - if (this.props.onFocus) { - this.props.onFocus(e); + const handleFocus = useCallback((e) => { + if (onFocus) { + onFocus(e); } - }; + }, [onFocus]); - onSuggestionClick = (e) => { - const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); + const handleSuggestionClick = useCallback((e) => { + const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index')); e.preventDefault(); - this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); - this.textarea.focus(); - }; + onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion); + textareaRef.current?.focus(); + }, [suggestions, onSuggestionSelected, textareaRef]); - UNSAFE_componentWillReceiveProps (nextProps) { - if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) { - this.setState({ suggestionsHidden: false }); - } - } - - setTextarea = (c) => { - this.textarea = c; - }; - - onPaste = (e) => { + const handlePaste = useCallback((e) => { if (e.clipboardData && e.clipboardData.files.length === 1) { - this.props.onPaste(e.clipboardData.files); + onPaste(e.clipboardData.files); e.preventDefault(); } - }; + }, [onPaste]); - renderSuggestion = (suggestion, i) => { - const { selectedSuggestion } = this.state; + // Show the suggestions again whenever they change and the textarea is focused + useEffect(() => { + if (suggestions.size > 0 && textareaRef.current === document.activeElement) { + setSuggestionsHidden(false); + } + }, [suggestions, textareaRef, setSuggestionsHidden]); + + const renderSuggestion = (suggestion, i) => { let inner, key; if (suggestion.type === 'emoji') { @@ -190,50 +177,64 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } return ( -
+
{inner}
); }; - render () { - const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props; - const { suggestionsHidden } = this.state; + return [ +
+
+