From 02851848e964675bb59919fa5fd1bdee2c1c29db Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 28 Jun 2022 09:42:13 +0200 Subject: Revamp post filtering system (#18058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add model for custom filter keywords * Use CustomFilterKeyword internally Does not change the API * Fix /filters/edit and /filters/new * Add migration tests * Remove whole_word column from custom_filters (covered by custom_filter_keywords) * Redesign /filters Instead of a list, present a card that displays more information and handles multiple keywords per filter. * Redesign /filters/new and /filters/edit to add and remove keywords This adds a new gem dependency: cocoon, as well as a npm dependency: cocoon-js-vanilla. Those are used to easily populate and remove form fields from the user interface when manipulating multiple keyword filters at once. * Add /api/v2/filters to edit filter with multiple keywords Entities: - `Filter`: `id`, `title`, `filter_action` (either `hide` or `warn`), `context` `keywords` - `FilterKeyword`: `id`, `keyword`, `whole_word` API endpoits: - `GET /api/v2/filters` to list filters (including keywords) - `POST /api/v2/filters` to create a new filter `keywords_attributes` can also be passed to create keywords in one request - `GET /api/v2/filters/:id` to read a particular filter - `PUT /api/v2/filters/:id` to update a new filter `keywords_attributes` can also be passed to edit, delete or add keywords in one request - `DELETE /api/v2/filters/:id` to delete a particular filter - `GET /api/v2/filters/:id/keywords` to list keywords for a filter - `POST /api/v2/filters/:filter_id/keywords/:id` to add a new keyword to a filter - `GET /api/v2/filter_keywords/:id` to read a particular keyword - `PUT /api/v2/filter_keywords/:id` to edit a particular keyword - `DELETE /api/v2/filter_keywords/:id` to delete a particular keyword * Change from `irreversible` boolean to `action` enum * Remove irrelevent `irreversible_must_be_within_context` check * Fix /filters/new and /filters/edit with update for filter_action * Fix Rubocop/Codeclimate complaining about task names * Refactor FeedManager#phrase_filtered? This moves regexp building and filter caching to the `CustomFilter` class. This does not change the functional behavior yet, but this changes how the cache is built, doing per-custom_filter regexps so that filters can be matched independently, while still offering caching. * Perform server-side filtering and output result in REST API * Fix numerous filters_changed events being sent when editing multiple keywords at once * Add some tests * Use the new API in the WebUI - use client-side logic for filters we have fetched rules for. This is so that filter changes can be retroactively applied without reloading the UI. - use server-side logic for filters we haven't fetched rules for yet (e.g. network error, or initial timeline loading) * Minor optimizations and refactoring * Perform server-side filtering on the streaming server * Change the wording of filter action labels * Fix issues pointed out by linter * Change design of “Show anyway” link in accordence to review comments * Drop “irreversible” filtering behavior * Move /api/v2/filter_keywords to /api/v1/filters/keywords * Rename `filter_results` attribute to `filtered` * Rename REST::LegacyFilterSerializer to REST::V1::FilterSerializer * Fix systemChannelId value in streaming server * Simplify code by removing client-side filtering code The simplifcation comes at a cost though: filters aren't retroactively applied anymore. --- ...20220613110628_create_custom_filter_keywords.rb | 13 +++++++++ .../20220613110711_migrate_custom_filters.rb | 34 ++++++++++++++++++++++ .../20220613110834_add_action_to_custom_filters.rb | 20 +++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 db/migrate/20220613110628_create_custom_filter_keywords.rb create mode 100644 db/migrate/20220613110711_migrate_custom_filters.rb create mode 100644 db/migrate/20220613110834_add_action_to_custom_filters.rb (limited to 'db/migrate') diff --git a/db/migrate/20220613110628_create_custom_filter_keywords.rb b/db/migrate/20220613110628_create_custom_filter_keywords.rb new file mode 100644 index 000000000..353fc334f --- /dev/null +++ b/db/migrate/20220613110628_create_custom_filter_keywords.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateCustomFilterKeywords < ActiveRecord::Migration[6.1] + def change + create_table :custom_filter_keywords do |t| + t.belongs_to :custom_filter, foreign_key: { on_delete: :cascade }, null: false + t.text :keyword, null: false, default: '' + t.boolean :whole_word, null: false, default: true + + t.timestamps + end + end +end diff --git a/db/migrate/20220613110711_migrate_custom_filters.rb b/db/migrate/20220613110711_migrate_custom_filters.rb new file mode 100644 index 000000000..ea6a9b8c6 --- /dev/null +++ b/db/migrate/20220613110711_migrate_custom_filters.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class MigrateCustomFilters < ActiveRecord::Migration[6.1] + def up + # Preserve IDs as much as possible to not confuse existing clients. + # As long as this migration is irreversible, we do not have to deal with conflicts. + safety_assured do + execute <<-SQL.squish + INSERT INTO custom_filter_keywords (id, custom_filter_id, keyword, whole_word, created_at, updated_at) + SELECT id, id, phrase, whole_word, created_at, updated_at + FROM custom_filters + SQL + end + end + + def down + # Copy back changes from custom filters guaranteed to be from the old API + safety_assured do + execute <<-SQL.squish + UPDATE custom_filters + SET phrase = custom_filter_keywords.keyword, whole_word = custom_filter_keywords.whole_word + FROM custom_filter_keywords + WHERE custom_filters.id = custom_filter_keywords.id AND custom_filters.id = custom_filter_keywords.custom_filter_id + SQL + end + + # Drop every keyword as we can't safely provide a 1:1 mapping + safety_assured do + execute <<-SQL.squish + TRUNCATE custom_filter_keywords RESTART IDENTITY + SQL + end + end +end diff --git a/db/migrate/20220613110834_add_action_to_custom_filters.rb b/db/migrate/20220613110834_add_action_to_custom_filters.rb new file mode 100644 index 000000000..9427a66fc --- /dev/null +++ b/db/migrate/20220613110834_add_action_to_custom_filters.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddActionToCustomFilters < ActiveRecord::Migration[6.1] + include Mastodon::MigrationHelpers + + disable_ddl_transaction! + + def up + safety_assured do + add_column_with_default :custom_filters, :action, :integer, allow_null: false, default: 0 + execute 'UPDATE custom_filters SET action = 1 WHERE irreversible IS TRUE' + end + end + + def down + execute 'UPDATE custom_filters SET irreversible = (action = 1)' + remove_column :custom_filters, :action + end +end -- cgit From 44b2ee3485ba0845e5910cefcb4b1e2f84f34470 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 5 Jul 2022 02:41:40 +0200 Subject: Add customizable user roles (#18641) * Add customizable user roles * Various fixes and improvements * Add migration for old settings and fix tootctl role management --- .rubocop.yml | 4 +- .../admin/account_actions_controller.rb | 4 + app/controllers/admin/accounts_controller.rb | 2 + app/controllers/admin/action_logs_controller.rb | 5 +- app/controllers/admin/base_controller.rb | 2 +- app/controllers/admin/custom_emojis_controller.rb | 2 + app/controllers/admin/dashboard_controller.rb | 4 +- .../admin/email_domain_blocks_controller.rb | 2 + .../admin/follow_recommendations_controller.rb | 2 + app/controllers/admin/ip_blocks_controller.rb | 2 + app/controllers/admin/relationships_controller.rb | 2 +- app/controllers/admin/roles_controller.rb | 65 +++++- app/controllers/admin/statuses_controller.rb | 2 + app/controllers/admin/subscriptions_controller.rb | 20 -- .../links/preview_card_providers_controller.rb | 4 +- app/controllers/admin/trends/links_controller.rb | 4 +- .../admin/trends/statuses_controller.rb | 4 +- app/controllers/admin/trends/tags_controller.rb | 4 +- .../admin/two_factor_authentications_controller.rb | 21 -- app/controllers/admin/users/roles_controller.rb | 33 +++ .../users/two_factor_authentications_controller.rb | 21 ++ .../api/v1/admin/account_actions_controller.rb | 7 +- .../api/v1/admin/accounts_controller.rb | 6 +- .../api/v1/admin/dimensions_controller.rb | 6 +- .../api/v1/admin/domain_allows_controller.rb | 2 +- .../api/v1/admin/domain_blocks_controller.rb | 2 +- .../api/v1/admin/measures_controller.rb | 6 +- app/controllers/api/v1/admin/reports_controller.rb | 2 +- .../api/v1/admin/retention_controller.rb | 6 +- .../api/v1/admin/trends/links_controller.rb | 20 +- .../api/v1/admin/trends/statuses_controller.rb | 20 +- .../api/v1/admin/trends/tags_controller.rb | 20 +- app/controllers/api/v1/trends/links_controller.rb | 10 +- .../api/v1/trends/statuses_controller.rb | 10 +- app/controllers/api/v1/trends/tags_controller.rb | 12 +- .../api/v2/admin/accounts_controller.rb | 13 +- app/controllers/application_controller.rb | 8 - app/controllers/custom_css_controller.rb | 2 +- app/helpers/accounts_helper.rb | 14 +- .../mastodon/components/status_action_bar.js | 6 +- app/javascript/mastodon/containers/mastodon.js | 1 + .../mastodon/features/account/components/header.js | 9 +- .../notifications/components/column_settings.js | 10 +- .../features/status/components/action_bar.js | 6 +- .../mastodon/features/ui/components/link_footer.js | 9 +- app/javascript/mastodon/initial_state.js | 2 - app/javascript/mastodon/permissions.js | 3 + app/javascript/mastodon/reducers/meta.js | 3 +- app/javascript/styles/mastodon/admin.scss | 15 ++ app/javascript/styles/mastodon/forms.scss | 4 + app/lib/admin/system_check.rb | 6 +- app/lib/admin/system_check/base_check.rb | 10 + .../admin/system_check/database_schema_check.rb | 4 + app/lib/admin/system_check/elasticsearch_check.rb | 8 +- app/lib/admin/system_check/rules_check.rb | 4 + .../admin/system_check/sidekiq_process_check.rb | 4 + app/models/account.rb | 9 +- app/models/account_filter.rb | 27 +-- app/models/concerns/user_roles.rb | 68 ------ app/models/form/admin_settings.rb | 4 - app/models/trends.rb | 2 +- app/models/user.rb | 38 +++- app/models/user_role.rb | 179 +++++++++++++++ app/policies/account_moderation_note_policy.rb | 4 +- app/policies/account_policy.rb | 40 ++-- app/policies/account_warning_policy.rb | 2 +- app/policies/account_warning_preset_policy.rb | 8 +- app/policies/announcement_policy.rb | 8 +- app/policies/appeal_policy.rb | 8 +- app/policies/application_policy.rb | 6 +- app/policies/audit_log_policy.rb | 7 + app/policies/custom_emoji_policy.rb | 14 +- app/policies/dashboard_policy.rb | 7 + app/policies/delivery_policy.rb | 6 +- app/policies/domain_allow_policy.rb | 8 +- app/policies/domain_block_policy.rb | 10 +- app/policies/email_domain_block_policy.rb | 6 +- app/policies/follow_recommendation_policy.rb | 6 +- app/policies/instance_policy.rb | 6 +- app/policies/invite_policy.rb | 12 +- app/policies/ip_block_policy.rb | 6 +- app/policies/preview_card_policy.rb | 4 +- app/policies/preview_card_provider_policy.rb | 4 +- app/policies/relay_policy.rb | 2 +- app/policies/report_note_policy.rb | 4 +- app/policies/report_policy.rb | 6 +- app/policies/rule_policy.rb | 8 +- app/policies/settings_policy.rb | 6 +- app/policies/status_policy.rb | 8 +- app/policies/tag_policy.rb | 8 +- app/policies/user_policy.rb | 38 +--- app/policies/user_role_policy.rb | 19 ++ app/policies/webhook_policy.rb | 16 +- app/presenters/initial_state_presenter.rb | 4 + app/serializers/initial_state_serializer.rb | 3 +- .../rest/credential_account_serializer.rb | 6 + app/serializers/rest/instance_serializer.rb | 2 +- app/serializers/rest/role_serializer.rb | 13 ++ app/services/account_search_service.rb | 4 +- app/services/appeal_service.rb | 2 +- app/services/bootstrap_timeline_service.rb | 2 +- app/services/notify_service.rb | 2 +- app/services/report_service.rb | 2 +- app/views/admin/accounts/index.html.haml | 55 ++--- app/views/admin/accounts/show.html.haml | 9 +- app/views/admin/action_logs/index.html.haml | 2 +- app/views/admin/instances/show.html.haml | 53 ++--- app/views/admin/roles/_form.html.haml | 37 ++++ app/views/admin/roles/_role.html.haml | 18 ++ app/views/admin/roles/edit.html.haml | 8 + app/views/admin/roles/index.html.haml | 17 ++ app/views/admin/roles/new.html.haml | 4 + app/views/admin/settings/edit.html.haml | 6 - app/views/admin/tags/show.html.haml | 87 ++++---- app/views/admin/users/roles/show.html.haml | 9 + app/views/custom_css/show.css.erb | 10 + app/views/layouts/application.html.haml | 4 +- .../preferences/notifications/show.html.haml | 10 +- config/application.rb | 1 + config/locales/activerecord.en.yml | 9 + config/locales/en.yml | 85 +++++-- config/locales/simple_form.en.yml | 15 ++ config/navigation.rb | 81 +++---- config/roles.yml | 35 +++ config/routes.rb | 13 +- db/migrate/20220611210335_create_user_roles.rb | 13 ++ db/migrate/20220611212541_add_role_id_to_users.rb | 8 + db/post_migrate/20220617202502_migrate_roles.rb | 26 +++ ...0220704024901_migrate_settings_to_user_roles.rb | 41 ++++ db/schema.rb | 15 +- db/seeds.rb | 12 +- db/seeds/01_web_app.rb | 1 + db/seeds/02_instance_actor.rb | 1 + db/seeds/03_roles.rb | 9 + db/seeds/04_admin.rb | 8 + lib/mastodon/accounts_cli.rb | 41 +++- lib/simple_navigation/item_extensions.rb | 15 ++ .../account_moderation_notes_controller_spec.rb | 2 +- spec/controllers/admin/accounts_controller_spec.rb | 52 +++-- .../admin/action_logs_controller_spec.rb | 2 +- spec/controllers/admin/base_controller_spec.rb | 7 +- .../admin/change_email_controller_spec.rb | 2 +- .../admin/confirmations_controller_spec.rb | 2 +- .../admin/custom_emojis_controller_spec.rb | 2 +- .../controllers/admin/dashboard_controller_spec.rb | 2 +- .../admin/disputes/appeals_controller_spec.rb | 4 +- .../admin/domain_blocks_controller_spec.rb | 2 +- .../admin/email_domain_blocks_controller_spec.rb | 2 +- .../controllers/admin/instances_controller_spec.rb | 8 +- spec/controllers/admin/invites_controller_spec.rb | 2 +- .../admin/report_notes_controller_spec.rb | 2 +- spec/controllers/admin/reports_controller_spec.rb | 2 +- spec/controllers/admin/resets_controller_spec.rb | 2 +- spec/controllers/admin/roles_controller_spec.rb | 244 +++++++++++++++++++-- spec/controllers/admin/settings_controller_spec.rb | 2 +- spec/controllers/admin/statuses_controller_spec.rb | 2 +- spec/controllers/admin/tags_controller_spec.rb | 2 +- .../two_factor_authentications_controller_spec.rb | 51 ----- spec/controllers/admin/users/roles_controller.rb | 81 +++++++ .../two_factor_authentications_controller_spec.rb | 52 +++++ .../v1/admin/account_actions_controller_spec.rb | 6 +- .../api/v1/admin/accounts_controller_spec.rb | 20 +- .../api/v1/admin/domain_allows_controller_spec.rb | 20 +- .../api/v1/admin/domain_blocks_controller_spec.rb | 20 +- .../api/v1/admin/reports_controller_spec.rb | 16 +- spec/controllers/api/v1/reports_controller_spec.rb | 2 +- .../api/v2/admin/accounts_controller_spec.rb | 6 +- spec/controllers/application_controller_spec.rb | 64 ------ .../disputes/appeals_controller_spec.rb | 2 +- spec/controllers/invites_controller_spec.rb | 40 ++-- spec/fabricators/user_role_fabricator.rb | 5 + spec/models/account_spec.rb | 14 +- spec/models/admin/account_action_spec.rb | 2 +- spec/models/user_role_spec.rb | 189 ++++++++++++++++ spec/models/user_spec.rb | 159 +------------- .../account_moderation_note_policy_spec.rb | 4 +- spec/policies/account_policy_spec.rb | 8 +- spec/policies/custom_emoji_policy_spec.rb | 2 +- spec/policies/domain_block_policy_spec.rb | 2 +- spec/policies/email_domain_block_policy_spec.rb | 2 +- spec/policies/instance_policy_spec.rb | 2 +- spec/policies/invite_policy_spec.rb | 54 ++--- spec/policies/relay_policy_spec.rb | 2 +- spec/policies/report_note_policy_spec.rb | 5 +- spec/policies/report_policy_spec.rb | 2 +- spec/policies/settings_policy_spec.rb | 2 +- spec/policies/status_policy_spec.rb | 2 +- spec/policies/tag_policy_spec.rb | 2 +- spec/policies/user_policy_spec.rb | 55 +---- 189 files changed, 2021 insertions(+), 1108 deletions(-) delete mode 100644 app/controllers/admin/subscriptions_controller.rb delete mode 100644 app/controllers/admin/two_factor_authentications_controller.rb create mode 100644 app/controllers/admin/users/roles_controller.rb create mode 100644 app/controllers/admin/users/two_factor_authentications_controller.rb create mode 100644 app/javascript/mastodon/permissions.js delete mode 100644 app/models/concerns/user_roles.rb create mode 100644 app/models/user_role.rb create mode 100644 app/policies/audit_log_policy.rb create mode 100644 app/policies/dashboard_policy.rb create mode 100644 app/policies/user_role_policy.rb create mode 100644 app/serializers/rest/role_serializer.rb create mode 100644 app/views/admin/roles/_form.html.haml create mode 100644 app/views/admin/roles/_role.html.haml create mode 100644 app/views/admin/roles/edit.html.haml create mode 100644 app/views/admin/roles/index.html.haml create mode 100644 app/views/admin/roles/new.html.haml create mode 100644 app/views/admin/users/roles/show.html.haml create mode 100644 app/views/custom_css/show.css.erb create mode 100644 config/roles.yml create mode 100644 db/migrate/20220611210335_create_user_roles.rb create mode 100644 db/migrate/20220611212541_add_role_id_to_users.rb create mode 100644 db/post_migrate/20220617202502_migrate_roles.rb create mode 100644 db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb create mode 100644 db/seeds/01_web_app.rb create mode 100644 db/seeds/02_instance_actor.rb create mode 100644 db/seeds/03_roles.rb create mode 100644 db/seeds/04_admin.rb create mode 100644 lib/simple_navigation/item_extensions.rb delete mode 100644 spec/controllers/admin/two_factor_authentications_controller_spec.rb create mode 100644 spec/controllers/admin/users/roles_controller.rb create mode 100644 spec/controllers/admin/users/two_factor_authentications_controller_spec.rb create mode 100644 spec/fabricators/user_role_fabricator.rb create mode 100644 spec/models/user_role_spec.rb (limited to 'db/migrate') diff --git a/.rubocop.yml b/.rubocop.yml index 9e3ff21f2..8dc2d1c47 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -67,7 +67,7 @@ Lint/UselessAccessModifier: - class_methods Metrics/AbcSize: - Max: 100 + Max: 115 Exclude: - 'lib/mastodon/*_cli.rb' @@ -84,7 +84,7 @@ Metrics/BlockNesting: Metrics/ClassLength: CountComments: false - Max: 400 + Max: 500 Exclude: - 'lib/mastodon/*_cli.rb' diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index ea56fa0ac..3f2e28b6a 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -5,11 +5,15 @@ module Admin before_action :set_account def new + authorize @account, :show? + @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true) @warning_presets = AccountWarningPreset.all end def create + authorize @account, :show? + account_action = Admin::AccountAction.new(resource_params) account_action.target_account = @account account_action.current_account = current_account diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index e0ae71b9f..46c9aba91 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -14,6 +14,8 @@ module Admin end def batch + authorize :account, :index? + @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/action_logs_controller.rb b/app/controllers/admin/action_logs_controller.rb index 2d77620df..42edec15a 100644 --- a/app/controllers/admin/action_logs_controller.rb +++ b/app/controllers/admin/action_logs_controller.rb @@ -4,7 +4,10 @@ module Admin class ActionLogsController < BaseController before_action :set_action_logs - def index; end + def index + authorize :audit_log, :index? + @auditable_accounts = Account.where(id: Admin::ActionLog.reorder(nil).select('distinct account_id')).select(:id, :username) + end private diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 7b81a2b01..5b7a7ec11 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -7,8 +7,8 @@ module Admin layout 'admin' - before_action :require_staff! before_action :set_body_classes + after_action :verify_authorized private diff --git a/app/controllers/admin/custom_emojis_controller.rb b/app/controllers/admin/custom_emojis_controller.rb index 71efb543e..2c33e9f8f 100644 --- a/app/controllers/admin/custom_emojis_controller.rb +++ b/app/controllers/admin/custom_emojis_controller.rb @@ -29,6 +29,8 @@ module Admin end def batch + authorize :custom_emoji, :index? + @form = Form::CustomEmojiBatch.new(form_custom_emoji_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index da9c6dd16..924b623ad 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -5,7 +5,9 @@ module Admin include Redisable def index - @system_checks = Admin::SystemCheck.perform + authorize :dashboard, :index? + + @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) @pending_users_count = User.pending.count @pending_reports_count = Report.unresolved.count diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index a4bbbba5b..593457b94 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -12,6 +12,8 @@ module Admin end def batch + authorize :email_domain_block, :index? + @form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/follow_recommendations_controller.rb b/app/controllers/admin/follow_recommendations_controller.rb index e3eac62b3..841e3cc7f 100644 --- a/app/controllers/admin/follow_recommendations_controller.rb +++ b/app/controllers/admin/follow_recommendations_controller.rb @@ -12,6 +12,8 @@ module Admin end def update + authorize :follow_recommendation, :show? + @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/ip_blocks_controller.rb b/app/controllers/admin/ip_blocks_controller.rb index 92b8b0d2b..a87520f4e 100644 --- a/app/controllers/admin/ip_blocks_controller.rb +++ b/app/controllers/admin/ip_blocks_controller.rb @@ -29,6 +29,8 @@ module Admin end def batch + authorize :ip_block, :index? + @form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/relationships_controller.rb b/app/controllers/admin/relationships_controller.rb index 085ded21c..67645f054 100644 --- a/app/controllers/admin/relationships_controller.rb +++ b/app/controllers/admin/relationships_controller.rb @@ -7,7 +7,7 @@ module Admin PER_PAGE = 40 def index - authorize :account, :index? + authorize @account, :show? @accounts = RelationshipFilter.new(@account, filter_params).results.includes(:account_stat, user: [:ips, :invite_request]).page(params[:page]).per(PER_PAGE) @form = Form::AccountBatch.new diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 13f56e9be..3e502ccc4 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -2,20 +2,63 @@ module Admin class RolesController < BaseController - before_action :set_user + before_action :set_role, except: [:index, :new, :create] - def promote - authorize @user, :promote? - @user.promote! - log_action :promote, @user - redirect_to admin_account_path(@user.account_id) + def index + authorize :user_role, :index? + + @roles = UserRole.order(position: :desc).page(params[:page]) + end + + def new + authorize :user_role, :create? + + @role = UserRole.new + end + + def create + authorize :user_role, :create? + + @role = UserRole.new(resource_params) + @role.current_account = current_account + + if @role.save + redirect_to admin_roles_path + else + render :new + end + end + + def edit + authorize @role, :update? + end + + def update + authorize @role, :update? + + @role.current_account = current_account + + if @role.update(resource_params) + redirect_to admin_roles_path + else + render :edit + end + end + + def destroy + authorize @role, :destroy? + @role.destroy! + redirect_to admin_roles_path + end + + private + + def set_role + @role = UserRole.find(params[:id]) end - def demote - authorize @user, :demote? - @user.demote! - log_action :demote, @user - redirect_to admin_account_path(@user.account_id) + def resource_params + params.require(:user_role).permit(:name, :color, :highlighted, :position, permissions_as_keys: []) end end end diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index 817c0caa9..084921ceb 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -14,6 +14,8 @@ module Admin end def batch + authorize :status, :index? + @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) @status_batch_action.save! rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/subscriptions_controller.rb b/app/controllers/admin/subscriptions_controller.rb deleted file mode 100644 index 40500ef43..000000000 --- a/app/controllers/admin/subscriptions_controller.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -module Admin - class SubscriptionsController < BaseController - def index - authorize :subscription, :index? - @subscriptions = ordered_subscriptions.page(requested_page) - end - - private - - def ordered_subscriptions - Subscription.order(id: :desc).includes(:account) - end - - def requested_page - params[:page].to_i - end - end -end diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb index 40a466cd6..97dee8eca 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -2,13 +2,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseController def index - authorize :preview_card_provider, :index? + authorize :preview_card_provider, :review? @preview_card_providers = filtered_preview_card_providers.page(params[:page]) @form = Trends::PreviewCardProviderBatch.new end def batch + authorize :preview_card_provider, :review? + @form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 434eec5fe..a497eae41 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -2,13 +2,15 @@ class Admin::Trends::LinksController < Admin::BaseController def index - authorize :preview_card, :index? + authorize :preview_card, :review? @preview_cards = filtered_preview_cards.page(params[:page]) @form = Trends::PreviewCardBatch.new end def batch + authorize :preview_card, :review? + @form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb index 766242738..c538962f9 100644 --- a/app/controllers/admin/trends/statuses_controller.rb +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -2,13 +2,15 @@ class Admin::Trends::StatusesController < Admin::BaseController def index - authorize :status, :index? + authorize :status, :review? @statuses = filtered_statuses.page(params[:page]) @form = Trends::StatusBatch.new end def batch + authorize :status, :review? + @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index f4d1ec0d1..98dd6c8ec 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -2,13 +2,15 @@ class Admin::Trends::TagsController < Admin::BaseController def index - authorize :tag, :index? + authorize :tag, :review? @tags = filtered_tags.page(params[:page]) @form = Trends::TagBatch.new end def batch + authorize :tag, :review? + @form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button)) @form.save rescue ActionController::ParameterMissing diff --git a/app/controllers/admin/two_factor_authentications_controller.rb b/app/controllers/admin/two_factor_authentications_controller.rb deleted file mode 100644 index f7fb7eb8f..000000000 --- a/app/controllers/admin/two_factor_authentications_controller.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Admin - class TwoFactorAuthenticationsController < BaseController - before_action :set_target_user - - def destroy - authorize @user, :disable_2fa? - @user.disable_two_factor! - log_action :disable_2fa, @user - UserMailer.two_factor_disabled(@user).deliver_later! - redirect_to admin_account_path(@user.account_id) - end - - private - - def set_target_user - @user = User.find(params[:user_id]) - end - end -end diff --git a/app/controllers/admin/users/roles_controller.rb b/app/controllers/admin/users/roles_controller.rb new file mode 100644 index 000000000..0db50cee9 --- /dev/null +++ b/app/controllers/admin/users/roles_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Admin + class Users::RolesController < BaseController + before_action :set_user + + def show + authorize @user, :change_role? + end + + def update + authorize @user, :change_role? + + @user.current_account = current_account + + if @user.update(resource_params) + redirect_to admin_account_path(@user.account_id), notice: I18n.t('admin.accounts.change_role.changed_msg') + else + render :show + end + end + + private + + def set_user + @user = User.find(params[:user_id]) + end + + def resource_params + params.require(:user).permit(:role_id) + end + end +end diff --git a/app/controllers/admin/users/two_factor_authentications_controller.rb b/app/controllers/admin/users/two_factor_authentications_controller.rb new file mode 100644 index 000000000..5e3fb2b3c --- /dev/null +++ b/app/controllers/admin/users/two_factor_authentications_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Admin + class Users::TwoFactorAuthenticationsController < BaseController + before_action :set_target_user + + def destroy + authorize @user, :disable_2fa? + @user.disable_two_factor! + log_action :disable_2fa, @user + UserMailer.two_factor_disabled(@user).deliver_later! + redirect_to admin_account_path(@user.account_id) + end + + private + + def set_target_user + @user = User.find(params[:user_id]) + end + end +end diff --git a/app/controllers/api/v1/admin/account_actions_controller.rb b/app/controllers/api/v1/admin/account_actions_controller.rb index 6c9e04402..7249797a4 100644 --- a/app/controllers/api/v1/admin/account_actions_controller.rb +++ b/app/controllers/api/v1/admin/account_actions_controller.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true class Api::V1::Admin::AccountActionsController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' } - before_action :require_staff! before_action :set_account + after_action :verify_authorized + def create + authorize @account, :show? + account_action = Admin::AccountAction.new(resource_params) account_action.target_account = @account account_action.current_account = current_account diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb index 65ed69f7b..0dee02e94 100644 --- a/app/controllers/api/v1/admin/accounts_controller.rb +++ b/app/controllers/api/v1/admin/accounts_controller.rb @@ -8,11 +8,11 @@ class Api::V1::Admin::AccountsController < Api::BaseController before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show] - before_action :require_staff! before_action :set_accounts, only: :index before_action :set_account, except: :index before_action :require_local_account!, only: [:enable, :approve, :reject] + after_action :verify_authorized after_action :insert_pagination_headers, only: :index FILTER_PARAMS = %i( @@ -119,7 +119,9 @@ class Api::V1::Admin::AccountsController < Api::BaseController translated_params[:status] = status.to_s if params[status].present? end - translated_params[:permissions] = 'staff' if params[:staff].present? + if params[:staff].present? + translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id) + end translated_params end diff --git a/app/controllers/api/v1/admin/dimensions_controller.rb b/app/controllers/api/v1/admin/dimensions_controller.rb index 49a5be1c3..4a72ad08b 100644 --- a/app/controllers/api/v1/admin/dimensions_controller.rb +++ b/app/controllers/api/v1/admin/dimensions_controller.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class Api::V1::Admin::DimensionsController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! before_action :set_dimensions + after_action :verify_authorized + def create + authorize :dashboard, :index? render json: @dimensions, each_serializer: REST::Admin::DimensionSerializer end diff --git a/app/controllers/api/v1/admin/domain_allows_controller.rb b/app/controllers/api/v1/admin/domain_allows_controller.rb index 838978ddb..59aa807d6 100644 --- a/app/controllers/api/v1/admin/domain_allows_controller.rb +++ b/app/controllers/api/v1/admin/domain_allows_controller.rb @@ -8,10 +8,10 @@ class Api::V1::Admin::DomainAllowsController < Api::BaseController before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_allows' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_allows' }, except: [:index, :show] - before_action :require_staff! before_action :set_domain_allows, only: :index before_action :set_domain_allow, only: [:show, :destroy] + after_action :verify_authorized after_action :insert_pagination_headers, only: :index PAGINATION_PARAMS = %i(limit).freeze diff --git a/app/controllers/api/v1/admin/domain_blocks_controller.rb b/app/controllers/api/v1/admin/domain_blocks_controller.rb index 229870eee..de8fd9d08 100644 --- a/app/controllers/api/v1/admin/domain_blocks_controller.rb +++ b/app/controllers/api/v1/admin/domain_blocks_controller.rb @@ -8,10 +8,10 @@ class Api::V1::Admin::DomainBlocksController < Api::BaseController before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:domain_blocks' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:domain_blocks' }, except: [:index, :show] - before_action :require_staff! before_action :set_domain_blocks, only: :index before_action :set_domain_block, only: [:show, :update, :destroy] + after_action :verify_authorized after_action :insert_pagination_headers, only: :index PAGINATION_PARAMS = %i(limit).freeze diff --git a/app/controllers/api/v1/admin/measures_controller.rb b/app/controllers/api/v1/admin/measures_controller.rb index da95d3422..d78d7e10b 100644 --- a/app/controllers/api/v1/admin/measures_controller.rb +++ b/app/controllers/api/v1/admin/measures_controller.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class Api::V1::Admin::MeasuresController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! before_action :set_measures + after_action :verify_authorized + def create + authorize :dashboard, :index? render json: @measures, each_serializer: REST::Admin::MeasureSerializer end diff --git a/app/controllers/api/v1/admin/reports_controller.rb b/app/controllers/api/v1/admin/reports_controller.rb index 865ba3d23..9dfb181a2 100644 --- a/app/controllers/api/v1/admin/reports_controller.rb +++ b/app/controllers/api/v1/admin/reports_controller.rb @@ -8,10 +8,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show] - before_action :require_staff! before_action :set_reports, only: :index before_action :set_report, except: :index + after_action :verify_authorized after_action :insert_pagination_headers, only: :index FILTER_PARAMS = %i( diff --git a/app/controllers/api/v1/admin/retention_controller.rb b/app/controllers/api/v1/admin/retention_controller.rb index 98d1a3d81..59d6b8388 100644 --- a/app/controllers/api/v1/admin/retention_controller.rb +++ b/app/controllers/api/v1/admin/retention_controller.rb @@ -1,11 +1,15 @@ # frozen_string_literal: true class Api::V1::Admin::RetentionController < Api::BaseController + include Authorization + before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! before_action :set_cohorts + after_action :verify_authorized + def create + authorize :dashboard, :index? render json: @cohorts, each_serializer: REST::Admin::CohortSerializer end diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb index 0a191fe4b..cc6388980 100644 --- a/app/controllers/api/v1/admin/trends/links_controller.rb +++ b/app/controllers/api/v1/admin/trends/links_controller.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true -class Api::V1::Admin::Trends::LinksController < Api::BaseController +class Api::V1::Admin::Trends::LinksController < Api::V1::Trends::LinksController before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! - before_action :set_links - - def index - render json: @links, each_serializer: REST::Trends::LinkSerializer - end private - def set_links - @links = Trends.links.query.limit(limit_param(10)) + def enabled? + super || current_user&.can?(:manage_taxonomies) + end + + def links_from_trends + if current_user&.can?(:manage_taxonomies) + Trends.links.query + else + super + end end end diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb index cb145f165..c39f77363 100644 --- a/app/controllers/api/v1/admin/trends/statuses_controller.rb +++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true -class Api::V1::Admin::Trends::StatusesController < Api::BaseController +class Api::V1::Admin::Trends::StatusesController < Api::V1::Trends::StatusesController before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! - before_action :set_statuses - - def index - render json: @statuses, each_serializer: REST::StatusSerializer - end private - def set_statuses - @statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) + def enabled? + super || current_user&.can?(:manage_taxonomies) + end + + def statuses_from_trends + if current_user&.can?(:manage_taxonomies) + Trends.statuses.query + else + super + end end end diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index 9c28b0412..f3c0c4b6b 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -1,17 +1,19 @@ # frozen_string_literal: true -class Api::V1::Admin::Trends::TagsController < Api::BaseController +class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController before_action -> { authorize_if_got_token! :'admin:read' } - before_action :require_staff! - before_action :set_tags - - def index - render json: @tags, each_serializer: REST::Admin::TagSerializer - end private - def set_tags - @tags = Trends.tags.query.limit(limit_param(10)) + def enabled? + super || current_user&.can?(:manage_taxonomies) + end + + def tags_from_trends + if current_user&.can?(:manage_taxonomies) + Trends.tags.query + else + super + end end end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 2385fe438..1a9f918f2 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -13,10 +13,14 @@ class Api::V1::Trends::LinksController < Api::BaseController private + def enabled? + Setting.trends + end + def set_links @links = begin - if Setting.trends - links_from_trends + if enabled? + links_from_trends.offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT)) else [] end @@ -24,7 +28,7 @@ class Api::V1::Trends::LinksController < Api::BaseController end def links_from_trends - Trends.links.query.allowed.in_locale(content_locale).offset(offset_param).limit(limit_param(DEFAULT_LINKS_LIMIT)) + Trends.links.query.allowed.in_locale(content_locale) end def insert_pagination_headers diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb index 1f2fff582..c275d5fc8 100644 --- a/app/controllers/api/v1/trends/statuses_controller.rb +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -11,10 +11,14 @@ class Api::V1::Trends::StatusesController < Api::BaseController private + def enabled? + Setting.trends + end + def set_statuses @statuses = begin - if Setting.trends - cache_collection(statuses_from_trends, Status) + if enabled? + cache_collection(statuses_from_trends.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status) else [] end @@ -24,7 +28,7 @@ class Api::V1::Trends::StatusesController < Api::BaseController def statuses_from_trends scope = Trends.statuses.query.allowed.in_locale(content_locale) scope = scope.filtered_for(current_account) if user_signed_in? - scope.offset(offset_param).limit(limit_param(DEFAULT_STATUSES_LIMIT)) + scope end def insert_pagination_headers diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 38003f599..41f9ffac1 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -13,16 +13,24 @@ class Api::V1::Trends::TagsController < Api::BaseController private + def enabled? + Setting.trends + end + def set_tags @tags = begin - if Setting.trends - Trends.tags.query.allowed.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT)) + if enabled? + tags_from_trends.offset(offset_param).limit(limit_param(DEFAULT_TAGS_LIMIT)) else [] end end end + def tags_from_trends + Trends.tags.query.allowed + end + def insert_pagination_headers set_pagination_headers(next_path, prev_path) end diff --git a/app/controllers/api/v2/admin/accounts_controller.rb b/app/controllers/api/v2/admin/accounts_controller.rb index a89e6835e..bcc1a0733 100644 --- a/app/controllers/api/v2/admin/accounts_controller.rb +++ b/app/controllers/api/v2/admin/accounts_controller.rb @@ -11,6 +11,7 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController email ip invited_by + role_ids ).freeze PAGINATION_PARAMS = (%i(limit) + FILTER_PARAMS).freeze @@ -18,7 +19,17 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController private def filtered_accounts - AccountFilter.new(filter_params).results + AccountFilter.new(translated_filter_params).results + end + + def translated_filter_params + translated_params = filter_params.slice(*AccountFilter::KEYS) + + if params[:permissions] == 'staff' + translated_params[:role_ids] = UserRole.that_can(:manage_reports).map(&:id) + end + + translated_params end def filter_params diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 3d2f8280b..615536b96 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -56,14 +56,6 @@ class ApplicationController < ActionController::Base store_location_for(:user, request.url) unless [:json, :rss].include?(request.format&.to_sym) end - def require_admin! - forbidden unless current_user&.admin? - end - - def require_staff! - forbidden unless current_user&.staff? - end - def require_functional! redirect_to edit_user_registration_path unless current_user.functional? end diff --git a/app/controllers/custom_css_controller.rb b/app/controllers/custom_css_controller.rb index e1dc5eaf6..9270c467d 100644 --- a/app/controllers/custom_css_controller.rb +++ b/app/controllers/custom_css_controller.rb @@ -13,6 +13,6 @@ class CustomCssController < ApplicationController def show expires_in 3.minutes, public: true request.session_options[:skip] = true - render plain: Setting.custom_css || '', content_type: 'text/css' + render content_type: 'text/css' end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index d37634964..59664373d 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -61,21 +61,13 @@ module AccountsHelper end end - def account_badge(account, all: false) + def account_badge(account) if account.bot? content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles') elsif account.group? content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles') - elsif (Setting.show_staff_badge && account.user_staff?) || all - content_tag(:div, class: 'roles') do - if all && !account.user_staff? - content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role') - elsif account.user_admin? - content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin') - elsif account.user_moderator? - content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator') - end - end + elsif account.user_role&.highlighted? + content_tag(:div, content_tag(:div, account.user_role.name, class: "account-role user-role-#{account.user_role.id}"), class: 'roles') end end diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index ab8755be0..d44da482d 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -6,8 +6,9 @@ import IconButton from './icon_button'; import DropdownMenuContainer from '../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me, isStaff } from '../initial_state'; +import { me } from '../initial_state'; import classNames from 'classnames'; +import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -55,6 +56,7 @@ class StatusActionBar extends ImmutablePureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -306,7 +308,7 @@ class StatusActionBar extends ImmutablePureComponent { } } - if (isStaff) { + if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 0c3f6afa8..f4bef4686 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -26,6 +26,7 @@ const createIdentityContext = state => ({ signedIn: !!state.meta.me, accountId: state.meta.me, accessToken: state.meta.access_token, + permissions: state.role.permissions, }); export default class Mastodon extends React.PureComponent { diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 8e6b9f063..1ad9341c7 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Button from 'mastodon/components/button'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; +import { autoPlayGif, me } from 'mastodon/initial_state'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; import IconButton from 'mastodon/components/icon_button'; @@ -14,6 +14,7 @@ import ShortNumber from 'mastodon/components/short_number'; import { NavLink } from 'react-router-dom'; import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container'; import AccountNoteContainer from '../containers/account_note_container'; +import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, @@ -64,6 +65,10 @@ const dateFormatOptions = { export default @injectIntl class Header extends ImmutablePureComponent { + static contextTypes = { + identity: PropTypes.object, + }; + static propTypes = { account: ImmutablePropTypes.map, identity_props: ImmutablePropTypes.list, @@ -241,7 +246,7 @@ class Header extends ImmutablePureComponent { } } - if (account.get('id') !== me && isStaff) { + if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${account.get('id')}` }); } diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 61df79b46..b1618c1b4 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -5,10 +5,14 @@ import { FormattedMessage } from 'react-intl'; import ClearColumnButton from './clear_column_button'; import GrantPermissionButton from './grant_permission_button'; import SettingToggle from './setting_toggle'; -import { isStaff } from 'mastodon/initial_state'; +import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'mastodon/permissions'; export default class ColumnSettings extends React.PureComponent { + static contextTypes = { + identity: PropTypes.object, + }; + static propTypes = { settings: ImmutablePropTypes.map.isRequired, pushSettings: ImmutablePropTypes.map.isRequired, @@ -166,7 +170,7 @@ export default class ColumnSettings extends React.PureComponent { - {isStaff && ( + {(this.context.identity.permissions & PERMISSION_MANAGE_USERS === PERMISSION_MANAGE_USERS) && (
@@ -179,7 +183,7 @@ export default class ColumnSettings extends React.PureComponent {
)} - {isStaff && ( + {(this.context.identity.permissions & PERMISSION_MANAGE_REPORTS === PERMISSION_MANAGE_REPORTS) && (
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index edaff959e..50bda69f8 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -5,8 +5,9 @@ import IconButton from '../../../components/icon_button'; import ImmutablePropTypes from 'react-immutable-proptypes'; import DropdownMenuContainer from '../../../containers/dropdown_menu_container'; import { defineMessages, injectIntl } from 'react-intl'; -import { me, isStaff } from '../../../initial_state'; +import { me } from '../../../initial_state'; import classNames from 'classnames'; +import { PERMISSION_MANAGE_USERS } from 'mastodon/permissions'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -50,6 +51,7 @@ class ActionBar extends React.PureComponent { static contextTypes = { router: PropTypes.object, + identity: PropTypes.object, }; static propTypes = { @@ -248,7 +250,7 @@ class ActionBar extends React.PureComponent { } } - if (isStaff) { + if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { menu.push(null); menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js index edf1104c4..bbb9b122a 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.js +++ b/app/javascript/mastodon/features/ui/components/link_footer.js @@ -3,9 +3,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; -import { invitesEnabled, limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state'; +import { limitedFederationMode, version, repository, source_url, profile_directory as profileDirectory } from 'mastodon/initial_state'; import { logOut } from 'mastodon/utils/log_out'; import { openModal } from 'mastodon/actions/modal'; +import { PERMISSION_INVITE_USERS } from 'mastodon/permissions'; const messages = defineMessages({ logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, @@ -27,6 +28,10 @@ export default @injectIntl @connect(null, mapDispatchToProps) class LinkFooter extends React.PureComponent { + static contextTypes = { + identity: PropTypes.object, + }; + static propTypes = { withHotkeys: PropTypes.bool, onLogout: PropTypes.func.isRequired, @@ -48,7 +53,7 @@ class LinkFooter extends React.PureComponent { return (
    - {invitesEnabled &&
  • ·
  • } + {((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) &&
  • ·
  • } {withHotkeys &&
  • ·
  • }
  • ·
  • {!limitedFederationMode &&
  • ·
  • } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index a6d54f134..709975270 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -12,14 +12,12 @@ export const boostModal = getMeta('boost_modal'); export const deleteModal = getMeta('delete_modal'); export const me = getMeta('me'); export const searchEnabled = getMeta('search_enabled'); -export const invitesEnabled = getMeta('invites_enabled'); export const limitedFederationMode = getMeta('limited_federation_mode'); export const repository = getMeta('repository'); export const source_url = getMeta('source_url'); export const version = getMeta('version'); export const mascot = getMeta('mascot'); export const profile_directory = getMeta('profile_directory'); -export const isStaff = getMeta('is_staff'); export const forceSingleColumn = !getMeta('advanced_layout'); export const useBlurhash = getMeta('use_blurhash'); export const usePendingItems = getMeta('use_pending_items'); diff --git a/app/javascript/mastodon/permissions.js b/app/javascript/mastodon/permissions.js new file mode 100644 index 000000000..752ddd6c5 --- /dev/null +++ b/app/javascript/mastodon/permissions.js @@ -0,0 +1,3 @@ +export const PERMISSION_INVITE_USERS = 0x0000000000010000; +export const PERMISSION_MANAGE_USERS = 0x0000000000000400; +export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010; diff --git a/app/javascript/mastodon/reducers/meta.js b/app/javascript/mastodon/reducers/meta.js index 65becc44f..5040a340f 100644 --- a/app/javascript/mastodon/reducers/meta.js +++ b/app/javascript/mastodon/reducers/meta.js @@ -7,12 +7,13 @@ const initialState = ImmutableMap({ streaming_api_base_url: null, access_token: null, layout: layoutFromWindow(), + permissions: '0', }); export default function meta(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: - return state.merge(action.state.get('meta')); + return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions'])); case APP_LAYOUT_CHANGE: return state.set('layout', action.layout); default: diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4ce5cd101..1c5494cde 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -924,6 +924,10 @@ a.name-tag, margin-top: 15px; } +.user-role { + color: var(--user-role-accent); +} + .announcements-list, .filters-list { border: 1px solid lighten($ui-base-color, 4%); @@ -960,6 +964,17 @@ a.name-tag, &__meta { padding: 0 15px; color: $dark-text-color; + + a { + color: inherit; + text-decoration: underline; + + &:hover, + &:focus, + &:active { + text-decoration: none; + } + } } &__action-bar { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index da699dd25..990903859 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -256,6 +256,10 @@ code { } } + .input.with_block_label.user_role_permissions_as_keys ul { + columns: unset; + } + .input.datetime .label_input select { display: inline-block; width: auto; diff --git a/app/lib/admin/system_check.rb b/app/lib/admin/system_check.rb index 877a42ef6..f512635ab 100644 --- a/app/lib/admin/system_check.rb +++ b/app/lib/admin/system_check.rb @@ -8,11 +8,11 @@ class Admin::SystemCheck Admin::SystemCheck::ElasticsearchCheck, ].freeze - def self.perform + def self.perform(current_user) ACTIVE_CHECKS.each_with_object([]) do |klass, arr| - check = klass.new + check = klass.new(current_user) - if check.pass? + if check.skip? || check.pass? arr else arr << check.message diff --git a/app/lib/admin/system_check/base_check.rb b/app/lib/admin/system_check/base_check.rb index fcad8daca..c2974c218 100644 --- a/app/lib/admin/system_check/base_check.rb +++ b/app/lib/admin/system_check/base_check.rb @@ -1,6 +1,16 @@ # frozen_string_literal: true class Admin::SystemCheck::BaseCheck + attr_reader :current_user + + def initialize(current_user) + @current_user = current_user + end + + def skip? + false + end + def pass? raise NotImplementedError end diff --git a/app/lib/admin/system_check/database_schema_check.rb b/app/lib/admin/system_check/database_schema_check.rb index b93d1954e..c2f01fd55 100644 --- a/app/lib/admin/system_check/database_schema_check.rb +++ b/app/lib/admin/system_check/database_schema_check.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Admin::SystemCheck::DatabaseSchemaCheck < Admin::SystemCheck::BaseCheck + def skip? + !current_user.can?(:view_devops) + end + def pass? !ActiveRecord::Base.connection.migration_context.needs_migration? end diff --git a/app/lib/admin/system_check/elasticsearch_check.rb b/app/lib/admin/system_check/elasticsearch_check.rb index 1b48a5415..8aee18267 100644 --- a/app/lib/admin/system_check/elasticsearch_check.rb +++ b/app/lib/admin/system_check/elasticsearch_check.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck + def skip? + !current_user.can?(:view_devops) + end + def pass? return true unless Chewy.enabled? @@ -32,8 +36,4 @@ class Admin::SystemCheck::ElasticsearchCheck < Admin::SystemCheck::BaseCheck def compatible_version? Gem::Version.new(running_version) >= Gem::Version.new(required_version) end - - def missing_queues - @missing_queues ||= Sidekiq::ProcessSet.new.reduce(SIDEKIQ_QUEUES) { |queues, process| queues - process['queues'] } - end end diff --git a/app/lib/admin/system_check/rules_check.rb b/app/lib/admin/system_check/rules_check.rb index 1fbdf955d..8206a5df3 100644 --- a/app/lib/admin/system_check/rules_check.rb +++ b/app/lib/admin/system_check/rules_check.rb @@ -3,6 +3,10 @@ class Admin::SystemCheck::RulesCheck < Admin::SystemCheck::BaseCheck include RoutingHelper + def skip? + !current_user.can?(:manage_rules) + end + def pass? Rule.kept.exists? end diff --git a/app/lib/admin/system_check/sidekiq_process_check.rb b/app/lib/admin/system_check/sidekiq_process_check.rb index 22446edaf..648811d6c 100644 --- a/app/lib/admin/system_check/sidekiq_process_check.rb +++ b/app/lib/admin/system_check/sidekiq_process_check.rb @@ -9,6 +9,10 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck scheduler ).freeze + def skip? + !current_user.can?(:view_devops) + end + def pass? missing_queues.empty? end diff --git a/app/models/account.rb b/app/models/account.rb index 730ef6293..628692d22 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -116,7 +116,7 @@ class Account < ApplicationRecord scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) } scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) } scope :popular, -> { order('account_stats.followers_count desc') } - scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } + scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } @@ -132,9 +132,6 @@ class Account < ApplicationRecord :unconfirmed?, :unconfirmed_or_pending?, :role, - :admin?, - :moderator?, - :staff?, :locale, :shows_application?, to: :user, @@ -454,7 +451,7 @@ class Account < ApplicationRecord DeliveryFailureTracker.without_unavailable(urls) end - def search_for(terms, limit = 10, offset = 0) + def search_for(terms, limit: 10, offset: 0) tsquery = generate_query_for_search(terms) sql = <<-SQL.squish @@ -476,7 +473,7 @@ class Account < ApplicationRecord records end - def advanced_search_for(terms, account, limit = 10, following = false, offset = 0) + def advanced_search_for(terms, account, limit: 10, following: false, offset: 0) tsquery = generate_query_for_search(terms) sql = advanced_search_for_sql_template(following) records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery]) diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index ec309ce09..e214e0bad 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -4,7 +4,7 @@ class AccountFilter KEYS = %i( origin status - permissions + role_ids username by_domain display_name @@ -26,7 +26,7 @@ class AccountFilter params.each do |key, value| next if key.to_s == 'page' - scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + scope.merge!(scope_for(key, value)) if value.present? end scope @@ -38,18 +38,18 @@ class AccountFilter case key.to_s when 'origin' origin_scope(value) - when 'permissions' - permissions_scope(value) + when 'role_ids' + role_scope(value) when 'status' status_scope(value) when 'by_domain' - Account.where(domain: value) + Account.where(domain: value.to_s) when 'username' - Account.matches_username(value) + Account.matches_username(value.to_s) when 'display_name' - Account.matches_display_name(value) + Account.matches_display_name(value.to_s) when 'email' - accounts_with_users.merge(User.matches_email(value)) + accounts_with_users.merge(User.matches_email(value.to_s)) when 'ip' valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none when 'invited_by' @@ -104,13 +104,8 @@ class AccountFilter Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s)) end - def permissions_scope(value) - case value.to_s - when 'staff' - accounts_with_users.merge(User.staff) - else - raise "Unknown permissions: #{value}" - end + def role_scope(value) + accounts_with_users.merge(User.where(role_id: Array(value).map(&:to_s))) end def accounts_with_users @@ -118,7 +113,7 @@ class AccountFilter end def valid_ip?(value) - IPAddr.new(value) && true + IPAddr.new(value.to_s) && true rescue IPAddr::InvalidAddressError false end diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb deleted file mode 100644 index a42b4a172..000000000 --- a/app/models/concerns/user_roles.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module UserRoles - extend ActiveSupport::Concern - - included do - scope :admins, -> { where(admin: true) } - scope :moderators, -> { where(moderator: true) } - scope :staff, -> { admins.or(moderators) } - end - - def staff? - admin? || moderator? - end - - def role=(value) - case value - when 'admin' - self.admin = true - self.moderator = false - when 'moderator' - self.admin = false - self.moderator = true - else - self.admin = false - self.moderator = false - end - end - - def role - if admin? - 'admin' - elsif moderator? - 'moderator' - else - 'user' - end - end - - def role?(role) - case role - when 'user' - true - when 'moderator' - staff? - when 'admin' - admin? - else - false - end - end - - def promote! - if moderator? - update!(moderator: false, admin: true) - elsif !admin? - update!(moderator: true) - end - end - - def demote! - if admin? - update!(admin: false, moderator: true) - elsif moderator? - update!(moderator: false) - end - end -end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 6fc7c56fd..97fabc6ac 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -15,10 +15,8 @@ class Form::AdminSettings closed_registrations_message open_deletion timeline_preview - show_staff_badge bootstrap_timeline_accounts theme - min_invite_role activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page @@ -39,7 +37,6 @@ class Form::AdminSettings BOOLEAN_KEYS = %i( open_deletion timeline_preview - show_staff_badge activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page @@ -62,7 +59,6 @@ class Form::AdminSettings validates :site_short_description, :site_description, html: { wrap_with: :p } validates :site_extended_description, :site_terms, :closed_registrations_message, html: true validates :registrations_mode, inclusion: { in: %w(open approved none) } - validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) } validates :site_contact_email, :site_contact_username, presence: true validates :site_contact_username, existing_username: true validates :bootstrap_timeline_accounts, existing_username: { multiple: true } diff --git a/app/models/trends.rb b/app/models/trends.rb index f8864e55f..d886be89a 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -34,7 +34,7 @@ module Trends return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty? - User.staff.includes(:account).find_each do |user| + User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user| AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails? end end diff --git a/app/models/user.rb b/app/models/user.rb index 81f6a58f6..60abaf77e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -37,6 +37,7 @@ # sign_in_token_sent_at :datetime # webauthn_id :string # sign_up_ip :inet +# role_id :bigint(8) # class User < ApplicationRecord @@ -50,7 +51,6 @@ class User < ApplicationRecord ) include Settings::Extend - include UserRoles include Redisable include LanguagesHelper @@ -79,6 +79,7 @@ class User < ApplicationRecord belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true + belongs_to :role, class_name: 'UserRole', optional: true accepts_nested_attributes_for :account has_many :applications, class_name: 'Doorkeeper::Application', as: :owner @@ -103,6 +104,7 @@ class User < ApplicationRecord validates_with RegistrationFormTimeValidator, on: :create validates :website, absence: true, on: :create validates :confirm_password, absence: true, on: :create + validate :validate_role_elevation scope :recent, -> { order(id: :desc) } scope :pending, -> { where(approved: false) } @@ -117,6 +119,7 @@ class User < ApplicationRecord scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } before_validation :sanitize_languages + before_validation :sanitize_role before_create :set_approved after_commit :send_pending_devise_notifications after_create_commit :trigger_webhooks @@ -135,8 +138,28 @@ class User < ApplicationRecord :disable_swiping, :always_send_emails, to: :settings, prefix: :setting, allow_nil: false + delegate :can?, to: :role + attr_reader :invite_code - attr_writer :external, :bypass_invite_request_check + attr_writer :external, :bypass_invite_request_check, :current_account + + def self.those_who_can(*any_of_privileges) + matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id) + + if matching_role_ids.empty? + none + else + where(role_id: matching_role_ids) + end + end + + def role + if role_id.nil? + UserRole.everyone + else + super + end + end def confirmed? confirmed_at.present? @@ -441,6 +464,11 @@ class User < ApplicationRecord self.chosen_languages = nil if chosen_languages.empty? end + def sanitize_role + return if role.nil? + self.role = nil if role.everyone? + end + def prepare_new_user! BootstrapTimelineWorker.perform_async(account_id) ActivityTracker.increment('activity:accounts:local') @@ -453,7 +481,7 @@ class User < ApplicationRecord end def notify_staff_about_pending_account! - User.staff.includes(:account).find_each do |u| + User.those_who_can(:manage_users).includes(:account).find_each do |u| next unless u.allows_pending_account_emails? AdminMailer.new_pending_account(u.account, self).deliver_later end @@ -471,6 +499,10 @@ class User < ApplicationRecord email_changed? && !external? && !(Rails.env.test? || Rails.env.development?) end + def validate_role_elevation + errors.add(:role_id, :elevated) if defined?(@current_account) && role&.overrides?(@current_account&.user_role) + end + def invite_text_required? Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check? end diff --git a/app/models/user_role.rb b/app/models/user_role.rb new file mode 100644 index 000000000..833b96d71 --- /dev/null +++ b/app/models/user_role.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: user_roles +# +# id :bigint(8) not null, primary key +# name :string default(""), not null +# color :string default(""), not null +# position :integer default(0), not null +# permissions :bigint(8) default(0), not null +# highlighted :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class UserRole < ApplicationRecord + FLAGS = { + administrator: (1 << 0), + view_devops: (1 << 1), + view_audit_log: (1 << 2), + view_dashboard: (1 << 3), + manage_reports: (1 << 4), + manage_federation: (1 << 5), + manage_settings: (1 << 6), + manage_blocks: (1 << 7), + manage_taxonomies: (1 << 8), + manage_appeals: (1 << 9), + manage_users: (1 << 10), + manage_invites: (1 << 11), + manage_rules: (1 << 12), + manage_announcements: (1 << 13), + manage_custom_emojis: (1 << 14), + manage_webhooks: (1 << 15), + invite_users: (1 << 16), + manage_roles: (1 << 17), + manage_user_access: (1 << 18), + delete_user_data: (1 << 19), + }.freeze + + module Flags + NONE = 0 + ALL = FLAGS.values.reduce(&:|) + + DEFAULT = FLAGS[:invite_users] + + CATEGORIES = { + invites: %i( + invite_users + ).freeze, + + moderation: %w( + view_dashboard + view_audit_log + manage_users + manage_user_access + delete_user_data + manage_reports + manage_appeals + manage_federation + manage_blocks + manage_taxonomies + manage_invites + ).freeze, + + administration: %w( + manage_settings + manage_rules + manage_roles + manage_webhooks + manage_custom_emojis + manage_announcements + ).freeze, + + devops: %w( + view_devops + ).freeze, + + special: %i( + administrator + ).freeze, + }.freeze + end + + attr_writer :current_account + + validates :name, presence: true, unless: :everyone? + validates :color, format: { with: /\A#?(?:[A-F0-9]{3}){1,2}\z/i }, unless: -> { color.blank? } + + validate :validate_permissions_elevation + validate :validate_position_elevation + validate :validate_dangerous_permissions + + before_validation :set_position + + scope :assignable, -> { where.not(id: -99).order(position: :asc) } + + has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify + + def self.nobody + @nobody ||= UserRole.new(permissions: Flags::NONE, position: -1) + end + + def self.everyone + UserRole.find(-99) + rescue ActiveRecord::RecordNotFound + UserRole.create!(id: -99, permissions: Flags::DEFAULT) + end + + def self.that_can(*any_of_privileges) + all.select { |role| role.can?(*any_of_privileges) } + end + + def everyone? + id == -99 + end + + def nobody? + id.nil? + end + + def permissions_as_keys + FLAGS.keys.select { |privilege| permissions & FLAGS[privilege] == FLAGS[privilege] }.map(&:to_s) + end + + def permissions_as_keys=(value) + self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask } + end + + def can?(*any_of_privileges) + any_of_privileges.any? { |privilege| in_permissions?(privilege) } + end + + def overrides?(other_role) + other_role.nil? || position > other_role.position + end + + def computed_permissions + # If called on the everyone role, no further computation needed + return permissions if everyone? + + # If called on the nobody role, no permissions are there to be given + return Flags::NONE if nobody? + + # Otherwise, compute permissions based on special conditions + @computed_permissions ||= begin + permissions = self.class.everyone.permissions | self.permissions + + if permissions & FLAGS[:administrator] == FLAGS[:administrator] + Flags::ALL + else + permissions + end + end + end + + private + + def in_permissions?(privilege) + raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege) + computed_permissions & FLAGS[privilege] == FLAGS[privilege] + end + + def set_position + self.position = -1 if everyone? + end + + def validate_permissions_elevation + errors.add(:permissions_as_keys, :elevated) if defined?(@current_account) && @current_account.user_role.computed_permissions & permissions != permissions + end + + def validate_position_elevation + errors.add(:position, :elevated) if defined?(@current_account) && @current_account.user_role.position < position + end + + def validate_dangerous_permissions + errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions + end +end diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb index 885411a5b..310ce854c 100644 --- a/app/policies/account_moderation_note_policy.rb +++ b/app/policies/account_moderation_note_policy.rb @@ -2,11 +2,11 @@ class AccountModerationNotePolicy < ApplicationPolicy def create? - staff? + role.can?(:manage_reports) end def destroy? - admin? || owner? + owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role)) end private diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index cc23771e7..a744af81d 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -2,74 +2,66 @@ class AccountPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_users) end def show? - staff? + role.can?(:manage_users) end def warn? - staff? && !record.user&.staff? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) end def suspend? - staff? && !record.user&.staff? && !record.instance_actor? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) && !record.instance_actor? end def destroy? - record.suspended_temporarily? && admin? + record.suspended_temporarily? && role.can?(:delete_user_data) end def unsuspend? - staff? && record.suspension_origin_local? + role.can?(:manage_users) && record.suspension_origin_local? end def sensitive? - staff? && !record.user&.staff? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) end def unsensitive? - staff? + role.can?(:manage_users) end def silence? - staff? && !record.user&.staff? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) end def unsilence? - staff? + role.can?(:manage_users) end def redownload? - admin? + role.can?(:manage_federation) end def remove_avatar? - staff? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) end def remove_header? - staff? - end - - def subscribe? - admin? - end - - def unsubscribe? - admin? + role.can?(:manage_users, :manage_reports) && role.overrides?(record.user_role) end def memorialize? - admin? && !record.user&.admin? && !record.instance_actor? + role.can?(:delete_user_data) && role.overrides?(record.user_role) && !record.instance_actor? end def unblock_email? - staff? + role.can?(:manage_users) end def review? - staff? + role.can?(:manage_taxonomies) end end diff --git a/app/policies/account_warning_policy.rb b/app/policies/account_warning_policy.rb index 65707dfa7..4f8df7420 100644 --- a/app/policies/account_warning_policy.rb +++ b/app/policies/account_warning_policy.rb @@ -2,7 +2,7 @@ class AccountWarningPolicy < ApplicationPolicy def show? - target? || staff? + target? || role.can?(:manage_appeals) end def appeal? diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb index bccbd33ef..59514e951 100644 --- a/app/policies/account_warning_preset_policy.rb +++ b/app/policies/account_warning_preset_policy.rb @@ -2,18 +2,18 @@ class AccountWarningPresetPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_settings) end def create? - staff? + role.can?(:manage_settings) end def update? - staff? + role.can?(:manage_settings) end def destroy? - staff? + role.can?(:manage_settings) end end diff --git a/app/policies/announcement_policy.rb b/app/policies/announcement_policy.rb index 0a4e4575c..b5dc6a18a 100644 --- a/app/policies/announcement_policy.rb +++ b/app/policies/announcement_policy.rb @@ -2,18 +2,18 @@ class AnnouncementPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_announcements) end def create? - admin? + role.can?(:manage_announcements) end def update? - admin? + role.can?(:manage_announcements) end def destroy? - admin? + role.can?(:manage_announcements) end end diff --git a/app/policies/appeal_policy.rb b/app/policies/appeal_policy.rb index a25187172..7466b334b 100644 --- a/app/policies/appeal_policy.rb +++ b/app/policies/appeal_policy.rb @@ -2,12 +2,14 @@ class AppealPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_appeals) end def approve? - record.pending? && staff? + record.pending? && role.can?(:manage_appeals) end - alias reject? approve? + def reject? + record.pending? && role.can?(:manage_appeals) + end end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index d1de5e81a..163b81e9e 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -8,8 +8,6 @@ class ApplicationPolicy @record = record end - delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true - private def current_user @@ -19,4 +17,8 @@ class ApplicationPolicy def user_signed_in? !current_user.nil? end + + def role + current_user&.role || UserRole.nobody + end end diff --git a/app/policies/audit_log_policy.rb b/app/policies/audit_log_policy.rb new file mode 100644 index 000000000..f78aa9a8e --- /dev/null +++ b/app/policies/audit_log_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AuditLogPolicy < ApplicationPolicy + def index? + role.can?(:view_audit_log) + end +end diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb index a8c3cbc73..18de71c19 100644 --- a/app/policies/custom_emoji_policy.rb +++ b/app/policies/custom_emoji_policy.rb @@ -2,30 +2,30 @@ class CustomEmojiPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_custom_emojis) end def create? - admin? + role.can?(:manage_custom_emojis) end def update? - admin? + role.can?(:manage_custom_emojis) end def copy? - admin? + role.can?(:manage_custom_emojis) end def enable? - staff? + role.can?(:manage_custom_emojis) end def disable? - staff? + role.can?(:manage_custom_emojis) end def destroy? - admin? + role.can?(:manage_custom_emojis) end end diff --git a/app/policies/dashboard_policy.rb b/app/policies/dashboard_policy.rb new file mode 100644 index 000000000..3df1c3088 --- /dev/null +++ b/app/policies/dashboard_policy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DashboardPolicy < ApplicationPolicy + def index? + role.can?(:view_dashboard) + end +end diff --git a/app/policies/delivery_policy.rb b/app/policies/delivery_policy.rb index 24d06c168..f6ba2eb18 100644 --- a/app/policies/delivery_policy.rb +++ b/app/policies/delivery_policy.rb @@ -2,14 +2,14 @@ class DeliveryPolicy < ApplicationPolicy def clear_delivery_errors? - admin? + role.can?(:manage_federation) end def restart_delivery? - admin? + role.can?(:manage_federation) end def stop_delivery? - admin? + role.can?(:manage_federation) end end diff --git a/app/policies/domain_allow_policy.rb b/app/policies/domain_allow_policy.rb index 7a5b5d780..45c797ecd 100644 --- a/app/policies/domain_allow_policy.rb +++ b/app/policies/domain_allow_policy.rb @@ -2,18 +2,18 @@ class DomainAllowPolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_federation) end def show? - admin? + role.can?(:manage_federation) end def create? - admin? + role.can?(:manage_federation) end def destroy? - admin? + role.can?(:manage_federation) end end diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb index 543259cce..0fea2e035 100644 --- a/app/policies/domain_block_policy.rb +++ b/app/policies/domain_block_policy.rb @@ -2,22 +2,22 @@ class DomainBlockPolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_federation) end def show? - admin? + role.can?(:manage_federation) end def create? - admin? + role.can?(:manage_federation) end def update? - admin? + role.can?(:manage_federation) end def destroy? - admin? + role.can?(:manage_federation) end end diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb index 5a75ee183..1a0ddfa87 100644 --- a/app/policies/email_domain_block_policy.rb +++ b/app/policies/email_domain_block_policy.rb @@ -2,14 +2,14 @@ class EmailDomainBlockPolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_blocks) end def create? - admin? + role.can?(:manage_blocks) end def destroy? - admin? + role.can?(:manage_blocks) end end diff --git a/app/policies/follow_recommendation_policy.rb b/app/policies/follow_recommendation_policy.rb index 68cd0e547..9245733ea 100644 --- a/app/policies/follow_recommendation_policy.rb +++ b/app/policies/follow_recommendation_policy.rb @@ -2,14 +2,14 @@ class FollowRecommendationPolicy < ApplicationPolicy def show? - staff? + role.can?(:manage_taxonomies) end def suppress? - staff? + role.can?(:manage_taxonomies) end def unsuppress? - staff? + role.can?(:manage_taxonomies) end end diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb index 801ca162e..b15e123fe 100644 --- a/app/policies/instance_policy.rb +++ b/app/policies/instance_policy.rb @@ -2,14 +2,14 @@ class InstancePolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_federation) end def show? - admin? + role.can?(:manage_federation) end def destroy? - admin? + role.can?(:manage_federation) end end diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb index 14236f78b..24eacd08e 100644 --- a/app/policies/invite_policy.rb +++ b/app/policies/invite_policy.rb @@ -2,19 +2,19 @@ class InvitePolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_invites) end def create? - min_required_role? + role.can?(:invite_users) end def deactivate_all? - admin? + role.can?(:manage_invites) end def destroy? - owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?) + owner? || role.can?(:manage_invites) end private @@ -22,8 +22,4 @@ class InvitePolicy < ApplicationPolicy def owner? record.user_id == current_user&.id end - - def min_required_role? - current_user&.role?(Setting.min_invite_role) - end end diff --git a/app/policies/ip_block_policy.rb b/app/policies/ip_block_policy.rb index 34dbd746a..1abc97ad8 100644 --- a/app/policies/ip_block_policy.rb +++ b/app/policies/ip_block_policy.rb @@ -2,14 +2,14 @@ class IpBlockPolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_blocks) end def create? - admin? + role.can?(:manage_blocks) end def destroy? - admin? + role.can?(:manage_blocks) end end diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb index 0410987e4..a7bb41634 100644 --- a/app/policies/preview_card_policy.rb +++ b/app/policies/preview_card_policy.rb @@ -2,10 +2,10 @@ class PreviewCardPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_taxonomies) end def review? - staff? + role.can?(:manage_taxonomies) end end diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb index 44d2ad5cf..131ccb5dd 100644 --- a/app/policies/preview_card_provider_policy.rb +++ b/app/policies/preview_card_provider_policy.rb @@ -2,10 +2,10 @@ class PreviewCardProviderPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_taxonomies) end def review? - staff? + role.can?(:manage_taxonomies) end end diff --git a/app/policies/relay_policy.rb b/app/policies/relay_policy.rb index bd75e2197..4305bcfaa 100644 --- a/app/policies/relay_policy.rb +++ b/app/policies/relay_policy.rb @@ -2,6 +2,6 @@ class RelayPolicy < ApplicationPolicy def update? - admin? + role.can?(:manage_federation) end end diff --git a/app/policies/report_note_policy.rb b/app/policies/report_note_policy.rb index 694bc096b..dc31416e8 100644 --- a/app/policies/report_note_policy.rb +++ b/app/policies/report_note_policy.rb @@ -2,11 +2,11 @@ class ReportNotePolicy < ApplicationPolicy def create? - staff? + role.can?(:manage_reports) end def destroy? - admin? || owner? + owner? || (role.can?(:manage_reports) && role.overrides?(record.account.user_role)) end private diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb index 95b5c30c8..c9f7639bd 100644 --- a/app/policies/report_policy.rb +++ b/app/policies/report_policy.rb @@ -2,14 +2,14 @@ class ReportPolicy < ApplicationPolicy def update? - staff? + role.can?(:manage_reports) end def index? - staff? + role.can?(:manage_reports) end def show? - staff? + role.can?(:manage_reports) end end diff --git a/app/policies/rule_policy.rb b/app/policies/rule_policy.rb index 6a4def009..51b2a6977 100644 --- a/app/policies/rule_policy.rb +++ b/app/policies/rule_policy.rb @@ -2,18 +2,18 @@ class RulePolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_rules) end def create? - admin? + role.can?(:manage_rules) end def update? - admin? + role.can?(:manage_rules) end def destroy? - admin? + role.can?(:manage_rules) end end diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb index 874f97bab..2b052af27 100644 --- a/app/policies/settings_policy.rb +++ b/app/policies/settings_policy.rb @@ -2,14 +2,14 @@ class SettingsPolicy < ApplicationPolicy def update? - admin? + role.can?(:manage_settings) end def show? - admin? + role.can?(:manage_settings) end def destroy? - admin? + role.can?(:manage_settings) end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 400f1ec79..2f48b5d70 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -8,7 +8,7 @@ class StatusPolicy < ApplicationPolicy end def index? - staff? + role.can?(:manage_reports, :manage_users) end def show? @@ -32,17 +32,17 @@ class StatusPolicy < ApplicationPolicy end def destroy? - staff? || owned? + role.can?(:manage_reports) || owned? end alias unreblog? destroy? def update? - staff? || owned? + role.can?(:manage_reports) || owned? end def review? - staff? + role.can?(:manage_taxonomies) end private diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb index bdfcec0c9..bb1d37d6c 100644 --- a/app/policies/tag_policy.rb +++ b/app/policies/tag_policy.rb @@ -2,18 +2,18 @@ class TagPolicy < ApplicationPolicy def index? - staff? + role.can?(:manage_taxonomies) end def show? - staff? + role.can?(:manage_taxonomies) end def update? - staff? + role.can?(:manage_taxonomies) end def review? - staff? + role.can?(:manage_taxonomies) end end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 140905e1f..6751b8b8f 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -2,52 +2,38 @@ class UserPolicy < ApplicationPolicy def reset_password? - staff? && !record.staff? + role.can?(:manage_user_access) && role.overrides?(record.role) end def change_email? - staff? && !record.staff? + role.can?(:manage_user_access) && role.overrides?(record.role) end def disable_2fa? - admin? && !record.staff? + role.can?(:manage_user_access) && role.overrides?(record.role) + end + + def change_role? + role.can?(:manage_roles) && role.overrides?(record.role) end def confirm? - staff? && !record.confirmed? + role.can?(:manage_user_access) && !record.confirmed? end def enable? - staff? + role.can?(:manage_users) end def approve? - staff? && !record.approved? + role.can?(:manage_users) && !record.approved? end def reject? - staff? && !record.approved? + role.can?(:manage_users) && !record.approved? end def disable? - staff? && !record.admin? - end - - def promote? - admin? && promotable? - end - - def demote? - admin? && !record.admin? && demoteable? - end - - private - - def promotable? - record.approved? && (!record.staff? || !record.admin?) - end - - def demoteable? - record.staff? + role.can?(:manage_users) && role.overrides?(record.role) end end diff --git a/app/policies/user_role_policy.rb b/app/policies/user_role_policy.rb new file mode 100644 index 000000000..7019637fc --- /dev/null +++ b/app/policies/user_role_policy.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UserRolePolicy < ApplicationPolicy + def index? + role.can?(:manage_roles) + end + + def create? + role.can?(:manage_roles) + end + + def update? + role.can?(:manage_roles) && role.overrides?(record) + end + + def destroy? + !record.everyone? && role.can?(:manage_roles) && role.overrides?(record) && role.id != record.id + end +end diff --git a/app/policies/webhook_policy.rb b/app/policies/webhook_policy.rb index 2c55703a1..a2199a333 100644 --- a/app/policies/webhook_policy.rb +++ b/app/policies/webhook_policy.rb @@ -2,34 +2,34 @@ class WebhookPolicy < ApplicationPolicy def index? - admin? + role.can?(:manage_webhooks) end def create? - admin? + role.can?(:manage_webhooks) end def show? - admin? + role.can?(:manage_webhooks) end def update? - admin? + role.can?(:manage_webhooks) end def enable? - admin? + role.can?(:manage_webhooks) end def disable? - admin? + role.can?(:manage_webhooks) end def rotate_secret? - admin? + role.can?(:manage_webhooks) end def destroy? - admin? + role.can?(:manage_webhooks) end end diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 06482935c..129ea2a46 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -3,4 +3,8 @@ class InitialStatePresenter < ActiveModelSerializers::Model attributes :settings, :push_subscription, :token, :current_account, :admin, :text, :visibility + + def role + current_account&.user_role + end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 34190a91d..5eda87757 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -6,6 +6,7 @@ class InitialStateSerializer < ActiveModel::Serializer :languages has_one :push_subscription, serializer: REST::WebPushSubscriptionSerializer + has_one :role, serializer: REST::RoleSerializer def meta store = { @@ -19,7 +20,6 @@ class InitialStateSerializer < ActiveModel::Serializer repository: Mastodon::Version.repository, source_url: Mastodon::Version.source_url, version: Mastodon::Version.to_s, - invites_enabled: Setting.min_invite_role == 'user', limited_federation_mode: Rails.configuration.x.whitelist_mode, mascot: instance_presenter.mascot&.file&.url, profile_directory: Setting.profile_directory, @@ -39,7 +39,6 @@ class InitialStateSerializer < ActiveModel::Serializer store[:advanced_layout] = object.current_account.user.setting_advanced_layout store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_pending_items] = object.current_account.user.setting_use_pending_items - store[:is_staff] = object.current_account.user.staff? store[:trends] = Setting.trends && object.current_account.user.setting_trends store[:crop_images] = object.current_account.user.setting_crop_images else diff --git a/app/serializers/rest/credential_account_serializer.rb b/app/serializers/rest/credential_account_serializer.rb index be0d763dc..27e1db207 100644 --- a/app/serializers/rest/credential_account_serializer.rb +++ b/app/serializers/rest/credential_account_serializer.rb @@ -3,6 +3,8 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer attributes :source + has_one :role, serializer: REST::RoleSerializer + def source user = object.user @@ -15,4 +17,8 @@ class REST::CredentialAccountSerializer < REST::AccountSerializer follow_requests_count: FollowRequest.where(target_account: object).limit(40).count, } end + + def role + object.user_role + end end diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb index f5dec0dac..9cc245422 100644 --- a/app/serializers/rest/instance_serializer.rb +++ b/app/serializers/rest/instance_serializer.rb @@ -93,7 +93,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer end def invites_enabled - Setting.min_invite_role == 'user' + UserRole.everyone.can?(:invite_users) end private diff --git a/app/serializers/rest/role_serializer.rb b/app/serializers/rest/role_serializer.rb new file mode 100644 index 000000000..5b81c6e04 --- /dev/null +++ b/app/serializers/rest/role_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class REST::RoleSerializer < ActiveModel::Serializer + attributes :id, :name, :permissions, :color, :highlighted + + def id + object.id.to_s + end + + def permissions + object.computed_permissions.to_s + end +end diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index 6fe4b6593..4dcae20eb 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -61,11 +61,11 @@ class AccountSearchService < BaseService end def advanced_search_results - Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, options[:following], offset) + Account.advanced_search_for(terms_for_query, account, limit: limit_for_non_exact_results, following: options[:following], offset: offset) end def simple_search_results - Account.search_for(terms_for_query, limit_for_non_exact_results, offset) + Account.search_for(terms_for_query, limit: limit_for_non_exact_results, offset: offset) end def from_elasticsearch diff --git a/app/services/appeal_service.rb b/app/services/appeal_service.rb index cef9be05f..399a053d6 100644 --- a/app/services/appeal_service.rb +++ b/app/services/appeal_service.rb @@ -22,7 +22,7 @@ class AppealService < BaseService end def notify_staff! - User.staff.includes(:account).each do |u| + User.those_who_can(:manage_appeals).includes(:account).each do |u| AdminMailer.new_appeal(u.account, @appeal).deliver_later if u.allows_appeal_emails? end end diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb index a02e55a6d..126c0fa2e 100644 --- a/app/services/bootstrap_timeline_service.rb +++ b/app/services/bootstrap_timeline_service.rb @@ -17,7 +17,7 @@ class BootstrapTimelineService < BaseService end def notify_staff! - User.staff.includes(:account).find_each do |user| + User.those_who_can(:manage_users).includes(:account).find_each do |user| LocalNotificationWorker.perform_async(user.account_id, @source_account.id, 'Account', 'admin.sign_up') end end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index d30b33876..c7454fc60 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -76,7 +76,7 @@ class NotifyService < BaseService end def from_staff? - @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff? + @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user_role&.overrides?(@recipient.user_role) end def optional_non_following_and_direct? diff --git a/app/services/report_service.rb b/app/services/report_service.rb index bd67ff8d3..8c92cf334 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -38,7 +38,7 @@ class ReportService < BaseService def notify_staff! return if @report.unresolved_siblings? - User.staff.includes(:account).each do |u| + User.those_who_can(:manage_reports).includes(:account).each do |u| LocalNotificationWorker.perform_async(u.account_id, @report.id, 'Report', 'admin.report') AdminMailer.new_report(u.account, @report).deliver_later if u.allows_report_emails? end diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index 60e4894d0..7560fac7a 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -4,45 +4,36 @@ - content_for :header_tags do = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' -.filters - .filter-subset - %strong= t('admin.accounts.location.title') - %ul - %li= filter_link_to t('generic.all'), origin: nil - %li= filter_link_to t('admin.accounts.location.local'), origin: 'local' - %li= filter_link_to t('admin.accounts.location.remote'), origin: 'remote' - .filter-subset - %strong= t('admin.accounts.moderation.title') - %ul - %li= filter_link_to t('generic.all'), status: nil - %li= filter_link_to t('admin.accounts.moderation.active'), status: 'active' - %li= filter_link_to t('admin.accounts.moderation.suspended'), status: 'suspended' - %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), status: 'pending' - .filter-subset - %strong= t('admin.accounts.role') - %ul - %li= filter_link_to t('admin.accounts.moderation.all'), permissions: nil - %li= filter_link_to t('admin.accounts.roles.staff'), permissions: 'staff' - .filter-subset - %strong= t 'generic.order_by' - %ul - %li= filter_link_to t('relationships.most_recent'), order: nil - %li= filter_link_to t('relationships.last_active'), order: 'active' - = form_tag admin_accounts_url, method: 'GET', class: 'simple_form' do - .fields-group - - (AccountFilter::KEYS - %i(origin status permissions)).each do |key| - - if params[key].present? - = hidden_field_tag key, params[key] + .filters + .filter-subset.filter-subset--with-select + %strong= t('admin.accounts.location.title') + .input.select.optional + = select_tag :origin, options_for_select([[t('admin.accounts.location.local'), 'local'], [t('admin.accounts.location.remote'), 'remote']], params[:origin]), prompt: I18n.t('generic.all') + .filter-subset.filter-subset--with-select + %strong= t('admin.accounts.moderation.title') + .input.select.optional + = select_tag :status, options_for_select([[t('admin.accounts.moderation.active'), 'active'], [t('admin.accounts.moderation.silenced'), 'silenced'], [t('admin.accounts.moderation.suspended'), 'suspended'], [safe_join([t('admin.accounts.moderation.pending'), "(#{number_with_delimiter(User.pending.count)})"], ' '), 'pending']], params[:status]), prompt: I18n.t('generic.all') + .filter-subset.filter-subset--with-select + %strong= t('admin.accounts.role') + .input.select.optional + = select_tag :role_ids, options_from_collection_for_select(UserRole.assignable, :id, :name, params[:role_ids]), prompt: I18n.t('admin.accounts.moderation.all') + .filter-subset.filter-subset--with-select + %strong= t 'generic.order_by' + .input.select + = select_tag :order, options_for_select([[t('relationships.most_recent'), nil], [t('relationships.last_active'), 'active']], params[:order]) + .fields-group - %i(username by_domain display_name email ip).each do |key| - unless key == :by_domain && params[:origin] != 'remote' .input.string.optional = text_field_tag key, params[key], class: 'string optional', placeholder: I18n.t("admin.accounts.#{key}") - .actions - %button.button= t('admin.accounts.search') - = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative' + .actions + %button.button= t('admin.accounts.search') + = link_to t('admin.accounts.reset'), admin_accounts_path, class: 'button negative' + +%hr.spacer/ = form_for(@form, url: batch_admin_accounts_path) do |f| = hidden_field_tag :page, params[:page] || 1 diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index a69832b04..dc3b35956 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -92,10 +92,13 @@ %tr %th= t('admin.accounts.role') - %td= t("admin.accounts.roles.#{@account.user&.role}") %td - = table_link_to 'angle-double-up', t('admin.accounts.promote'), promote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:promote, @account.user) - = table_link_to 'angle-double-down', t('admin.accounts.demote'), demote_admin_account_role_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:demote, @account.user) + - if @account.user_role&.everyone? + = t('admin.accounts.no_role_assigned') + - else + = @account.user_role&.name + %td + = table_link_to 'vcard', t('admin.accounts.change_role.label'), admin_user_role_path(@account.user) if can?(:change_role, @account.user) %tr %th{ rowspan: can?(:create, :email_domain_block) ? 3 : 2 }= t('admin.accounts.email') diff --git a/app/views/admin/action_logs/index.html.haml b/app/views/admin/action_logs/index.html.haml index f611bfe9d..d8b7132f5 100644 --- a/app/views/admin/action_logs/index.html.haml +++ b/app/views/admin/action_logs/index.html.haml @@ -11,7 +11,7 @@ .filter-subset.filter-subset--with-select %strong= t('admin.action_logs.filter_by_user') .input.select.optional - = select_tag :account_id, options_from_collection_for_select(Account.joins(:user).merge(User.staff), :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all') + = select_tag :account_id, options_from_collection_for_select(@auditable_accounts, :id, :username, params[:account_id]), prompt: I18n.t('admin.accounts.moderation.all') .filter-subset.filter-subset--with-select %strong= t('admin.action_logs.filter_by_action') diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index ef4de602d..ab290912e 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -4,32 +4,33 @@ - content_for :header_tags do = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' -- content_for :heading_actions do - = l(@time_period.first) - = ' - ' - = l(@time_period.last) - -%p - = fa_icon 'info fw' - = t('admin.instances.totals_time_period_hint_html') - -.dashboard - .dashboard__item - = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain) - .dashboard__item - = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure') - .dashboard__item - = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure') - .dashboard__item - = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure') - .dashboard__item - = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure') - .dashboard__item - = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain) - .dashboard__item - = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension') - .dashboard__item - = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension') +- if current_user.can?(:view_dashboard) + - content_for :heading_actions do + = l(@time_period.first) + = ' - ' + = l(@time_period.last) + + %p + = fa_icon 'info fw' + = t('admin.instances.totals_time_period_hint_html') + + .dashboard + .dashboard__item + = react_admin_component :counter, measure: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_accounts_measure'), href: admin_accounts_path(origin: 'remote', by_domain: @instance.domain) + .dashboard__item + = react_admin_component :counter, measure: 'instance_statuses', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_statuses_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_media_attachments', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_media_attachments_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_follows', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_follows_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_followers', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_followers_measure') + .dashboard__item + = react_admin_component :counter, measure: 'instance_reports', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, label: t('admin.instances.dashboard.instance_reports_measure'), href: admin_reports_path(by_target_domain: @instance.domain) + .dashboard__item + = react_admin_component :dimension, dimension: 'instance_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_accounts_dimension') + .dashboard__item + = react_admin_component :dimension, dimension: 'instance_languages', start_at: @time_period.first, end_at: @time_period.last, params: { domain: @instance.domain }, limit: 8, label: t('admin.instances.dashboard.instance_languages_dimension') %hr.spacer/ diff --git a/app/views/admin/roles/_form.html.haml b/app/views/admin/roles/_form.html.haml new file mode 100644 index 000000000..68607ce68 --- /dev/null +++ b/app/views/admin/roles/_form.html.haml @@ -0,0 +1,37 @@ += simple_form_for @role, url: @role.new_record? ? admin_roles_path : admin_role_path(@role) do |f| + = render 'shared/error_messages', object: @role + + - if @role.everyone? + .flash-message.info + = t('admin.roles.everyone_full_description_html') + - else + .fields-group + = f.input :name, wrapper: :with_label + + .fields-group + = f.input :position, wrapper: :with_label + + .fields-group + = f.input :color, wrapper: :with_label, input_html: { placeholder: '#000000' } + + %hr.spacer/ + + .fields-group + = f.input :highlighted, wrapper: :with_label + + %hr.spacer/ + + .field-group + .input.with_block_label + %label= t('simple_form.labels.user_role.permissions_as_keys') + %span.hint= t('simple_form.hints.user_role.permissions_as_keys') + + - (@role.everyone? ? UserRole::Flags::CATEGORIES.slice(:invites) : UserRole::Flags::CATEGORIES).each do |category, permissions| + %h4= t(category, scope: 'admin.roles.categories') + + = f.input :permissions_as_keys, collection: permissions, wrapper: :with_block_label, include_blank: false, label_method: lambda { |privilege| safe_join([t("admin.roles.privileges.#{privilege}"), content_tag(:span, t("admin.roles.privileges.#{privilege}_description"), class: 'hint')]) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label: false, hint: false + + %hr.spacer/ + + .actions + = f.button :button, @role.new_record? ? t('admin.roles.add_new') : t('generic.save_changes'), type: :submit diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml new file mode 100644 index 000000000..6804f4f15 --- /dev/null +++ b/app/views/admin/roles/_role.html.haml @@ -0,0 +1,18 @@ +.announcements-list__item + = link_to edit_admin_role_path(role), class: 'announcements-list__item__title' do + %span.user-role{ class: "user-role-#{role.id}" } + = fa_icon 'users fw' + + - if role.everyone? + = t('admin.roles.everyone') + - else + = role.name + + .announcements-list__item__action-bar + .announcements-list__item__meta + - if role.everyone? + = t('admin.roles.everyone_full_description_html') + - else + = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_id: role.id) + • + %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size) diff --git a/app/views/admin/roles/edit.html.haml b/app/views/admin/roles/edit.html.haml new file mode 100644 index 000000000..659ccb8dc --- /dev/null +++ b/app/views/admin/roles/edit.html.haml @@ -0,0 +1,8 @@ +- content_for :page_title do + = t('admin.roles.edit', name: @role.everyone? ? t('admin.roles.everyone') : @role.name) + +- content_for :heading_actions do + = link_to t('admin.roles.delete'), admin_role_path(@role), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:destroy, @role) + += render partial: 'form' + diff --git a/app/views/admin/roles/index.html.haml b/app/views/admin/roles/index.html.haml new file mode 100644 index 000000000..4f6c511b4 --- /dev/null +++ b/app/views/admin/roles/index.html.haml @@ -0,0 +1,17 @@ +- content_for :page_title do + = t('admin.roles.title') + +- content_for :heading_actions do + = link_to t('admin.roles.add_new'), new_admin_role_path, class: 'button' if can?(:create, :user_role) + +%p= t('admin.roles.description_html') + +%hr.spacer/ + +.applications-list + = render partial: 'role', collection: @roles.select(&:everyone?) + +%hr.spacer/ + +.applications-list + = render partial: 'role', collection: @roles.reject(&:everyone?) diff --git a/app/views/admin/roles/new.html.haml b/app/views/admin/roles/new.html.haml new file mode 100644 index 000000000..821079271 --- /dev/null +++ b/app/views/admin/roles/new.html.haml @@ -0,0 +1,4 @@ +- content_for :page_title do + = t('admin.roles.add_new') + += render partial: 'form' diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 33bfc43d3..d7896bbc0 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -61,9 +61,6 @@ .fields-group = f.input :show_known_fediverse_at_about_page, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_known_fediverse_at_about_page.title'), hint: t('admin.settings.show_known_fediverse_at_about_page.desc_html') - .fields-group - = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html') - .fields-group = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html') @@ -91,9 +88,6 @@ %hr.spacer/ - .fields-group - = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - .fields-row .fields-row__column.fields-row__column-6.fields-group = f.input :show_domain_blocks, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml index df72bd5f5..fd9acce4a 100644 --- a/app/views/admin/tags/show.html.haml +++ b/app/views/admin/tags/show.html.haml @@ -4,49 +4,50 @@ - content_for :page_title do = "##{@tag.name}" -- content_for :heading_actions do - = l(@time_period.first) - = ' - ' - = l(@time_period.last) - -.dashboard - .dashboard__item - = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank' - .dashboard__item - = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure') - .dashboard__item - = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure') - .dashboard__item - = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension') - .dashboard__item - = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension') - .dashboard__item - = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do - - if @tag.usable? - %span= t('admin.trends.tags.usable') - = fa_icon 'check fw' - - else - %span= t('admin.trends.tags.not_usable') - = fa_icon 'lock fw' - - = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do - - if @tag.trendable? - %span= t('admin.trends.tags.trendable') - = fa_icon 'check fw' - - else - %span= t('admin.trends.tags.not_trendable') - = fa_icon 'lock fw' - - - = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do - - if @tag.listable? - %span= t('admin.trends.tags.listable') - = fa_icon 'check fw' - - else - %span= t('admin.trends.tags.not_listable') - = fa_icon 'lock fw' - -%hr.spacer/ +- if current_user.can?(:view_dashboard) + - content_for :heading_actions do + = l(@time_period.first) + = ' - ' + = l(@time_period.last) + + .dashboard + .dashboard__item + = react_admin_component :counter, measure: 'tag_accounts', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_accounts_measure'), href: tag_url(@tag), target: '_blank' + .dashboard__item + = react_admin_component :counter, measure: 'tag_uses', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_uses_measure') + .dashboard__item + = react_admin_component :counter, measure: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, label: t('admin.trends.tags.dashboard.tag_servers_measure') + .dashboard__item + = react_admin_component :dimension, dimension: 'tag_servers', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_servers_dimension') + .dashboard__item + = react_admin_component :dimension, dimension: 'tag_languages', start_at: @time_period.first, end_at: @time_period.last, params: { id: @tag.id }, limit: 8, label: t('admin.trends.tags.dashboard.tag_languages_dimension') + .dashboard__item + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.usable? ? 'positive' : 'negative'] do + - if @tag.usable? + %span= t('admin.trends.tags.usable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_usable') + = fa_icon 'lock fw' + + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.trendable? ? 'positive' : 'negative'] do + - if @tag.trendable? + %span= t('admin.trends.tags.trendable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_trendable') + = fa_icon 'lock fw' + + + = link_to admin_tag_path(@tag.id), class: ['dashboard__quick-access', @tag.listable? ? 'positive' : 'negative'] do + - if @tag.listable? + %span= t('admin.trends.tags.listable') + = fa_icon 'check fw' + - else + %span= t('admin.trends.tags.not_listable') + = fa_icon 'lock fw' + + %hr.spacer/ = simple_form_for @tag, url: admin_tag_path(@tag.id) do |f| = render 'shared/error_messages', object: @tag diff --git a/app/views/admin/users/roles/show.html.haml b/app/views/admin/users/roles/show.html.haml new file mode 100644 index 000000000..821618060 --- /dev/null +++ b/app/views/admin/users/roles/show.html.haml @@ -0,0 +1,9 @@ +- content_for :page_title do + = t('admin.accounts.change_role.title', username: @user.account.username) + += simple_form_for @user, url: admin_user_role_path(@user) do |f| + .fields-group + = f.association :role, wrapper: :with_block_label, collection: UserRole.assignable, label_method: :name, include_blank: I18n.t('admin.accounts.change_role.no_role') + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/custom_css/show.css.erb b/app/views/custom_css/show.css.erb new file mode 100644 index 000000000..521834832 --- /dev/null +++ b/app/views/custom_css/show.css.erb @@ -0,0 +1,10 @@ +<%- if Setting.custom_css.present? %> +<%= Setting.custom_css %> + +<%- end %> +<%- UserRole.where(highlighted: true).select { |role| role.color.present? }.each do |role| %> +.user-role-<%= role.id %> { + --user-role-accent: <%= role.color %>; +} + +<%- end %> diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 25fd5bc34..bf164223c 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -34,9 +34,7 @@ %meta{ name: 'style-nonce', content: request.content_security_policy_nonce } = stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style' - - - if Setting.custom_css.present? - = stylesheet_link_tag custom_css_path, host: request.host, media: 'all' + = stylesheet_link_tag custom_css_path, host: default_url_options[:host], media: 'all' = yield :header_tags diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index 42754a852..bc7afb993 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -18,12 +18,10 @@ = ff.input :reblog, as: :boolean, wrapper: :with_label = ff.input :favourite, as: :boolean, wrapper: :with_label = ff.input :mention, as: :boolean, wrapper: :with_label - - - if current_user.staff? - = ff.input :report, as: :boolean, wrapper: :with_label - = ff.input :appeal, as: :boolean, wrapper: :with_label - = ff.input :pending_account, as: :boolean, wrapper: :with_label - = ff.input :trending_tag, as: :boolean, wrapper: :with_label + = ff.input :report, as: :boolean, wrapper: :with_label if current_user.can?(:manage_reports) + = ff.input :appeal, as: :boolean, wrapper: :with_label if current_user.can?(:manage_appeals) + = ff.input :pending_account, as: :boolean, wrapper: :with_label if current_user.can?(:manage_users) + = ff.input :trending_tag, as: :boolean, wrapper: :with_label if current_user.can?(:manage_taxonomies) .fields-group = f.input :setting_always_send_emails, as: :boolean, wrapper: :with_label diff --git a/config/application.rb b/config/application.rb index 24fa2a978..06360832c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -44,6 +44,7 @@ require_relative '../lib/webpacker/helper_extensions' require_relative '../lib/rails/engine_extensions' require_relative '../lib/active_record/database_tasks_extensions' require_relative '../lib/active_record/batches' +require_relative '../lib/simple_navigation/item_extensions' Dotenv::Railtie.load diff --git a/config/locales/activerecord.en.yml b/config/locales/activerecord.en.yml index 720b0f5e3..daeed58b8 100644 --- a/config/locales/activerecord.en.yml +++ b/config/locales/activerecord.en.yml @@ -38,3 +38,12 @@ en: email: blocked: uses a disallowed e-mail provider unreachable: does not seem to exist + role_id: + elevated: cannot be higher than your current role + user_role: + attributes: + permissions_as_keys: + dangerous: include permissions that are not safe for the base role + elevated: cannot include permissions your current role does not possess + position: + elevated: cannot be higher than your current role diff --git a/config/locales/en.yml b/config/locales/en.yml index 91ae3a3bc..2cd4f45ac 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -83,10 +83,8 @@ en: posts_tab_heading: Posts posts_with_replies: Posts and replies roles: - admin: Admin bot: Bot group: Group - moderator: Mod unavailable: Profile unavailable unfollow: Unfollow admin: @@ -105,12 +103,17 @@ en: avatar: Avatar by_domain: Domain change_email: - changed_msg: Account email successfully changed! + changed_msg: Email successfully changed! current_email: Current email label: Change email new_email: New email submit: Change email title: Change email for %{username} + change_role: + changed_msg: Role successfully changed! + label: Change role + no_role: No role + title: Change role for %{username} confirm: Confirm confirmed: Confirmed confirming: Confirming @@ -154,6 +157,7 @@ en: active: Active all: All pending: Pending + silenced: Limited suspended: Suspended title: Moderation moderation_notes: Moderation notes @@ -161,6 +165,7 @@ en: most_recent_ip: Most recent IP no_account_selected: No accounts were changed as none were selected no_limits_imposed: No limits imposed + no_role_assigned: No role assigned not_subscribed: Not subscribed pending: Pending review perform_full_suspension: Suspend @@ -187,12 +192,7 @@ en: reset: Reset reset_password: Reset password resubscribe: Resubscribe - role: Permissions - roles: - admin: Administrator - moderator: Moderator - staff: Staff - user: User + role: Role search: Search search_same_email_domain: Other users with the same e-mail domain search_same_ip: Other users with the same IP @@ -649,6 +649,67 @@ en: unresolved: Unresolved updated_at: Updated view_profile: View profile + roles: + add_new: Add role + assigned_users: + one: "%{count} user" + other: "%{count} users" + categories: + administration: Administration + devops: Devops + invites: Invites + moderation: Moderation + special: Special + delete: Delete + description_html: With user roles, you can customize which functions and areas of Mastodon your users can access. + edit: Edit '%{name}' role + everyone: Default permissions + everyone_full_description_html: This is the base role affecting all users, even those without an assigned role. All other roles inherit permissions from it. + permissions_count: + one: "%{count} permission" + other: "%{count} permissions" + privileges: + administrator: Administrator + administrator_description: Users with this permission will bypass every permission + delete_user_data: Delete User Data + delete_user_data_description: Allows users to delete other users' data without delay + invite_users: Invite Users + invite_users_description: Allows users to invite new people to the server + manage_announcements: Manage Announcements + manage_announcements_description: Allows users to manage announcements on the server + manage_appeals: Manage Appeals + manage_appeals_description: Allows users to review appeals against moderation actions + manage_blocks: Manage Blocks + manage_blocks_description: Allows users to block e-mail providers and IP addresses + manage_custom_emojis: Manage Custom Emojis + manage_custom_emojis_description: Allows users to manage custom emojis on the server + manage_federation: Manage Federation + manage_federation_description: Allows users to block or allow federation with other domains, and control deliverability + manage_invites: Manage Invites + manage_invites_description: Allows users to browse and deactivate invite links + manage_reports: Manage Reports + manage_reports_description: Allows users to review reports and perform moderation actions against them + manage_roles: Manage Roles + manage_roles_description: Allows users to manage and assign roles below theirs + manage_rules: Manage Rules + manage_rules_description: Allows users to change server rules + manage_settings: Manage Settings + manage_settings_description: Allows users to change site settings + manage_taxonomies: Manage Taxonomies + manage_taxonomies_description: Allows users to review trending content and update hashtag settings + manage_user_access: Manage User Access + manage_user_access_description: Allows users to disable other users' two-factor authentication, change their e-mail address, and reset their password + manage_users: Manage Users + manage_users_description: Allows users to view other users' details and perform moderation actions against them + manage_webhooks: Manage Webhooks + manage_webhooks_description: Allows users to set up webhooks for administrative events + view_audit_log: View Audit Log + view_audit_log_description: Allows users to see a history of administrative actions on the server + view_dashboard: View Dashboard + view_dashboard_description: Allows users to access the dashboard and various metrics + view_devops: Devops + view_devops_description: Allows users to access Sidekiq and pgHero dashboards + title: Roles rules: add_new: Add rule delete: Delete @@ -701,9 +762,6 @@ en: deletion: desc_html: Allow anyone to delete their account title: Open account deletion - min_invite_role: - disabled: No one - title: Allow invitations by require_invite_text: desc_html: When registrations require manual approval, make the “Why do you want to join?” text input mandatory rather than optional title: Require new users to enter a reason to join @@ -716,9 +774,6 @@ en: show_known_fediverse_at_about_page: desc_html: When disabled, restricts the public timeline linked from the landing page to showing only local content title: Include federated content on unauthenticated public timeline page - show_staff_badge: - desc_html: Show a staff badge on a user page - title: Show staff badge site_description: desc_html: Introductory paragraph on the API. Describe what makes this Mastodon server special and anything else important. You can use HTML tags, in particular <a> and <em>. title: Server description diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index ea4f68562..932f34d82 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -96,6 +96,13 @@ en: name: You can only change the casing of the letters, for example, to make it more readable user: chosen_languages: When checked, only posts in selected languages will be displayed in public timelines + role: The role controls which permissions the user has + user_role: + color: Color to be used for the role throughout the UI, as RGB in hex format + highlighted: This makes the role publicly visible + name: Public name of the role, if role is set to be displayed as a badge + permissions_as_keys: Users with this role will have access to... + position: Higher role decides conflict resolution in certain situations webhook: events: Select events to send url: Where events will be sent to @@ -232,6 +239,14 @@ en: name: Hashtag trendable: Allow this hashtag to appear under trends usable: Allow posts to use this hashtag + user: + role: Role + user_role: + color: Badge color + highlighted: Display role as badge on user profiles + name: Name + permissions_as_keys: Permissions + position: Priority webhook: events: Enabled events url: Endpoint URL diff --git a/config/navigation.rb b/config/navigation.rb index ec5719e3e..706de0471 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -2,66 +2,67 @@ SimpleNavigation::Configuration.run do |navigation| navigation.items do |n| - n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_url + n.item :web, safe_join([fa_icon('chevron-left fw'), t('settings.back')]), root_path - n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_url, if: -> { current_user.functional? } do |s| - s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_url - s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url + n.item :profile, safe_join([fa_icon('user fw'), t('settings.profile')]), settings_profile_path, if: -> { current_user.functional? } do |s| + s.item :profile, safe_join([fa_icon('pencil fw'), t('settings.appearance')]), settings_profile_path + s.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_path end - n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_url, if: -> { current_user.functional? } do |s| - s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_url - s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_url - s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_url + n.item :preferences, safe_join([fa_icon('cog fw'), t('settings.preferences')]), settings_preferences_path, if: -> { current_user.functional? } do |s| + s.item :appearance, safe_join([fa_icon('desktop fw'), t('settings.appearance')]), settings_preferences_appearance_path + s.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_preferences_notifications_path + s.item :other, safe_join([fa_icon('cog fw'), t('preferences.other')]), settings_preferences_other_path end - n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_url, if: -> { current_user.functional? } + n.item :relationships, safe_join([fa_icon('users fw'), t('settings.relationships')]), relationships_path, if: -> { current_user.functional? } n.item :filters, safe_join([fa_icon('filter fw'), t('filters.index.title')]), filters_path, highlights_on: %r{/filters}, if: -> { current_user.functional? } - n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_url, if: -> { current_user.functional? } + n.item :statuses_cleanup, safe_join([fa_icon('history fw'), t('settings.statuses_cleanup')]), statuses_cleanup_path, if: -> { current_user.functional? } - n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_url do |s| - s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes} - s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_url, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys} - s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url + n.item :security, safe_join([fa_icon('lock fw'), t('settings.account')]), edit_user_registration_path do |s| + s.item :password, safe_join([fa_icon('lock fw'), t('settings.account_settings')]), edit_user_registration_path, highlights_on: %r{/auth/edit|/settings/delete|/settings/migration|/settings/aliases|/settings/login_activities|^/disputes} + s.item :two_factor_authentication, safe_join([fa_icon('mobile fw'), t('settings.two_factor_authentication')]), settings_two_factor_authentication_methods_path, highlights_on: %r{/settings/two_factor_authentication|/settings/otp_authentication|/settings/security_keys} + s.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_path end - n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_url do |s| - s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url, if: -> { current_user.functional? } - s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url + n.item :data, safe_join([fa_icon('cloud-download fw'), t('settings.import_and_export')]), settings_export_path do |s| + s.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_path, if: -> { current_user.functional? } + s.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_path end - n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: proc { Setting.min_invite_role == 'user' && current_user.functional? } - n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? } + n.item :invites, safe_join([fa_icon('user-plus fw'), t('invites.title')]), invites_path, if: -> { current_user.can?(:invite_users) && current_user.functional? } + n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_path, if: -> { current_user.functional? } - n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_tags_path, if: proc { current_user.staff? } do |s| + n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_statuses_path, if: -> { current_user.can?(:manage_taxonomies) } do |s| s.item :statuses, safe_join([fa_icon('comments-o fw'), t('admin.trends.statuses.title')]), admin_trends_statuses_path, highlights_on: %r{/admin/trends/statuses} s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags} s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links} end - n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), admin_reports_url, if: proc { current_user.staff? } do |s| - s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_url - s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} - s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes} - s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path - s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations} - s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } - s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } - s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? } + n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), nil, if: -> { current_user.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks) } do |s| + s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_path, highlights_on: %r{/admin/reports}, if: -> { current_user.can?(:manage_reports) } + s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_path(origin: 'local'), highlights_on: %r{/admin/accounts|/admin/pending_accounts|/admin/disputes|/admin/users}, if: -> { current_user.can?(:manage_users) } + s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path, if: -> { current_user.can?(:manage_invites) } + s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}, if: -> { current_user.can?(:manage_taxonomies) } + s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_path(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.can?(:manage_federation) } + s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_path, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.can?(:manage_blocks) } + s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_path, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.can?(:manage_blocks) } + s.item :action_logs, safe_join([fa_icon('bars fw'), t('admin.action_logs.title')]), admin_action_logs_path, if: -> { current_user.can?(:view_audit_log) } end - n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_dashboard_url, if: proc { current_user.staff? } do |s| - s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_url - s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_url, if: -> { current_user.admin? }, highlights_on: %r{/admin/settings} - s.item :rules, safe_join([fa_icon('gavel fw'), t('admin.rules.title')]), admin_rules_path, highlights_on: %r{/admin/rules} - s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements} - s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_url, highlights_on: %r{/admin/custom_emojis} - s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks} - s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_url, if: -> { current_user.admin? && !whitelist_mode? }, highlights_on: %r{/admin/relays} - s.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_url, link_html: { target: 'sidekiq' }, if: -> { current_user.admin? } - s.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_url, link_html: { target: 'pghero' }, if: -> { current_user.admin? } + n.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), nil, if: -> { current_user.can?(:view_dashboard, :manage_settings, :manage_rules, :manage_announcements, :manage_custom_emojis, :manage_webhooks, :manage_federation) } do |s| + s.item :dashboard, safe_join([fa_icon('tachometer fw'), t('admin.dashboard.title')]), admin_dashboard_path, if: -> { current_user.can?(:view_dashboard) } + s.item :settings, safe_join([fa_icon('cogs fw'), t('admin.settings.title')]), edit_admin_settings_path, if: -> { current_user.can?(:manage_settings) }, highlights_on: %r{/admin/settings} + s.item :rules, safe_join([fa_icon('gavel fw'), t('admin.rules.title')]), admin_rules_path, highlights_on: %r{/admin/rules}, if: -> { current_user.can?(:manage_rules) } + s.item :roles, safe_join([fa_icon('vcard fw'), t('admin.roles.title')]), admin_roles_path, highlights_on: %r{/admin/roles}, if: -> { current_user.can?(:manage_roles) } + s.item :announcements, safe_join([fa_icon('bullhorn fw'), t('admin.announcements.title')]), admin_announcements_path, highlights_on: %r{/admin/announcements}, if: -> { current_user.can?(:manage_announcements) } + s.item :custom_emojis, safe_join([fa_icon('smile-o fw'), t('admin.custom_emojis.title')]), admin_custom_emojis_path, highlights_on: %r{/admin/custom_emojis}, if: -> { current_user.can?(:manage_custom_emojis) } + s.item :webhooks, safe_join([fa_icon('inbox fw'), t('admin.webhooks.title')]), admin_webhooks_path, highlights_on: %r{/admin/webhooks}, if: -> { current_user.can?(:manage_webhooks) } + s.item :relays, safe_join([fa_icon('exchange fw'), t('admin.relays.title')]), admin_relays_path, highlights_on: %r{/admin/relays}, if: -> { !whitelist_mode? && current_user.can?(:manage_federation) } end - n.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_url, link_html: { 'data-method' => 'delete' } + n.item :sidekiq, safe_join([fa_icon('diamond fw'), 'Sidekiq']), sidekiq_path, link_html: { target: 'sidekiq' }, if: -> { current_user.can?(:view_devops) } + n.item :pghero, safe_join([fa_icon('database fw'), 'PgHero']), pghero_path, link_html: { target: 'pghero' }, if: -> { current_user.can?(:view_devops) } + n.item :logout, safe_join([fa_icon('sign-out fw'), t('auth.logout')]), destroy_user_session_path, link_html: { 'data-method' => 'delete' } end end diff --git a/config/roles.yml b/config/roles.yml new file mode 100644 index 000000000..f443250d1 --- /dev/null +++ b/config/roles.yml @@ -0,0 +1,35 @@ +moderator: + name: Moderator + position: 10 + permissions: + - view_dashboard + - view_audit_log + - manage_users + - manage_reports + - manage_taxonomies +admin: + name: Admin + position: 100 + permissions: + - view_dashboard + - view_audit_log + - manage_users + - manage_user_access + - delete_user_data + - manage_reports + - manage_taxonomies + - manage_federation + - manage_settings + - manage_blocks + - manage_appeals + - manage_rules + - manage_invites + - manage_announcements + - manage_custom_emojis + - manage_webhooks + - manage_roles +owner: + name: Owner + position: 1000 + permissions: + - administrator diff --git a/config/routes.rb b/config/routes.rb index 4abf55655..177c1cff4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -10,7 +10,7 @@ Rails.application.routes.draw do get 'health', to: 'health#show' - authenticate :user, lambda { |u| u.admin? } do + authenticate :user, lambda { |u| u.role&.can?(:view_devops) } do mount Sidekiq::Web, at: 'sidekiq', as: :sidekiq mount PgHero::Engine, at: 'pghero', as: :pghero end @@ -295,17 +295,11 @@ Rails.application.routes.draw do post :resend end end - - resource :role, only: [] do - member do - post :promote - post :demote - end - end end resources :users, only: [] do - resource :two_factor_authentication, only: [:destroy] + resource :two_factor_authentication, only: [:destroy], controller: 'users/two_factor_authentications' + resource :role, only: [:show, :update], controller: 'users/roles' end resources :custom_emojis, only: [:index, :new, :create] do @@ -320,6 +314,7 @@ Rails.application.routes.draw do end end + resources :roles, except: [:show] resources :account_moderation_notes, only: [:create, :destroy] resource :follow_recommendations, only: [:show, :update] resources :tags, only: [:show, :update] diff --git a/db/migrate/20220611210335_create_user_roles.rb b/db/migrate/20220611210335_create_user_roles.rb new file mode 100644 index 000000000..6b7f2b637 --- /dev/null +++ b/db/migrate/20220611210335_create_user_roles.rb @@ -0,0 +1,13 @@ +class CreateUserRoles < ActiveRecord::Migration[6.1] + def change + create_table :user_roles do |t| + t.string :name, null: false, default: '' + t.string :color, null: false, default: '' + t.integer :position, null: false, default: 0 + t.bigint :permissions, null: false, default: 0 + t.boolean :highlighted, null: false, default: false + + t.timestamps + end + end +end diff --git a/db/migrate/20220611212541_add_role_id_to_users.rb b/db/migrate/20220611212541_add_role_id_to_users.rb new file mode 100644 index 000000000..2fda647d4 --- /dev/null +++ b/db/migrate/20220611212541_add_role_id_to_users.rb @@ -0,0 +1,8 @@ +class AddRoleIdToUsers < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + def change + safety_assured { add_reference :users, :role, foreign_key: { to_table: 'user_roles', on_delete: :nullify }, index: false } + add_index :users, :role_id, algorithm: :concurrently, where: 'role_id IS NOT NULL' + end +end diff --git a/db/post_migrate/20220617202502_migrate_roles.rb b/db/post_migrate/20220617202502_migrate_roles.rb new file mode 100644 index 000000000..b7a7b2201 --- /dev/null +++ b/db/post_migrate/20220617202502_migrate_roles.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class MigrateRoles < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + class UserRole < ApplicationRecord; end + class User < ApplicationRecord; end + + def up + load Rails.root.join('db', 'seeds', '03_roles.rb') + + admin_role = UserRole.find_by(name: 'Admin') + moderator_role = UserRole.find_by(name: 'Moderator') + + User.where(admin: true).in_batches.update_all(role_id: admin_role.id) + User.where(moderator: true).in_batches.update_all(role_id: moderator_role.id) + end + + def down + admin_role = UserRole.find_by(name: 'Admin') + moderator_role = UserRole.find_by(name: 'Moderator') + + User.where(role_id: admin_role.id).in_batches.update_all(admin: true) if admin_role + User.where(role_id: moderator_role.id).in_batches.update_all(moderator: true) if moderator_role + end +end diff --git a/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb b/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb new file mode 100644 index 000000000..254690cc3 --- /dev/null +++ b/db/post_migrate/20220704024901_migrate_settings_to_user_roles.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class MigrateSettingsToUserRoles < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + class UserRole < ApplicationRecord; end + + def up + owner_role = UserRole.find_by(name: 'Owner') + admin_role = UserRole.find_by(name: 'Admin') + moderator_role = UserRole.find_by(name: 'Moderator') + everyone_role = UserRole.find_by(id: -99) + + min_invite_role = Setting.min_invite_role + show_staff_badge = Setting.show_staff_badge + + if everyone_role + everyone_role.permissions &= ~::UserRole::FLAGS[:invite_users] unless min_invite_role == 'user' + everyone_role.save + end + + if owner_role + owner_role.highlighted = show_staff_badge + owner_role.save + end + + if admin_role + admin_role.permissions |= ::UserRole::FLAGS[:invite_users] if %w(admin moderator).include?(min_invite_role) + admin_role.highlighted = show_staff_badge + admin_role.save + end + + if moderator_role + moderator_role.permissions |= ::UserRole::FLAGS[:invite_users] if %w(moderator).include?(min_invite_role) + moderator_role.highlighted = show_staff_badge + moderator_role.save + end + end + + def down; end +end diff --git a/db/schema.rb b/db/schema.rb index 759dc712b..54966ef64 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_06_13_110903) do +ActiveRecord::Schema.define(version: 2022_07_04_024901) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -968,6 +968,16 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do t.index ["user_id"], name: "index_user_invite_requests_on_user_id" end + create_table "user_roles", force: :cascade do |t| + t.string "name", default: "", null: false + t.string "color", default: "", null: false + t.integer "position", default: 0, null: false + t.bigint "permissions", default: 0, null: false + t.boolean "highlighted", default: false, null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + end + create_table "users", force: :cascade do |t| t.string "email", default: "", null: false t.datetime "created_at", null: false @@ -1003,11 +1013,13 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do t.string "webauthn_id" t.inet "sign_up_ip" t.boolean "skip_sign_in_token" + t.bigint "role_id" t.index ["account_id"], name: "index_users_on_account_id" t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true t.index ["created_by_application_id"], name: "index_users_on_created_by_application_id", where: "(created_by_application_id IS NOT NULL)" t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, opclass: :text_pattern_ops, where: "(reset_password_token IS NOT NULL)" + t.index ["role_id"], name: "index_users_on_role_id", where: "(role_id IS NOT NULL)" end create_table "web_push_subscriptions", force: :cascade do |t| @@ -1159,6 +1171,7 @@ ActiveRecord::Schema.define(version: 2022_06_13_110903) do add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade add_foreign_key "users", "invites", on_delete: :nullify add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify + add_foreign_key "users", "user_roles", column: "role_id", on_delete: :nullify add_foreign_key "web_push_subscriptions", "oauth_access_tokens", column: "access_token_id", on_delete: :cascade add_foreign_key "web_push_subscriptions", "users", on_delete: :cascade add_foreign_key "web_settings", "users", name: "fk_11910667b2", on_delete: :cascade diff --git a/db/seeds.rb b/db/seeds.rb index 0bfb5d0db..1ca300de7 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,11 +1,5 @@ -Doorkeeper::Application.create!(name: 'Web', superapp: true, redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow push') +# frozen_string_literal: true -domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain -account = Account.find_or_initialize_by(id: -99, actor_type: 'Application', locked: true, username: domain) -account.save! - -if Rails.env.development? - admin = Account.where(username: 'admin').first_or_initialize(username: 'admin') - admin.save(validate: false) - User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, admin: true, account: admin, agreement: true, approved: true).save! +Dir[Rails.root.join('db', 'seeds', '*.rb')].sort.each do |seed| + load seed end diff --git a/db/seeds/01_web_app.rb b/db/seeds/01_web_app.rb new file mode 100644 index 000000000..a457a883b --- /dev/null +++ b/db/seeds/01_web_app.rb @@ -0,0 +1 @@ +Doorkeeper::Application.create_with(name: 'Web', redirect_uri: Doorkeeper.configuration.native_redirect_uri, scopes: 'read write follow push').find_or_create_by(superapp: true) diff --git a/db/seeds/02_instance_actor.rb b/db/seeds/02_instance_actor.rb new file mode 100644 index 000000000..39186b273 --- /dev/null +++ b/db/seeds/02_instance_actor.rb @@ -0,0 +1 @@ +Account.create_with(actor_type: 'Application', locked: true, username: ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain).find_or_create_by(id: -99) diff --git a/db/seeds/03_roles.rb b/db/seeds/03_roles.rb new file mode 100644 index 000000000..7fedf0f71 --- /dev/null +++ b/db/seeds/03_roles.rb @@ -0,0 +1,9 @@ +# Pre-create base role +UserRole.everyone + +# Create default roles defined in config file +default_roles = YAML.load_file(Rails.root.join('config', 'roles.yml')) + +default_roles.each do |_, config| + UserRole.create_with(position: config['position'], permissions_as_keys: config['permissions'], highlighted: true).find_or_create_by(name: config['name']) +end diff --git a/db/seeds/04_admin.rb b/db/seeds/04_admin.rb new file mode 100644 index 000000000..a67040e4e --- /dev/null +++ b/db/seeds/04_admin.rb @@ -0,0 +1,8 @@ +if Rails.env.development? + domain = ENV['LOCAL_DOMAIN'] || Rails.configuration.x.local_domain + + admin = Account.where(username: 'admin').first_or_initialize(username: 'admin') + admin.save(validate: false) + + User.where(email: "admin@#{domain}").first_or_initialize(email: "admin@#{domain}", password: 'mastodonadmin', password_confirmation: 'mastodonadmin', confirmed_at: Time.now.utc, role: UserRole.find_by(name: 'Owner'), account: admin, agreement: true, approved: true).save! +end diff --git a/lib/mastodon/accounts_cli.rb b/lib/mastodon/accounts_cli.rb index 7256d1da9..29c934453 100644 --- a/lib/mastodon/accounts_cli.rb +++ b/lib/mastodon/accounts_cli.rb @@ -54,7 +54,7 @@ module Mastodon option :email, required: true option :confirmed, type: :boolean - option :role, default: 'user', enum: %w(user moderator admin) + option :role option :reattach, type: :boolean option :force, type: :boolean desc 'create USERNAME', 'Create a new user' @@ -65,8 +65,7 @@ module Mastodon With the --confirmed option, the confirmation e-mail will be skipped and the account will be active straight away. - With the --role option one of "user", "admin" or "moderator" - can be supplied. Defaults to "user" + With the --role option, the role can be supplied. With the --reattach option, the new user will be reattached to a given existing username of an old account. If the old @@ -75,9 +74,22 @@ module Mastodon username to the new account anyway. LONG_DESC def create(username) + role_id = nil + + if options[:role] + role = UserRole.find_by(name: options[:role]) + + if role.nil? + say('Cannot find user role with that name', :red) + exit(1) + end + + role_id = role.id + end + account = Account.new(username: username) password = SecureRandom.hex - user = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true) + user = User.new(email: options[:email], password: password, agreement: true, approved: true, role_id: role_id, confirmed_at: options[:confirmed] ? Time.now.utc : nil, bypass_invite_request_check: true) if options[:reattach] account = Account.find_local(username) || Account.new(username: username) @@ -106,14 +118,14 @@ module Mastodon user.errors.to_h.each do |key, error| say('Failure/Error: ', :red) say(key) - say(' ' + error, :red) + say(" #{error}", :red) end exit(1) end end - option :role, enum: %w(user moderator admin) + option :role option :email option :confirm, type: :boolean option :enable, type: :boolean @@ -125,8 +137,7 @@ module Mastodon long_desc <<-LONG_DESC Modify a user account. - With the --role option, update the user's role to one of "user", - "moderator" or "admin". + With the --role option, update the user's role. With the --email option, update the user's e-mail address. With the --confirm option, mark the user's e-mail as confirmed. @@ -152,8 +163,14 @@ module Mastodon end if options[:role] - user.admin = options[:role] == 'admin' - user.moderator = options[:role] == 'moderator' + role = UserRole.find_by(name: options[:role]) + + if role.nil? + say('Cannot find user role with that name', :red) + exit(1) + end + + user.role_id = role.id end password = SecureRandom.hex if options[:reset_password] @@ -172,7 +189,7 @@ module Mastodon user.errors.to_h.each do |key, error| say('Failure/Error: ', :red) say(key) - say(' ' + error, :red) + say(" #{error}", :red) end exit(1) @@ -319,7 +336,7 @@ module Mastodon unless skip_domains.empty? say('The following domains were not available during the check:', :yellow) - skip_domains.each { |domain| say(' ' + domain) } + skip_domains.each { |domain| say(" #{domain}") } end end diff --git a/lib/simple_navigation/item_extensions.rb b/lib/simple_navigation/item_extensions.rb new file mode 100644 index 000000000..28af37a18 --- /dev/null +++ b/lib/simple_navigation/item_extensions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module SimpleNavigation + module ItemExtensions + def url + if @url.nil? && @sub_navigation + @sub_navigation.items.first.url + else + @url + end + end + end +end + +SimpleNavigation::Item.prepend(SimpleNavigation::ItemExtensions) diff --git a/spec/controllers/admin/account_moderation_notes_controller_spec.rb b/spec/controllers/admin/account_moderation_notes_controller_spec.rb index 410ce6543..d3f3263f8 100644 --- a/spec/controllers/admin/account_moderation_notes_controller_spec.rb +++ b/spec/controllers/admin/account_moderation_notes_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Admin::AccountModerationNotesController, type: :controller do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:target_account) { Fabricate(:account) } before do diff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb index 1779fb7c0..1bd51a0c8 100644 --- a/spec/controllers/admin/accounts_controller_spec.rb +++ b/spec/controllers/admin/accounts_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::AccountsController, type: :controller do before { sign_in current_user, scope: :user } describe 'GET #index' do - let(:current_user) { Fabricate(:user, admin: true) } + let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } around do |example| default_per_page = Account.default_per_page @@ -60,7 +60,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end describe 'GET #show' do - let(:current_user) { Fabricate(:user, admin: true) } + let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:account) { Fabricate(:account) } it 'returns http success' do @@ -72,15 +72,15 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'POST #memorialize' do subject { post :memorialize, params: { id: account.id } } - let(:current_user) { Fabricate(:user, admin: current_user_admin) } + let(:current_user) { Fabricate(:user, role: current_role) } let(:account) { user.account } - let(:user) { Fabricate(:user, admin: target_user_admin) } + let(:user) { Fabricate(:user, role: target_role) } context 'when user is admin' do - let(:current_user_admin) { true } + let(:current_role) { UserRole.find_by(name: 'Admin') } context 'when target user is admin' do - let(:target_user_admin) { true } + let(:target_role) { UserRole.find_by(name: 'Admin') } it 'fails to memorialize account' do is_expected.to have_http_status :forbidden @@ -89,7 +89,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when target user is not admin' do - let(:target_user_admin) { false } + let(:target_role) { UserRole.find_by(name: 'Moderator') } it 'succeeds in memorializing account' do is_expected.to redirect_to admin_account_path(account.id) @@ -99,10 +99,10 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when user is not admin' do - let(:current_user_admin) { false } + let(:current_role) { UserRole.find_by(name: 'Moderator') } context 'when target user is admin' do - let(:target_user_admin) { true } + let(:target_role) { UserRole.find_by(name: 'Admin') } it 'fails to memorialize account' do is_expected.to have_http_status :forbidden @@ -111,7 +111,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when target user is not admin' do - let(:target_user_admin) { false } + let(:target_role) { UserRole.find_by(name: 'Moderator') } it 'fails to memorialize account' do is_expected.to have_http_status :forbidden @@ -124,12 +124,12 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'POST #enable' do subject { post :enable, params: { id: account.id } } - let(:current_user) { Fabricate(:user, admin: admin) } + let(:current_user) { Fabricate(:user, role: role) } let(:account) { user.account } let(:user) { Fabricate(:user, disabled: true) } context 'when user is admin' do - let(:admin) { true } + let(:role) { UserRole.find_by(name: 'Admin') } it 'succeeds in enabling account' do is_expected.to redirect_to admin_account_path(account.id) @@ -138,7 +138,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when user is not admin' do - let(:admin) { false } + let(:role) { UserRole.everyone } it 'fails to enable account' do is_expected.to have_http_status :forbidden @@ -150,19 +150,23 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'POST #redownload' do subject { post :redownload, params: { id: account.id } } - let(:current_user) { Fabricate(:user, admin: admin) } - let(:account) { Fabricate(:account) } + let(:current_user) { Fabricate(:user, role: role) } + let(:account) { Fabricate(:account, domain: 'example.com') } + + before do + allow_any_instance_of(ResolveAccountService).to receive(:call) + end context 'when user is admin' do - let(:admin) { true } + let(:role) { UserRole.find_by(name: 'Admin') } - it 'succeeds in redownloadin' do + it 'succeeds in redownloading' do is_expected.to redirect_to admin_account_path(account.id) end end context 'when user is not admin' do - let(:admin) { false } + let(:role) { UserRole.everyone } it 'fails to redownload' do is_expected.to have_http_status :forbidden @@ -173,11 +177,11 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'POST #remove_avatar' do subject { post :remove_avatar, params: { id: account.id } } - let(:current_user) { Fabricate(:user, admin: admin) } + let(:current_user) { Fabricate(:user, role: role) } let(:account) { Fabricate(:account) } context 'when user is admin' do - let(:admin) { true } + let(:role) { UserRole.find_by(name: 'Admin') } it 'succeeds in removing avatar' do is_expected.to redirect_to admin_account_path(account.id) @@ -185,7 +189,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when user is not admin' do - let(:admin) { false } + let(:role) { UserRole.everyone } it 'fails to remove avatar' do is_expected.to have_http_status :forbidden @@ -196,12 +200,12 @@ RSpec.describe Admin::AccountsController, type: :controller do describe 'POST #unblock_email' do subject { post :unblock_email, params: { id: account.id } } - let(:current_user) { Fabricate(:user, admin: admin) } + let(:current_user) { Fabricate(:user, role: role) } let(:account) { Fabricate(:account, suspended: true) } let!(:email_block) { Fabricate(:canonical_email_block, reference_account: account) } context 'when user is admin' do - let(:admin) { true } + let(:role) { UserRole.find_by(name: 'Admin') } it 'succeeds in removing email blocks' do expect { subject }.to change { CanonicalEmailBlock.where(reference_account: account).count }.from(1).to(0) @@ -214,7 +218,7 @@ RSpec.describe Admin::AccountsController, type: :controller do end context 'when user is not admin' do - let(:admin) { false } + let(:role) { UserRole.everyone } it 'fails to remove avatar' do subject diff --git a/spec/controllers/admin/action_logs_controller_spec.rb b/spec/controllers/admin/action_logs_controller_spec.rb index 4720ed2e2..c1957258f 100644 --- a/spec/controllers/admin/action_logs_controller_spec.rb +++ b/spec/controllers/admin/action_logs_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Admin::ActionLogsController, type: :controller do describe 'GET #index' do it 'returns 200' do - sign_in Fabricate(:user, admin: true) + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) get :index, params: { page: 1 } expect(response).to have_http_status(200) diff --git a/spec/controllers/admin/base_controller_spec.rb b/spec/controllers/admin/base_controller_spec.rb index 9ac833623..44be91951 100644 --- a/spec/controllers/admin/base_controller_spec.rb +++ b/spec/controllers/admin/base_controller_spec.rb @@ -5,13 +5,14 @@ require 'rails_helper' describe Admin::BaseController, type: :controller do controller do def success + authorize :dashboard, :index? render 'admin/reports/show' end end it 'requires administrator or moderator' do routes.draw { get 'success' => 'admin/base#success' } - sign_in(Fabricate(:user, admin: false, moderator: false)) + sign_in(Fabricate(:user)) get :success expect(response).to have_http_status(:forbidden) @@ -19,14 +20,14 @@ describe Admin::BaseController, type: :controller do it 'renders admin layout as a moderator' do routes.draw { get 'success' => 'admin/base#success' } - sign_in(Fabricate(:user, moderator: true)) + sign_in(Fabricate(:user, role: UserRole.find_by(name: 'Moderator'))) get :success expect(response).to render_template layout: 'admin' end it 'renders admin layout as an admin' do routes.draw { get 'success' => 'admin/base#success' } - sign_in(Fabricate(:user, admin: true)) + sign_in(Fabricate(:user, role: UserRole.find_by(name: 'Admin'))) get :success expect(response).to render_template layout: 'admin' end diff --git a/spec/controllers/admin/change_email_controller_spec.rb b/spec/controllers/admin/change_email_controller_spec.rb index e7f3f7c97..cf8a27d39 100644 --- a/spec/controllers/admin/change_email_controller_spec.rb +++ b/spec/controllers/admin/change_email_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Admin::ChangeEmailsController, type: :controller do render_views - let(:admin) { Fabricate(:user, admin: true) } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do sign_in admin diff --git a/spec/controllers/admin/confirmations_controller_spec.rb b/spec/controllers/admin/confirmations_controller_spec.rb index 5b4f7e925..6268903c4 100644 --- a/spec/controllers/admin/confirmations_controller_spec.rb +++ b/spec/controllers/admin/confirmations_controller_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Admin::ConfirmationsController, type: :controller do render_views before do - sign_in Fabricate(:user, admin: true), scope: :user + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user end describe 'POST #create' do diff --git a/spec/controllers/admin/custom_emojis_controller_spec.rb b/spec/controllers/admin/custom_emojis_controller_spec.rb index a8d96948c..06cd0c22d 100644 --- a/spec/controllers/admin/custom_emojis_controller_spec.rb +++ b/spec/controllers/admin/custom_emojis_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Admin::CustomEmojisController do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do sign_in user, scope: :user diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb index 7824854f9..6231a09a2 100644 --- a/spec/controllers/admin/dashboard_controller_spec.rb +++ b/spec/controllers/admin/dashboard_controller_spec.rb @@ -12,7 +12,7 @@ describe Admin::DashboardController, type: :controller do Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path), Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'), ]) - sign_in Fabricate(:user, admin: true) + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) end it 'returns 200' do diff --git a/spec/controllers/admin/disputes/appeals_controller_spec.rb b/spec/controllers/admin/disputes/appeals_controller_spec.rb index 6a06f9406..712657791 100644 --- a/spec/controllers/admin/disputes/appeals_controller_spec.rb +++ b/spec/controllers/admin/disputes/appeals_controller_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Admin::Disputes::AppealsController, type: :controller do end describe 'POST #approve' do - let(:current_user) { Fabricate(:user, admin: true) } + let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil)) @@ -35,7 +35,7 @@ RSpec.describe Admin::Disputes::AppealsController, type: :controller do end describe 'POST #reject' do - let(:current_user) { Fabricate(:user, admin: true) } + let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil)) diff --git a/spec/controllers/admin/domain_blocks_controller_spec.rb b/spec/controllers/admin/domain_blocks_controller_spec.rb index ecc79292b..5c2dcd268 100644 --- a/spec/controllers/admin/domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/domain_blocks_controller_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do render_views before do - sign_in Fabricate(:user, admin: true), scope: :user + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user end describe 'GET #new' do diff --git a/spec/controllers/admin/email_domain_blocks_controller_spec.rb b/spec/controllers/admin/email_domain_blocks_controller_spec.rb index cf194579d..e9cef4a94 100644 --- a/spec/controllers/admin/email_domain_blocks_controller_spec.rb +++ b/spec/controllers/admin/email_domain_blocks_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::EmailDomainBlocksController, type: :controller do render_views before do - sign_in Fabricate(:user, admin: true), scope: :user + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user end describe 'GET #index' do diff --git a/spec/controllers/admin/instances_controller_spec.rb b/spec/controllers/admin/instances_controller_spec.rb index 53427b874..337f7a80c 100644 --- a/spec/controllers/admin/instances_controller_spec.rb +++ b/spec/controllers/admin/instances_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Admin::InstancesController, type: :controller do render_views - let(:current_user) { Fabricate(:user, admin: true) } + let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let!(:account) { Fabricate(:account, domain: 'popular') } let!(:account2) { Fabricate(:account, domain: 'popular') } @@ -35,11 +35,11 @@ RSpec.describe Admin::InstancesController, type: :controller do describe 'DELETE #destroy' do subject { delete :destroy, params: { id: Instance.first.id } } - let(:current_user) { Fabricate(:user, admin: admin) } + let(:current_user) { Fabricate(:user, role: role) } let(:account) { Fabricate(:account) } context 'when user is admin' do - let(:admin) { true } + let(:role) { UserRole.find_by(name: 'Admin') } it 'succeeds in purging instance' do is_expected.to redirect_to admin_instances_path @@ -47,7 +47,7 @@ RSpec.describe Admin::InstancesController, type: :controller do end context 'when user is not admin' do - let(:admin) { false } + let(:role) { nil } it 'fails to purge instance' do is_expected.to have_http_status :forbidden diff --git a/spec/controllers/admin/invites_controller_spec.rb b/spec/controllers/admin/invites_controller_spec.rb index 449a699e4..1fb488742 100644 --- a/spec/controllers/admin/invites_controller_spec.rb +++ b/spec/controllers/admin/invites_controller_spec.rb @@ -5,7 +5,7 @@ require 'rails_helper' describe Admin::InvitesController do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do sign_in user, scope: :user diff --git a/spec/controllers/admin/report_notes_controller_spec.rb b/spec/controllers/admin/report_notes_controller_spec.rb index c0013f41a..fa7572d18 100644 --- a/spec/controllers/admin/report_notes_controller_spec.rb +++ b/spec/controllers/admin/report_notes_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Admin::ReportNotesController do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do sign_in user, scope: :user diff --git a/spec/controllers/admin/reports_controller_spec.rb b/spec/controllers/admin/reports_controller_spec.rb index d421f0739..4cd1524bf 100644 --- a/spec/controllers/admin/reports_controller_spec.rb +++ b/spec/controllers/admin/reports_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Admin::ReportsController do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } before do sign_in user, scope: :user end diff --git a/spec/controllers/admin/resets_controller_spec.rb b/spec/controllers/admin/resets_controller_spec.rb index 28510b5af..aeb172318 100644 --- a/spec/controllers/admin/resets_controller_spec.rb +++ b/spec/controllers/admin/resets_controller_spec.rb @@ -5,7 +5,7 @@ describe Admin::ResetsController do let(:account) { Fabricate(:account) } before do - sign_in Fabricate(:user, admin: true), scope: :user + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user end describe 'POST #create' do diff --git a/spec/controllers/admin/roles_controller_spec.rb b/spec/controllers/admin/roles_controller_spec.rb index 8e0de73cb..8ff891205 100644 --- a/spec/controllers/admin/roles_controller_spec.rb +++ b/spec/controllers/admin/roles_controller_spec.rb @@ -3,31 +3,247 @@ require 'rails_helper' describe Admin::RolesController do render_views - let(:admin) { Fabricate(:user, admin: true) } + let(:permissions) { UserRole::Flags::NONE } + let(:current_role) { UserRole.create(name: 'Foo', permissions: permissions, position: 10) } + let(:current_user) { Fabricate(:user, role: current_role) } before do - sign_in admin, scope: :user + sign_in current_user, scope: :user end - describe 'POST #promote' do - subject { post :promote, params: { account_id: user.account_id } } + describe 'GET #index' do + before do + get :index + end + + context 'when user does not have permission to manage roles' do + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end - let(:user) { Fabricate(:user, moderator: false, admin: false) } + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } - it 'promotes user' do - expect(subject).to redirect_to admin_account_path(user.account_id) - expect(user.reload).to be_moderator + it 'returns http success' do + expect(response).to have_http_status(:success) + end end end - describe 'POST #demote' do - subject { post :demote, params: { account_id: user.account_id } } + describe 'GET #new' do + before do + get :new + end + + context 'when user does not have permission to manage roles' do + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + end + + describe 'POST #create' do + let(:selected_position) { 1 } + let(:selected_permissions_as_keys) { %w(manage_roles) } + + before do + post :create, params: { user_role: { name: 'Bar', position: selected_position, permissions_as_keys: selected_permissions_as_keys } } + end + + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + + context 'when new role\'s does not elevate above the user\'s role' do + let(:selected_position) { 1 } + let(:selected_permissions_as_keys) { %w(manage_roles) } + + it 'redirects to roles page' do + expect(response).to redirect_to(admin_roles_path) + end + + it 'creates new role' do + expect(UserRole.find_by(name: 'Bar')).to_not be_nil + end + end + + context 'when new role\'s position is higher than user\'s role' do + let(:selected_position) { 100 } + let(:selected_permissions_as_keys) { %w(manage_roles) } + + it 'renders new template' do + expect(response).to render_template(:new) + end + + it 'does not create new role' do + expect(UserRole.find_by(name: 'Bar')).to be_nil + end + end + + context 'when new role has permissions the user does not have' do + let(:selected_position) { 1 } + let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) } + + it 'renders new template' do + expect(response).to render_template(:new) + end + + it 'does not create new role' do + expect(UserRole.find_by(name: 'Bar')).to be_nil + end + end + + context 'when user has administrator permission' do + let(:permissions) { UserRole::FLAGS[:administrator] } + + let(:selected_position) { 1 } + let(:selected_permissions_as_keys) { %w(manage_roles manage_users manage_reports) } + + it 'redirects to roles page' do + expect(response).to redirect_to(admin_roles_path) + end + + it 'creates new role' do + expect(UserRole.find_by(name: 'Bar')).to_not be_nil + end + end + end + end + + describe 'GET #edit' do + let(:role_position) { 8 } + let(:role) { UserRole.create(name: 'Bar', permissions: UserRole::FLAGS[:manage_users], position: role_position) } + + before do + get :edit, params: { id: role.id } + end + + context 'when user does not have permission to manage roles' do + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + + context 'when user outranks the role' do + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'when role outranks user' do + let(:role_position) { current_role.position + 1 } + + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + end + end + + describe 'PUT #update' do + let(:role_position) { 8 } + let(:role_permissions) { UserRole::FLAGS[:manage_users] } + let(:role) { UserRole.create(name: 'Bar', permissions: role_permissions, position: role_position) } + + let(:selected_position) { 8 } + let(:selected_permissions_as_keys) { %w(manage_users) } + + before do + put :update, params: { id: role.id, user_role: { name: 'Baz', position: selected_position, permissions_as_keys: selected_permissions_as_keys } } + end + + context 'when user does not have permission to manage roles' do + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + + it 'does not update the role' do + expect(role.reload.name).to eq 'Bar' + end + end + + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + + context 'when role has permissions the user doesn\'t' do + it 'renders edit template' do + expect(response).to render_template(:edit) + end + + it 'does not update the role' do + expect(role.reload.name).to eq 'Bar' + end + end + + context 'when user has all permissions of the role' do + let(:permissions) { UserRole::FLAGS[:manage_roles] | UserRole::FLAGS[:manage_users] } + + context 'when user outranks the role' do + it 'redirects to roles page' do + expect(response).to redirect_to(admin_roles_path) + end + + it 'updates the role' do + expect(role.reload.name).to eq 'Baz' + end + end + + context 'when role outranks user' do + let(:role_position) { current_role.position + 1 } + + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + + it 'does not update the role' do + expect(role.reload.name).to eq 'Bar' + end + end + end + end + end + + describe 'DELETE #destroy' do + let(:role_position) { 8 } + let(:role) { UserRole.create(name: 'Bar', permissions: UserRole::FLAGS[:manage_users], position: role_position) } + + before do + delete :destroy, params: { id: role.id } + end + + context 'when user does not have permission to manage roles' do + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + + context 'when user has permission to manage roles' do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + + context 'when user outranks the role' do + it 'redirects to roles page' do + expect(response).to redirect_to(admin_roles_path) + end + end - let(:user) { Fabricate(:user, moderator: true, admin: false) } + context 'when role outranks user' do + let(:role_position) { current_role.position + 1 } - it 'demotes user' do - expect(subject).to redirect_to admin_account_path(user.account_id) - expect(user.reload).not_to be_moderator + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end end end end diff --git a/spec/controllers/admin/settings_controller_spec.rb b/spec/controllers/admin/settings_controller_spec.rb index 6cf0ee20a..46749f76c 100644 --- a/spec/controllers/admin/settings_controller_spec.rb +++ b/spec/controllers/admin/settings_controller_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Admin::SettingsController, type: :controller do describe 'When signed in as an admin' do before do - sign_in Fabricate(:user, admin: true), scope: :user + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user end describe 'GET #edit' do diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index de32fd18e..227688e23 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe Admin::StatusesController do render_views - let(:user) { Fabricate(:user, admin: true) } + let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:account) { Fabricate(:account) } let!(:status) { Fabricate(:status, account: account) } let(:media_attached_status) { Fabricate(:status, account: account, sensitive: !sensitive) } diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb index 85c801a9c..52fd09eb1 100644 --- a/spec/controllers/admin/tags_controller_spec.rb +++ b/spec/controllers/admin/tags_controller_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::TagsController, type: :controller do render_views before do - sign_in Fabricate(:user, admin: true) + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) end describe 'GET #show' do diff --git a/spec/controllers/admin/two_factor_authentications_controller_spec.rb b/spec/controllers/admin/two_factor_authentications_controller_spec.rb deleted file mode 100644 index c65095729..000000000 --- a/spec/controllers/admin/two_factor_authentications_controller_spec.rb +++ /dev/null @@ -1,51 +0,0 @@ -require 'rails_helper' -require 'webauthn/fake_client' - -describe Admin::TwoFactorAuthenticationsController do - render_views - - let(:user) { Fabricate(:user) } - before do - sign_in Fabricate(:user, admin: true), scope: :user - end - - describe 'DELETE #destroy' do - context 'when user has OTP enabled' do - before do - user.update(otp_required_for_login: true) - end - - it 'redirects to admin account page' do - delete :destroy, params: { user_id: user.id } - - user.reload - expect(user.otp_enabled?).to eq false - expect(response).to redirect_to(admin_account_path(user.account_id)) - end - end - - context 'when user has OTP and WebAuthn enabled' do - let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') } - - before do - user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id) - - public_key_credential = WebAuthn::Credential.from_create(fake_client.create) - Fabricate(:webauthn_credential, - user_id: user.id, - external_id: public_key_credential.id, - public_key: public_key_credential.public_key, - nickname: 'Security Key') - end - - it 'redirects to admin account page' do - delete :destroy, params: { user_id: user.id } - - user.reload - expect(user.otp_enabled?).to eq false - expect(user.webauthn_enabled?).to eq false - expect(response).to redirect_to(admin_account_path(user.account_id)) - end - end - end -end diff --git a/spec/controllers/admin/users/roles_controller.rb b/spec/controllers/admin/users/roles_controller.rb new file mode 100644 index 000000000..bd6a3fa67 --- /dev/null +++ b/spec/controllers/admin/users/roles_controller.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +describe Admin::Users::RolesController do + render_views + + let(:current_role) { UserRole.create(name: 'Foo', permissions: UserRole::FLAGS[:manage_roles], position: 10) } + let(:current_user) { Fabricate(:user, role: current_role) } + + let(:previous_role) { nil } + let(:user) { Fabricate(:user, role: previous_role) } + + before do + sign_in current_user, scope: :user + end + + describe 'GET #show' do + before do + get :show, params: { user_id: user.id } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + context 'when target user is higher ranked than current user' do + let(:previous_role) { UserRole.create(name: 'Baz', permissions: UserRole::FLAGS[:administrator], position: 100) } + + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + end + + describe 'PUT #update' do + let(:selected_role) { UserRole.create(name: 'Bar', permissions: permissions, position: position) } + + before do + put :update, params: { user_id: user.id, user: { role_id: selected_role.id } } + end + + context do + let(:permissions) { UserRole::FLAGS[:manage_roles] } + let(:position) { 1 } + + it 'updates user role' do + expect(user.reload.role_id).to eq selected_role&.id + end + + it 'redirects back to account page' do + expect(response).to redirect_to(admin_account_path(user.account_id)) + end + end + + context 'when selected role has higher position than current user\'s role' do + let(:permissions) { UserRole::FLAGS[:administrator] } + let(:position) { 100 } + + it 'does not update user role' do + expect(user.reload.role_id).to eq previous_role&.id + end + + it 'renders edit form' do + expect(response).to render_template(:show) + end + end + + context 'when target user is higher ranked than current user' do + let(:previous_role) { UserRole.create(name: 'Baz', permissions: UserRole::FLAGS[:administrator], position: 100) } + let(:permissions) { UserRole::FLAGS[:manage_roles] } + let(:position) { 1 } + + it 'does not update user role' do + expect(user.reload.role_id).to eq previous_role&.id + end + + it 'returns http forbidden' do + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb b/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb new file mode 100644 index 000000000..e56264ef6 --- /dev/null +++ b/spec/controllers/admin/users/two_factor_authentications_controller_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' +require 'webauthn/fake_client' + +describe Admin::Users::TwoFactorAuthenticationsController do + render_views + + let(:user) { Fabricate(:user) } + + before do + sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')), scope: :user + end + + describe 'DELETE #destroy' do + context 'when user has OTP enabled' do + before do + user.update(otp_required_for_login: true) + end + + it 'redirects to admin account page' do + delete :destroy, params: { user_id: user.id } + + user.reload + expect(user.otp_enabled?).to eq false + expect(response).to redirect_to(admin_account_path(user.account_id)) + end + end + + context 'when user has OTP and WebAuthn enabled' do + let(:fake_client) { WebAuthn::FakeClient.new('http://test.host') } + + before do + user.update(otp_required_for_login: true, webauthn_id: WebAuthn.generate_user_id) + + public_key_credential = WebAuthn::Credential.from_create(fake_client.create) + Fabricate(:webauthn_credential, + user_id: user.id, + external_id: public_key_credential.id, + public_key: public_key_credential.public_key, + nickname: 'Security Key') + end + + it 'redirects to admin account page' do + delete :destroy, params: { user_id: user.id } + + user.reload + expect(user.otp_enabled?).to eq false + expect(user.webauthn_enabled?).to eq false + expect(response).to redirect_to(admin_account_path(user.account_id)) + end + end + end +end diff --git a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb index 601290b82..199395f55 100644 --- a/spec/controllers/api/v1/admin/account_actions_controller_spec.rb +++ b/spec/controllers/api/v1/admin/account_actions_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do render_views - let(:role) { 'moderator' } + let(:role) { UserRole.find_by(name: 'Moderator') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -35,7 +35,7 @@ RSpec.describe Api::V1::Admin::AccountActionsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) diff --git a/spec/controllers/api/v1/admin/accounts_controller_spec.rb b/spec/controllers/api/v1/admin/accounts_controller_spec.rb index b69595f7e..cd38030e0 100644 --- a/spec/controllers/api/v1/admin/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/admin/accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Admin::AccountsController, type: :controller do render_views - let(:role) { 'moderator' } + let(:role) { UserRole.find_by(name: 'Moderator') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -46,7 +46,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' [ [{ active: 'true', local: 'true', staff: 'true' }, [:admin_account]], @@ -77,7 +77,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -91,7 +91,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -109,7 +109,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -127,7 +127,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -145,7 +145,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -163,7 +163,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -181,7 +181,7 @@ RSpec.describe Api::V1::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) diff --git a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb index edee3ab6c..26a391a60 100644 --- a/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb +++ b/spec/controllers/api/v1/admin/domain_allows_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do render_views - let(:role) { 'admin' } + let(:role) { UserRole.find_by(name: 'Admin') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -21,7 +21,7 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -36,8 +36,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -58,8 +58,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -79,8 +79,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -99,8 +99,8 @@ RSpec.describe Api::V1::Admin::DomainAllowsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) diff --git a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb index 196f6dc28..f12285b2a 100644 --- a/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb +++ b/spec/controllers/api/v1/admin/domain_blocks_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do render_views - let(:role) { 'admin' } + let(:role) { UserRole.find_by(name: 'Admin') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -21,7 +21,7 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -36,8 +36,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -58,8 +58,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -79,8 +79,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) @@ -100,8 +100,8 @@ RSpec.describe Api::V1::Admin::DomainBlocksController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' - it_behaves_like 'forbidden for wrong role', 'moderator' + it_behaves_like 'forbidden for wrong role', '' + it_behaves_like 'forbidden for wrong role', 'Moderator' it 'returns http success' do expect(response).to have_http_status(200) diff --git a/spec/controllers/api/v1/admin/reports_controller_spec.rb b/spec/controllers/api/v1/admin/reports_controller_spec.rb index b6df53048..880e72030 100644 --- a/spec/controllers/api/v1/admin/reports_controller_spec.rb +++ b/spec/controllers/api/v1/admin/reports_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V1::Admin::ReportsController, type: :controller do render_views - let(:role) { 'moderator' } + let(:role) { UserRole.find_by(name: 'Moderator') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -22,7 +22,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -35,7 +35,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -48,7 +48,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -61,7 +61,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -74,7 +74,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -87,7 +87,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) @@ -100,7 +100,7 @@ RSpec.describe Api::V1::Admin::ReportsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' it 'returns http success' do expect(response).to have_http_status(200) diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index b5baf60e1..dbc64e704 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Api::V1::ReportsController, type: :controller do end describe 'POST #create' do - let!(:admin) { Fabricate(:user, admin: true) } + let!(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:scopes) { 'write:reports' } let(:status) { Fabricate(:status) } diff --git a/spec/controllers/api/v2/admin/accounts_controller_spec.rb b/spec/controllers/api/v2/admin/accounts_controller_spec.rb index 3212ddb84..2508a9e05 100644 --- a/spec/controllers/api/v2/admin/accounts_controller_spec.rb +++ b/spec/controllers/api/v2/admin/accounts_controller_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' RSpec.describe Api::V2::Admin::AccountsController, type: :controller do render_views - let(:role) { 'moderator' } + let(:role) { UserRole.find_by(name: 'Moderator') } let(:user) { Fabricate(:user, role: role) } let(:scopes) { 'admin:read admin:write' } let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } @@ -22,7 +22,7 @@ RSpec.describe Api::V2::Admin::AccountsController, type: :controller do end shared_examples 'forbidden for wrong role' do |wrong_role| - let(:role) { wrong_role } + let(:role) { UserRole.find_by(name: wrong_role) } it 'returns http forbidden' do expect(response).to have_http_status(403) @@ -46,7 +46,7 @@ RSpec.describe Api::V2::Admin::AccountsController, type: :controller do end it_behaves_like 'forbidden for wrong scope', 'write:statuses' - it_behaves_like 'forbidden for wrong role', 'user' + it_behaves_like 'forbidden for wrong role', '' [ [{ status: 'active', origin: 'local', permissions: 'staff' }, [:admin_account]], diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb index 53e163d49..1b002e01c 100644 --- a/spec/controllers/application_controller_spec.rb +++ b/spec/controllers/application_controller_spec.rb @@ -183,70 +183,6 @@ describe ApplicationController, type: :controller do end end - describe 'require_admin!' do - controller do - before_action :require_admin! - - def success - head 200 - end - end - - before do - routes.draw { get 'success' => 'anonymous#success' } - end - - it 'returns a 403 if current user is not admin' do - sign_in(Fabricate(:user, admin: false)) - get 'success' - expect(response).to have_http_status(403) - end - - it 'returns a 403 if current user is only a moderator' do - sign_in(Fabricate(:user, moderator: true)) - get 'success' - expect(response).to have_http_status(403) - end - - it 'does nothing if current user is admin' do - sign_in(Fabricate(:user, admin: true)) - get 'success' - expect(response).to have_http_status(200) - end - end - - describe 'require_staff!' do - controller do - before_action :require_staff! - - def success - head 200 - end - end - - before do - routes.draw { get 'success' => 'anonymous#success' } - end - - it 'returns a 403 if current user is not admin or moderator' do - sign_in(Fabricate(:user, admin: false, moderator: false)) - get 'success' - expect(response).to have_http_status(403) - end - - it 'does nothing if current user is moderator' do - sign_in(Fabricate(:user, moderator: true)) - get 'success' - expect(response).to have_http_status(200) - end - - it 'does nothing if current user is admin' do - sign_in(Fabricate(:user, admin: true)) - get 'success' - expect(response).to have_http_status(200) - end - end - describe 'forbidden' do controller do def route_forbidden diff --git a/spec/controllers/disputes/appeals_controller_spec.rb b/spec/controllers/disputes/appeals_controller_spec.rb index faa571fc9..90f222f49 100644 --- a/spec/controllers/disputes/appeals_controller_spec.rb +++ b/spec/controllers/disputes/appeals_controller_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Disputes::AppealsController, type: :controller do before { sign_in current_user, scope: :user } - let!(:admin) { Fabricate(:user, admin: true) } + let!(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } describe '#create' do let(:current_user) { Fabricate(:user) } diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 76e617e6b..23b98fb12 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -7,30 +7,30 @@ describe InvitesController do sign_in user end - around do |example| - min_invite_role = Setting.min_invite_role - example.run - Setting.min_invite_role = min_invite_role - end - describe 'GET #index' do subject { get :index } - let(:user) { Fabricate(:user, moderator: false, admin: false) } + let(:user) { Fabricate(:user) } let!(:invite) { Fabricate(:invite, user: user) } - context 'when user is a staff' do + context 'when everyone can invite' do + before do + UserRole.everyone.update(permissions: UserRole.everyone.permissions | UserRole::FLAGS[:invite_users]) + end + it 'renders index page' do - Setting.min_invite_role = 'user' expect(subject).to render_template :index expect(assigns(:invites)).to include invite expect(assigns(:invites).count).to eq 1 end end - context 'when user is not a staff' do + context 'when not everyone can invite' do + before do + UserRole.everyone.update(permissions: UserRole.everyone.permissions & ~UserRole::FLAGS[:invite_users]) + end + it 'returns 403' do - Setting.min_invite_role = 'modelator' expect(subject).to have_http_status 403 end end @@ -39,8 +39,12 @@ describe InvitesController do describe 'POST #create' do subject { post :create, params: { invite: { max_uses: '10', expires_in: 1800 } } } - context 'when user is an admin' do - let(:user) { Fabricate(:user, moderator: false, admin: true) } + context 'when everyone can invite' do + let(:user) { Fabricate(:user) } + + before do + UserRole.everyone.update(permissions: UserRole.everyone.permissions | UserRole::FLAGS[:invite_users]) + end it 'succeeds to create a invite' do expect { subject }.to change { Invite.count }.by(1) @@ -49,8 +53,12 @@ describe InvitesController do end end - context 'when user is not an admin' do - let(:user) { Fabricate(:user, moderator: true, admin: false) } + context 'when not everyone can invite' do + let(:user) { Fabricate(:user) } + + before do + UserRole.everyone.update(permissions: UserRole.everyone.permissions & ~UserRole::FLAGS[:invite_users]) + end it 'returns 403' do expect(subject).to have_http_status 403 @@ -61,8 +69,8 @@ describe InvitesController do describe 'DELETE #create' do subject { delete :destroy, params: { id: invite.id } } + let(:user) { Fabricate(:user) } let!(:invite) { Fabricate(:invite, user: user, expires_at: nil) } - let(:user) { Fabricate(:user, moderator: false, admin: true) } it 'expires invite' do expect(subject).to redirect_to invites_path diff --git a/spec/fabricators/user_role_fabricator.rb b/spec/fabricators/user_role_fabricator.rb new file mode 100644 index 000000000..28f76c8c4 --- /dev/null +++ b/spec/fabricators/user_role_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:user_role) do + name "MyString" + color "MyString" + permissions "" +end \ No newline at end of file diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index dc0ca3da3..467d41836 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -445,7 +445,7 @@ RSpec.describe Account, type: :model do it 'accepts arbitrary limits' do 2.times.each { Fabricate(:account, display_name: "Display Name") } - results = Account.search_for("display", 1) + results = Account.search_for("display", limit: 1) expect(results.size).to eq 1 end @@ -473,7 +473,7 @@ RSpec.describe Account, type: :model do ) account.follow!(match) - results = Account.advanced_search_for('A?l\i:c e', account, 10, true) + results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) expect(results).to eq [match] end @@ -485,7 +485,7 @@ RSpec.describe Account, type: :model do domain: 'example.com' ) - results = Account.advanced_search_for('A?l\i:c e', account, 10, true) + results = Account.advanced_search_for('A?l\i:c e', account, limit: 10, following: true) expect(results).to eq [] end @@ -498,7 +498,7 @@ RSpec.describe Account, type: :model do suspended: true ) - results = Account.advanced_search_for('username', account, 10, true) + results = Account.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end @@ -511,7 +511,7 @@ RSpec.describe Account, type: :model do match.user.update(approved: false) - results = Account.advanced_search_for('username', account, 10, true) + results = Account.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end @@ -524,7 +524,7 @@ RSpec.describe Account, type: :model do match.user.update(confirmed_at: nil) - results = Account.advanced_search_for('username', account, 10, true) + results = Account.advanced_search_for('username', account, limit: 10, following: true) expect(results).to eq [] end end @@ -588,7 +588,7 @@ RSpec.describe Account, type: :model do it 'accepts arbitrary limits' do 2.times { Fabricate(:account, display_name: "Display Name") } - results = Account.advanced_search_for("display", account, 1) + results = Account.advanced_search_for("display", account, limit: 1) expect(results.size).to eq 1 end diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index 809c7fc46..b6a052b76 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -5,7 +5,7 @@ RSpec.describe Admin::AccountAction, type: :model do describe '#save!' do subject { account_action.save! } - let(:account) { Fabricate(:user, admin: true).account } + let(:account) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:target_account) { Fabricate(:account) } let(:type) { 'disable' } diff --git a/spec/models/user_role_spec.rb b/spec/models/user_role_spec.rb new file mode 100644 index 000000000..28019593e --- /dev/null +++ b/spec/models/user_role_spec.rb @@ -0,0 +1,189 @@ +require 'rails_helper' + +RSpec.describe UserRole, type: :model do + subject { described_class.create(name: 'Foo', position: 1) } + + describe '#can?' do + context 'with a single flag' do + it 'returns true if any of them are present' do + subject.permissions = UserRole::FLAGS[:manage_reports] + expect(subject.can?(:manage_reports)).to be true + end + + it 'returns false if it is not set' do + expect(subject.can?(:manage_reports)).to be false + end + end + + context 'with multiple flags' do + it 'returns true if any of them are present' do + subject.permissions = UserRole::FLAGS[:manage_users] + expect(subject.can?(:manage_reports, :manage_users)).to be true + end + + it 'returns false if none of them are present' do + expect(subject.can?(:manage_reports, :manage_users)).to be false + end + end + + context 'with an unknown flag' do + it 'raises an error' do + expect { subject.can?(:foo) }.to raise_error ArgumentError + end + end + end + + describe '#overrides?' do + it 'returns true if other role has lower position' do + expect(subject.overrides?(described_class.new(position: subject.position - 1))).to be true + end + + it 'returns true if other role is nil' do + expect(subject.overrides?(nil)).to be true + end + + it 'returns false if other role has higher position' do + expect(subject.overrides?(described_class.new(position: subject.position + 1))).to be false + end + end + + describe '#permissions_as_keys' do + before do + subject.permissions = UserRole::FLAGS[:invite_users] | UserRole::FLAGS[:view_dashboard] | UserRole::FLAGS[:manage_reports] + end + + it 'returns an array' do + expect(subject.permissions_as_keys).to match_array %w(invite_users view_dashboard manage_reports) + end + end + + describe '#permissions_as_keys=' do + let(:input) { } + + before do + subject.permissions_as_keys = input + end + + context 'with a single value' do + let(:input) { %w(manage_users) } + + it 'sets permission flags' do + expect(subject.permissions).to eq UserRole::FLAGS[:manage_users] + end + end + + context 'with multiple values' do + let(:input) { %w(manage_users manage_reports) } + + it 'sets permission flags' do + expect(subject.permissions).to eq UserRole::FLAGS[:manage_users] | UserRole::FLAGS[:manage_reports] + end + end + + context 'with an unknown value' do + let(:input) { %w(foo) } + + it 'does not set permission flags' do + expect(subject.permissions).to eq UserRole::Flags::NONE + end + end + end + + describe '#computed_permissions' do + context 'when the role is nobody' do + let(:subject) { described_class.nobody } + + it 'returns none' do + expect(subject.computed_permissions).to eq UserRole::Flags::NONE + end + end + + context 'when the role is everyone' do + let(:subject) { described_class.everyone } + + it 'returns permissions' do + expect(subject.computed_permissions).to eq subject.permissions + end + end + + context 'when role has the administrator flag' do + before do + subject.permissions = UserRole::FLAGS[:administrator] + end + + it 'returns all permissions' do + expect(subject.computed_permissions).to eq UserRole::Flags::ALL + end + end + + context do + it 'returns permissions combined with the everyone role' do + expect(subject.computed_permissions).to eq described_class.everyone.permissions + end + end + end + + describe '.everyone' do + subject { described_class.everyone } + + it 'returns a role' do + expect(subject).to be_kind_of(described_class) + end + + it 'is identified as the everyone role' do + expect(subject.everyone?).to be true + end + + it 'has default permissions' do + expect(subject.permissions).to eq UserRole::FLAGS[:invite_users] + end + + it 'has negative position' do + expect(subject.position).to eq -1 + end + end + + describe '.nobody' do + subject { described_class.nobody } + + it 'returns a role' do + expect(subject).to be_kind_of(described_class) + end + + it 'is identified as the nobody role' do + expect(subject.nobody?).to be true + end + + it 'has no permissions' do + expect(subject.permissions).to eq UserRole::Flags::NONE + end + + it 'has negative position' do + expect(subject.position).to eq -1 + end + end + + describe '#everyone?' do + it 'returns true when id is -99' do + subject.id = -99 + expect(subject.everyone?).to be true + end + + it 'returns false when id is not -99' do + subject.id = 123 + expect(subject.everyone?).to be false + end + end + + describe '#nobody?' do + it 'returns true when id is nil' do + subject.id = nil + expect(subject.nobody?).to be true + end + + it 'returns false when id is not nil' do + subject.id = 123 + expect(subject.nobody?).to be false + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 1645ab59e..a7da31e60 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -56,14 +56,6 @@ RSpec.describe User, type: :model do end end - describe 'admins' do - it 'returns an array of users who are admin' do - user_1 = Fabricate(:user, admin: false) - user_2 = Fabricate(:user, admin: true) - expect(User.admins).to match_array([user_2]) - end - end - describe 'confirmed' do it 'returns an array of users who are confirmed' do user_1 = Fabricate(:user, confirmed_at: nil) @@ -289,49 +281,6 @@ RSpec.describe User, type: :model do end end - describe '#role' do - it 'returns admin for admin' do - user = User.new(admin: true) - expect(user.role).to eq 'admin' - end - - it 'returns moderator for moderator' do - user = User.new(moderator: true) - expect(user.role).to eq 'moderator' - end - - it 'returns user otherwise' do - user = User.new - expect(user.role).to eq 'user' - end - end - - describe '#role?' do - it 'returns false when invalid role requested' do - user = User.new(admin: true) - expect(user.role?('disabled')).to be false - end - - it 'returns true when exact role match' do - user = User.new - mod = User.new(moderator: true) - admin = User.new(admin: true) - - expect(user.role?('user')).to be true - expect(mod.role?('moderator')).to be true - expect(admin.role?('admin')).to be true - end - - it 'returns true when role higher than needed' do - mod = User.new(moderator: true) - admin = User.new(admin: true) - - expect(mod.role?('user')).to be true - expect(admin.role?('user')).to be true - expect(admin.role?('moderator')).to be true - end - end - describe '#disable!' do subject(:user) { Fabricate(:user, disabled: false, current_sign_in_at: current_sign_in_at, last_sign_in_at: nil) } let(:current_sign_in_at) { Time.zone.now } @@ -420,110 +369,6 @@ RSpec.describe User, type: :model do end end - describe '#promote!' do - subject(:user) { Fabricate(:user, admin: is_admin, moderator: is_moderator) } - - before do - user.promote! - end - - context 'when user is an admin' do - let(:is_admin) { true } - - context 'when user is a moderator' do - let(:is_moderator) { true } - - it 'changes moderator filed false' do - expect(user).to be_admin - expect(user).not_to be_moderator - end - end - - context 'when user is not a moderator' do - let(:is_moderator) { false } - - it 'does not change status' do - expect(user).to be_admin - expect(user).not_to be_moderator - end - end - end - - context 'when user is not admin' do - let(:is_admin) { false } - - context 'when user is a moderator' do - let(:is_moderator) { true } - - it 'changes user into an admin' do - expect(user).to be_admin - expect(user).not_to be_moderator - end - end - - context 'when user is not a moderator' do - let(:is_moderator) { false } - - it 'changes user into a moderator' do - expect(user).not_to be_admin - expect(user).to be_moderator - end - end - end - end - - describe '#demote!' do - subject(:user) { Fabricate(:user, admin: admin, moderator: moderator) } - - before do - user.demote! - end - - context 'when user is an admin' do - let(:admin) { true } - - context 'when user is a moderator' do - let(:moderator) { true } - - it 'changes user into a moderator' do - expect(user).not_to be_admin - expect(user).to be_moderator - end - end - - context 'when user is not a moderator' do - let(:moderator) { false } - - it 'changes user into a moderator' do - expect(user).not_to be_admin - expect(user).to be_moderator - end - end - end - - context 'when user is not an admin' do - let(:admin) { false } - - context 'when user is a moderator' do - let(:moderator) { true } - - it 'changes user into a plain user' do - expect(user).not_to be_admin - expect(user).not_to be_moderator - end - end - - context 'when user is not a moderator' do - let(:moderator) { false } - - it 'does not change any fields' do - expect(user).not_to be_admin - expect(user).not_to be_moderator - end - end - end - end - describe '#active_for_authentication?' do subject { user.active_for_authentication? } let(:user) { Fabricate(:user, disabled: disabled, confirmed_at: confirmed_at) } @@ -560,4 +405,8 @@ RSpec.describe User, type: :model do end end end + + describe '.those_who_can' do + pending + end end diff --git a/spec/policies/account_moderation_note_policy_spec.rb b/spec/policies/account_moderation_note_policy_spec.rb index 39ec2008a..846747346 100644 --- a/spec/policies/account_moderation_note_policy_spec.rb +++ b/spec/policies/account_moderation_note_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe AccountModerationNotePolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :create? do @@ -31,7 +31,7 @@ RSpec.describe AccountModerationNotePolicy do context 'admin' do it 'grants to destroy' do - expect(subject).to permit(admin, AccountModerationNotePolicy) + expect(subject).to permit(admin, account_moderation_note) end end diff --git a/spec/policies/account_policy_spec.rb b/spec/policies/account_policy_spec.rb index b55eb65a7..0f23fd97e 100644 --- a/spec/policies/account_policy_spec.rb +++ b/spec/policies/account_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe AccountPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } let(:alice) { Fabricate(:account) } @@ -55,7 +55,7 @@ RSpec.describe AccountPolicy do end end - permissions :redownload?, :subscribe?, :unsubscribe? do + permissions :redownload? do context 'admin' do it 'permits' do expect(subject).to permit(admin) @@ -70,7 +70,7 @@ RSpec.describe AccountPolicy do end permissions :suspend?, :silence? do - let(:staff) { Fabricate(:user, admin: true).account } + let(:staff) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } context 'staff' do context 'record is staff' do @@ -94,7 +94,7 @@ RSpec.describe AccountPolicy do end permissions :memorialize? do - let(:other_admin) { Fabricate(:user, admin: true).account } + let(:other_admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } context 'admin' do context 'record is admin' do diff --git a/spec/policies/custom_emoji_policy_spec.rb b/spec/policies/custom_emoji_policy_spec.rb index e4f1af3c1..6a6ef6694 100644 --- a/spec/policies/custom_emoji_policy_spec.rb +++ b/spec/policies/custom_emoji_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe CustomEmojiPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :index?, :enable?, :disable? do diff --git a/spec/policies/domain_block_policy_spec.rb b/spec/policies/domain_block_policy_spec.rb index b24ed9e3a..01b97e823 100644 --- a/spec/policies/domain_block_policy_spec.rb +++ b/spec/policies/domain_block_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe DomainBlockPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :index?, :show?, :create?, :destroy? do diff --git a/spec/policies/email_domain_block_policy_spec.rb b/spec/policies/email_domain_block_policy_spec.rb index 1ff55af8e..913075c3d 100644 --- a/spec/policies/email_domain_block_policy_spec.rb +++ b/spec/policies/email_domain_block_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe EmailDomainBlockPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :index?, :create?, :destroy? do diff --git a/spec/policies/instance_policy_spec.rb b/spec/policies/instance_policy_spec.rb index 71ef1fe50..f6f51af06 100644 --- a/spec/policies/instance_policy_spec.rb +++ b/spec/policies/instance_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe InstancePolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :index?, :show?, :destroy? do diff --git a/spec/policies/invite_policy_spec.rb b/spec/policies/invite_policy_spec.rb index 122137804..01660322f 100644 --- a/spec/policies/invite_policy_spec.rb +++ b/spec/policies/invite_policy_spec.rb @@ -5,8 +5,8 @@ require 'pundit/rspec' RSpec.describe InvitePolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } - let(:john) { Fabricate(:account) } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } + let(:john) { Fabricate(:user).account } permissions :index? do context 'staff?' do @@ -17,16 +17,22 @@ RSpec.describe InvitePolicy do end permissions :create? do - context 'min_required_role?' do + context 'has privilege' do + before do + UserRole.everyone.update(permissions: UserRole::FLAGS[:invite_users]) + end + it 'permits' do - allow_any_instance_of(described_class).to receive(:min_required_role?) { true } expect(subject).to permit(john, Invite) end end - context 'not min_required_role?' do + context 'does not have privilege' do + before do + UserRole.everyone.update(permissions: UserRole::Flags::NONE) + end + it 'denies' do - allow_any_instance_of(described_class).to receive(:min_required_role?) { false } expect(subject).to_not permit(john, Invite) end end @@ -54,39 +60,15 @@ RSpec.describe InvitePolicy do end context 'not owner?' do - context 'Setting.min_invite_role == "admin"' do - before do - Setting.min_invite_role = 'admin' - end - - context 'admin?' do - it 'permits' do - expect(subject).to permit(admin, Fabricate(:invite)) - end - end - - context 'not admin?' do - it 'denies' do - expect(subject).to_not permit(john, Fabricate(:invite)) - end + context 'admin?' do + it 'permits' do + expect(subject).to permit(admin, Fabricate(:invite)) end end - context 'Setting.min_invite_role != "admin"' do - before do - Setting.min_invite_role = 'else' - end - - context 'staff?' do - it 'permits' do - expect(subject).to permit(admin, Fabricate(:invite)) - end - end - - context 'not staff?' do - it 'denies' do - expect(subject).to_not permit(john, Fabricate(:invite)) - end + context 'not admin?' do + it 'denies' do + expect(subject).to_not permit(john, Fabricate(:invite)) end end end diff --git a/spec/policies/relay_policy_spec.rb b/spec/policies/relay_policy_spec.rb index 139d945dc..2c50ba1e9 100644 --- a/spec/policies/relay_policy_spec.rb +++ b/spec/policies/relay_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe RelayPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :update? do diff --git a/spec/policies/report_note_policy_spec.rb b/spec/policies/report_note_policy_spec.rb index c34f99b71..99f5ffb8e 100644 --- a/spec/policies/report_note_policy_spec.rb +++ b/spec/policies/report_note_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe ReportNotePolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :create? do @@ -25,7 +25,8 @@ RSpec.describe ReportNotePolicy do permissions :destroy? do context 'admin?' do it 'permit' do - expect(subject).to permit(admin, ReportNote) + report_note = Fabricate(:report_note, account: john) + expect(subject).to permit(admin, report_note) end end diff --git a/spec/policies/report_policy_spec.rb b/spec/policies/report_policy_spec.rb index 84c366d7f..8b005d8dd 100644 --- a/spec/policies/report_policy_spec.rb +++ b/spec/policies/report_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe ReportPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :update?, :index?, :show? do diff --git a/spec/policies/settings_policy_spec.rb b/spec/policies/settings_policy_spec.rb index 3fa183c50..e16ee51a4 100644 --- a/spec/policies/settings_policy_spec.rb +++ b/spec/policies/settings_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe SettingsPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :update?, :show? do diff --git a/spec/policies/status_policy_spec.rb b/spec/policies/status_policy_spec.rb index 28b808ee2..205ecd720 100644 --- a/spec/policies/status_policy_spec.rb +++ b/spec/policies/status_policy_spec.rb @@ -6,7 +6,7 @@ require 'pundit/rspec' RSpec.describe StatusPolicy, type: :model do subject { described_class } - let(:admin) { Fabricate(:user, admin: true) } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) } let(:alice) { Fabricate(:account, username: 'alice') } let(:bob) { Fabricate(:account, username: 'bob') } let(:status) { Fabricate(:status, account: alice) } diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb index 256e6786a..9be7140fc 100644 --- a/spec/policies/tag_policy_spec.rb +++ b/spec/policies/tag_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe TagPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :index?, :show?, :update? do diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb index 731c041d1..ff0916674 100644 --- a/spec/policies/user_policy_spec.rb +++ b/spec/policies/user_policy_spec.rb @@ -5,7 +5,7 @@ require 'pundit/rspec' RSpec.describe UserPolicy do let(:subject) { described_class } - let(:admin) { Fabricate(:user, admin: true).account } + let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account } let(:john) { Fabricate(:account) } permissions :reset_password?, :change_email? do @@ -111,57 +111,4 @@ RSpec.describe UserPolicy do end end end - - permissions :promote? do - context 'admin?' do - context 'promotable?' do - it 'permits' do - expect(subject).to permit(admin, john.user) - end - end - - context '!promotable?' do - it 'denies' do - expect(subject).to_not permit(admin, admin.user) - end - end - end - - context '!admin?' do - it 'denies' do - expect(subject).to_not permit(john, User) - end - end - end - - permissions :demote? do - context 'admin?' do - context '!record.admin?' do - context 'demoteable?' do - it 'permits' do - john.user.update(moderator: true) - expect(subject).to permit(admin, john.user) - end - end - - context '!demoteable?' do - it 'denies' do - expect(subject).to_not permit(admin, john.user) - end - end - end - - context 'record.admin?' do - it 'denies' do - expect(subject).to_not permit(admin, admin.user) - end - end - end - - context '!admin?' do - it 'denies' do - expect(subject).to_not permit(john, User) - end - end - end end -- cgit From e7aa2be828f6a632dadd5c41e2364cea91ddbb2c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 13 Jul 2022 15:03:28 +0200 Subject: Change how hashtags are normalized (#18795) * Change how hashtags are normalized * Fix tests --- app/controllers/admin/tags_controller.rb | 4 ++- app/controllers/api/v1/featured_tags_controller.rb | 4 +-- .../settings/featured_tags_controller.rb | 1 - app/javascript/mastodon/actions/compose.js | 15 +++++++++- app/lib/ascii_folding.rb | 10 +++++++ app/lib/hashtag_normalizer.rb | 25 +++++++++++++++++ app/models/account.rb | 2 +- app/models/custom_filter.rb | 6 ++-- app/models/custom_filter_keyword.rb | 4 +-- app/models/featured_tag.rb | 31 ++++++++++++++------- app/models/tag.rb | 20 +++++++++++--- app/serializers/activitypub/hashtag_serializer.rb | 4 +-- app/serializers/rest/featured_tag_serializer.rb | 4 +++ app/serializers/rest/tag_serializer.rb | 4 +++ app/views/accounts/show.html.haml | 2 +- app/views/accounts/show.rss.ruby | 2 +- app/views/admin/tags/show.html.haml | 4 +-- app/views/admin/trends/tags/_tag.html.haml | 2 +- app/views/admin_mailer/_new_trending_tags.text.erb | 4 +-- app/views/settings/featured_tags/index.html.haml | 2 +- app/views/tags/_og.html.haml | 4 +-- app/views/tags/show.html.haml | 6 ++-- app/views/tags/show.rss.ruby | 6 ++-- config/initializers/inflections.rb | 1 + .../20220710102457_add_display_name_to_tags.rb | 5 ++++ db/schema.rb | 3 +- spec/lib/hashtag_normalizer_spec.rb | 29 ++++++++++++++++++++ spec/models/tag_spec.rb | 8 +++--- streaming/index.js | 32 ++++++++++++++++++++-- 29 files changed, 193 insertions(+), 51 deletions(-) create mode 100644 app/lib/ascii_folding.rb create mode 100644 app/lib/hashtag_normalizer.rb create mode 100644 db/migrate/20220710102457_add_display_name_to_tags.rb create mode 100644 spec/lib/hashtag_normalizer_spec.rb (limited to 'db/migrate') diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 749e2f144..4f727c398 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -16,6 +16,8 @@ module Admin if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') else + @time_period = (6.days.ago.to_date...Time.now.utc.to_date) + render :show end end @@ -27,7 +29,7 @@ module Admin end def tag_params - params.require(:tag).permit(:name, :trendable, :usable, :listable) + params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) end end end diff --git a/app/controllers/api/v1/featured_tags_controller.rb b/app/controllers/api/v1/featured_tags_controller.rb index e4e836c97..c1ead4f54 100644 --- a/app/controllers/api/v1/featured_tags_controller.rb +++ b/app/controllers/api/v1/featured_tags_controller.rb @@ -13,9 +13,7 @@ class Api::V1::FeaturedTagsController < Api::BaseController end def create - @featured_tag = current_account.featured_tags.new(featured_tag_params) - @featured_tag.reset_data - @featured_tag.save! + @featured_tag = current_account.featured_tags.create!(featured_tag_params) render json: @featured_tag, serializer: REST::FeaturedTagSerializer end diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index e805527d0..aadff7c83 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -11,7 +11,6 @@ class Settings::FeaturedTagsController < Settings::BaseController def create @featured_tag = current_account.featured_tags.new(featured_tag_params) - @featured_tag.reset_data if @featured_tag.save redirect_to settings_featured_tags_path diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index bd4c1d002..32d8c229e 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -606,7 +606,20 @@ function insertIntoTagHistory(recognizedTags, text) { const state = getState(); const oldHistory = state.getIn(['compose', 'tagHistory']); const me = state.getIn(['meta', 'me']); - const names = recognizedTags.map(tag => text.match(new RegExp(`#${tag.name}`, 'i'))[0].slice(1)); + + // FIXME: Matching input hashtags with recognized hashtags has become more + // complicated because of new normalization rules, it's no longer just + // a case sensitivity issue + const names = recognizedTags.map(tag => { + const matches = text.match(new RegExp(`#${tag.name}`, 'i')); + + if (matches && matches.length > 0) { + return matches[0].slice(1); + } else { + return tag.name; + } + }); + const intersectedOldHistory = oldHistory.filter(name => names.findIndex(newName => newName.toLowerCase() === name.toLowerCase()) === -1); names.push(...intersectedOldHistory.toJS()); diff --git a/app/lib/ascii_folding.rb b/app/lib/ascii_folding.rb new file mode 100644 index 000000000..1798d3d0e --- /dev/null +++ b/app/lib/ascii_folding.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ASCIIFolding + NON_ASCII_CHARS = 'ÀÁÂÃÄÅàáâãäåĀāĂ㥹ÇçĆćĈĉĊċČčÐðĎďĐđÈÉÊËèéêëĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħÌÍÎÏìíîïĨĩĪīĬĭĮįİıĴĵĶķĸĹĺĻļĽľĿŀŁłÑñŃńŅņŇňʼnŊŋÒÓÔÕÖØòóôõöøŌōŎŏŐőŔŕŖŗŘřŚśŜŝŞşŠšſŢţŤťŦŧÙÚÛÜùúûüŨũŪūŬŭŮůŰűŲųŴŵÝýÿŶŷŸŹźŻżŽž' + EQUIVALENT_ASCII_CHARS = 'AAAAAAaaaaaaAaAaAaCcCcCcCcCcDdDdDdEEEEeeeeEeEeEeEeEeGgGgGgGgHhHhIIIIiiiiIiIiIiIiIiJjKkkLlLlLlLlLlNnNnNnNnnNnOOOOOOooooooOoOoOoRrRrRrSsSsSsSssTtTtTtUUUUuuuuUuUuUuUuUuUuWwYyyYyYZzZzZz' + + def fold(str) + str.tr(NON_ASCII_CHARS, EQUIVALENT_ASCII_CHARS) + end +end diff --git a/app/lib/hashtag_normalizer.rb b/app/lib/hashtag_normalizer.rb new file mode 100644 index 000000000..c1f99e163 --- /dev/null +++ b/app/lib/hashtag_normalizer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class HashtagNormalizer + def normalize(str) + remove_invalid_characters(ascii_folding(lowercase(cjk_width(str)))) + end + + private + + def remove_invalid_characters(str) + str.gsub(/[^[:alnum:]#{Tag::HASHTAG_SEPARATORS}]/, '') + end + + def ascii_folding(str) + ASCIIFolding.new.fold(str) + end + + def lowercase(str) + str.mb_chars.downcase.to_s + end + + def cjk_width(str) + str.unicode_normalize(:nfkc) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 628692d22..fe77eaec4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -62,7 +62,7 @@ class Account < ApplicationRecord ) USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i - MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:word:]\.\-]+[[:word:]]+)?)/i + MENTION_RE = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[[:alnum:]\.\-]+[[:alnum:]]+)?)/i URL_PREFIX_RE = /\Ahttp(s?):\/\/[^\/]+/ include Attachmentable diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index e98ed7df9..985eab125 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -3,14 +3,14 @@ # # Table name: custom_filters # -# id :bigint not null, primary key -# account_id :bigint +# id :bigint(8) not null, primary key +# account_id :bigint(8) # expires_at :datetime # phrase :text default(""), not null # context :string default([]), not null, is an Array # created_at :datetime not null # updated_at :datetime not null -# action :integer default(0), not null +# action :integer default("warn"), not null # class CustomFilter < ApplicationRecord diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb index bf5c55746..e0d0289ae 100644 --- a/app/models/custom_filter_keyword.rb +++ b/app/models/custom_filter_keyword.rb @@ -3,8 +3,8 @@ # # Table name: custom_filter_keywords # -# id :bigint not null, primary key -# custom_filter_id :bigint not null +# id :bigint(8) not null, primary key +# custom_filter_id :bigint(8) not null # keyword :text default(""), not null # whole_word :boolean default(TRUE), not null # created_at :datetime not null diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index 74d62e777..c9c285bfa 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -13,17 +13,19 @@ # class FeaturedTag < ApplicationRecord - belongs_to :account, inverse_of: :featured_tags, required: true - belongs_to :tag, inverse_of: :featured_tags, required: true + belongs_to :account, inverse_of: :featured_tags + belongs_to :tag, inverse_of: :featured_tags, optional: true # Set after validation - delegate :name, to: :tag, allow_nil: true - - validates_associated :tag, on: :create - validates :name, presence: true, on: :create + validate :validate_tag_name, on: :create validate :validate_featured_tags_limit, on: :create - def name=(str) - self.tag = Tag.find_or_create_by_names(str.strip)&.first + before_create :set_tag + before_create :reset_data + + attr_writer :name + + def name + tag_id.present? ? tag.name : @name end def increment(timestamp) @@ -34,14 +36,23 @@ class FeaturedTag < ApplicationRecord update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at) end + private + + def set_tag + self.tag = Tag.find_or_create_by_names(@name)&.first + end + def reset_data self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at end - private - def validate_featured_tags_limit errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10 end + + def validate_tag_name + errors.add(:name, :blank) if @name.blank? + errors.add(:name, :invalid) unless @name.match?(/\A(#{Tag::HASHTAG_NAME_RE})\z/i) + end end diff --git a/app/models/tag.rb b/app/models/tag.rb index a64042614..f078007f2 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -15,6 +15,7 @@ # last_status_at :datetime # max_score :float # max_score_at :datetime +# display_name :string # class Tag < ApplicationRecord @@ -24,11 +25,12 @@ class Tag < ApplicationRecord has_many :featured_tags, dependent: :destroy, inverse_of: :tag HASHTAG_SEPARATORS = "_\u00B7\u200c" - HASHTAG_NAME_RE = "([[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)" + HASHTAG_NAME_RE = "([[:alnum:]_][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alnum:]_])|([[:alnum:]_]*[[:alpha:]][[:alnum:]_]*)" HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } validate :validate_name_change, if: -> { !new_record? && name_changed? } + validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? } scope :reviewed, -> { where.not(reviewed_at: nil) } scope :unreviewed, -> { where(reviewed_at: nil) } @@ -46,6 +48,10 @@ class Tag < ApplicationRecord name end + def display_name + attributes['display_name'] || name + end + def usable boolean_with_default('usable', true) end @@ -90,8 +96,10 @@ class Tag < ApplicationRecord class << self def find_or_create_by_names(name_or_names) - Array(name_or_names).map(&method(:normalize)).uniq { |str| str.mb_chars.downcase.to_s }.map do |normalized_name| - tag = matching_name(normalized_name).first || create(name: normalized_name) + names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first) + + names.map do |(normalized_name, display_name)| + tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name) yield tag if block_given? @@ -129,7 +137,7 @@ class Tag < ApplicationRecord end def normalize(str) - str.gsub(/\A#/, '') + HashtagNormalizer.new.normalize(str) end end @@ -138,4 +146,8 @@ class Tag < ApplicationRecord def validate_name_change errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero? end + + def validate_display_name_change + errors.add(:display_name, I18n.t('tags.does_not_match_previous_name')) unless HashtagNormalizer.new.normalize(display_name).casecmp(name.mb_chars).zero? + end end diff --git a/app/serializers/activitypub/hashtag_serializer.rb b/app/serializers/activitypub/hashtag_serializer.rb index 1a56e4dfe..90929c57f 100644 --- a/app/serializers/activitypub/hashtag_serializer.rb +++ b/app/serializers/activitypub/hashtag_serializer.rb @@ -10,11 +10,11 @@ class ActivityPub::HashtagSerializer < ActivityPub::Serializer end def name - "##{object.name}" + "##{object.display_name}" end def href - if object.class.name == 'FeaturedTag' + if object.instance_of?(FeaturedTag) short_account_tag_url(object.account, object.tag) else tag_url(object) diff --git a/app/serializers/rest/featured_tag_serializer.rb b/app/serializers/rest/featured_tag_serializer.rb index 96adcc7d0..8abcd9b90 100644 --- a/app/serializers/rest/featured_tag_serializer.rb +++ b/app/serializers/rest/featured_tag_serializer.rb @@ -12,4 +12,8 @@ class REST::FeaturedTagSerializer < ActiveModel::Serializer def url short_account_tag_url(object.account, object.tag) end + + def name + object.display_name + end end diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb index 74aa571a4..52bfaa4ce 100644 --- a/app/serializers/rest/tag_serializer.rb +++ b/app/serializers/rest/tag_serializer.rb @@ -8,4 +8,8 @@ class REST::TagSerializer < ActiveModel::Serializer def url tag_url(object) end + + def name + object.display_name + end end diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 72e9c6611..7fa688bd3 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -75,7 +75,7 @@ = link_to short_account_tag_path(@account, featured_tag.tag) do %h4 = fa_icon 'hashtag' - = featured_tag.name + = featured_tag.display_name %small - if featured_tag.last_status_at.nil? = t('accounts.nothing_here') diff --git a/app/views/accounts/show.rss.ruby b/app/views/accounts/show.rss.ruby index fd45a8b2b..34e29d483 100644 --- a/app/views/accounts/show.rss.ruby +++ b/app/views/accounts/show.rss.ruby @@ -28,7 +28,7 @@ RSS::Builder.build do |doc| end status.tags.each do |tag| - item.category(tag.name) + item.category(tag.display_name) end end end diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml index fd9acce4a..104190b58 100644 --- a/app/views/admin/tags/show.html.haml +++ b/app/views/admin/tags/show.html.haml @@ -2,7 +2,7 @@ = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' - content_for :page_title do - = "##{@tag.name}" + = "##{@tag.display_name}" - if current_user.can?(:view_dashboard) - content_for :heading_actions do @@ -53,7 +53,7 @@ = render 'shared/error_messages', object: @tag .fields-group - = f.input :name, wrapper: :with_block_label + = f.input :display_name, wrapper: :with_block_label .fields-group = f.input :usable, as: :boolean, wrapper: :with_label diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml index 7bb99b158..a30666a08 100644 --- a/app/views/admin/trends/tags/_tag.html.haml +++ b/app/views/admin/trends/tags/_tag.html.haml @@ -6,7 +6,7 @@ .pending-account__header = link_to admin_tag_path(tag.id) do = fa_icon 'hashtag' - = tag.name + = tag.display_name %br/ diff --git a/app/views/admin_mailer/_new_trending_tags.text.erb b/app/views/admin_mailer/_new_trending_tags.text.erb index cde5af4e4..363df369d 100644 --- a/app/views/admin_mailer/_new_trending_tags.text.erb +++ b/app/views/admin_mailer/_new_trending_tags.text.erb @@ -1,12 +1,12 @@ <%= raw t('admin_mailer.new_trends.new_trending_tags.title') %> <% @tags.each do |tag| %> -- #<%= tag.name %> +- #<%= tag.display_name %> <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %> <% end %> <% if @lowest_trending_tag %> -<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %> +<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.display_name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %> <% else %> <%= raw t('admin_mailer.new_trends.new_trending_tags.no_approved_tags') %> <% end %> diff --git a/app/views/settings/featured_tags/index.html.haml b/app/views/settings/featured_tags/index.html.haml index 65de7f8f3..5d87e2862 100644 --- a/app/views/settings/featured_tags/index.html.haml +++ b/app/views/settings/featured_tags/index.html.haml @@ -9,7 +9,7 @@ = render 'shared/error_messages', object: @featured_tag .fields-group - = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@recently_used_tags.map { |tag| link_to("##{tag.name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ') + = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@recently_used_tags.map { |tag| link_to("##{tag.display_name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ') .actions = f.button :button, t('featured_tags.add_new'), type: :submit diff --git a/app/views/tags/_og.html.haml b/app/views/tags/_og.html.haml index a7c289bcb..37f644cf2 100644 --- a/app/views/tags/_og.html.haml +++ b/app/views/tags/_og.html.haml @@ -1,6 +1,6 @@ = opengraph 'og:site_name', t('about.hosted_on', domain: site_hostname) = opengraph 'og:url', tag_url(@tag) = opengraph 'og:type', 'website' -= opengraph 'og:title', "##{@tag.name}" -= opengraph 'og:description', strip_tags(t('about.about_hashtag_html', hashtag: @tag.name)) += opengraph 'og:title', "##{@tag.display_name}" += opengraph 'og:description', strip_tags(t('about.about_hashtag_html', hashtag: @tag.display_name)) = opengraph 'twitter:card', 'summary' diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index 5cd513b32..6dfb4f9b3 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -1,5 +1,5 @@ - content_for :page_title do - = "##{@tag.name}" + = "##{@tag.display_name}" - content_for :header_tags do %meta{ name: 'robots', content: 'noindex' }/ @@ -9,8 +9,8 @@ = render 'og' .page-header - %h1= "##{@tag.name}" - %p= t('about.about_hashtag_html', hashtag: @tag.name) + %h1= "##{@tag.display_name}" + %p= t('about.about_hashtag_html', hashtag: @tag.display_name) #mastodon-timeline{ data: { props: Oj.dump(default_props.merge(hashtag: @tag.name, local: @local)) }} .notranslate#modal-container diff --git a/app/views/tags/show.rss.ruby b/app/views/tags/show.rss.ruby index 9ce71be74..8e0c2327b 100644 --- a/app/views/tags/show.rss.ruby +++ b/app/views/tags/show.rss.ruby @@ -1,6 +1,6 @@ RSS::Builder.build do |doc| - doc.title("##{@tag.name}") - doc.description(I18n.t('rss.descriptions.tag', hashtag: @tag.name)) + doc.title("##{@tag.display_name}") + doc.description(I18n.t('rss.descriptions.tag', hashtag: @tag.display_name)) doc.link(tag_url(@tag)) doc.last_build_date(@statuses.first.created_at) if @statuses.any? doc.generator("Mastodon v#{Mastodon::Version.to_s}") @@ -26,7 +26,7 @@ RSS::Builder.build do |doc| end status.tags.each do |tag| - item.category(tag.name) + item.category(tag.display_name) end end end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 9bc9a54b2..3e5a55617 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -24,6 +24,7 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'RSS' inflect.acronym 'REST' inflect.acronym 'URL' + inflect.acronym 'ASCII' inflect.singular 'data', 'data' end diff --git a/db/migrate/20220710102457_add_display_name_to_tags.rb b/db/migrate/20220710102457_add_display_name_to_tags.rb new file mode 100644 index 000000000..aa7867645 --- /dev/null +++ b/db/migrate/20220710102457_add_display_name_to_tags.rb @@ -0,0 +1,5 @@ +class AddDisplayNameToTags < ActiveRecord::Migration[6.1] + def change + add_column :tags, :display_name, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 54966ef64..9b465b674 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_04_024901) do +ActiveRecord::Schema.define(version: 2022_07_10_102457) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -940,6 +940,7 @@ ActiveRecord::Schema.define(version: 2022_07_04_024901) do t.datetime "last_status_at" t.float "max_score" t.datetime "max_score_at" + t.string "display_name" t.index "lower((name)::text) text_pattern_ops", name: "index_tags_on_name_lower_btree", unique: true end diff --git a/spec/lib/hashtag_normalizer_spec.rb b/spec/lib/hashtag_normalizer_spec.rb new file mode 100644 index 000000000..fbb9f37c0 --- /dev/null +++ b/spec/lib/hashtag_normalizer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe HashtagNormalizer do + subject { described_class.new } + + describe '#normalize' do + it 'converts full-width Latin characters into basic Latin characters' do + expect(subject.normalize('Synthwave')).to eq 'synthwave' + end + + it 'converts half-width Katakana into Kana characters' do + expect(subject.normalize('シーサイドライナー')).to eq 'シーサイドライナー' + end + + it 'converts modified Latin characters into basic Latin characters' do + expect(subject.normalize('BLÅHAJ')).to eq 'blahaj' + end + + it 'strips out invalid characters' do + expect(subject.normalize('#foo')).to eq 'foo' + end + + it 'keeps valid characters' do + expect(subject.normalize('a·b')).to eq 'a·b' + end + end +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 3949dbce5..b16f99a79 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -91,7 +91,7 @@ RSpec.describe Tag, type: :model do upcase_string = 'abcABCabcABCやゆよ' downcase_string = 'abcabcabcabcやゆよ'; - tag = Fabricate(:tag, name: downcase_string) + tag = Fabricate(:tag, name: HashtagNormalizer.new.normalize(downcase_string)) expect(Tag.find_normalized(upcase_string)).to eq tag end end @@ -101,12 +101,12 @@ RSpec.describe Tag, type: :model do upcase_string = 'abcABCabcABCやゆよ' downcase_string = 'abcabcabcabcやゆよ'; - tag = Fabricate(:tag, name: downcase_string) + tag = Fabricate(:tag, name: HashtagNormalizer.new.normalize(downcase_string)) expect(Tag.matches_name(upcase_string)).to eq [tag] end it 'uses the LIKE operator' do - expect(Tag.matches_name('100%abc').to_sql).to eq %q[SELECT "tags".* FROM "tags" WHERE LOWER("tags"."name") LIKE LOWER('100\\%abc%')] + expect(Tag.matches_name('100%abc').to_sql).to eq %q[SELECT "tags".* FROM "tags" WHERE LOWER("tags"."name") LIKE LOWER('100abc%')] end end @@ -115,7 +115,7 @@ RSpec.describe Tag, type: :model do upcase_string = 'abcABCabcABCやゆよ' downcase_string = 'abcabcabcabcやゆよ'; - tag = Fabricate(:tag, name: downcase_string) + tag = Fabricate(:tag, name: HashtagNormalizer.new.normalize(downcase_string)) expect(Tag.matching_name(upcase_string)).to eq [tag] end end diff --git a/streaming/index.js b/streaming/index.js index 792ec5a44..a55181bad 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -892,6 +892,34 @@ const startWorker = async (workerId) => { return arr; }; + /** + * See app/lib/ascii_folder.rb for the canon definitions + * of these constants + */ + const NON_ASCII_CHARS = 'ÀÁÂÃÄÅàáâãäåĀāĂ㥹ÇçĆćĈĉĊċČčÐðĎďĐđÈÉÊËèéêëĒēĔĕĖėĘęĚěĜĝĞğĠġĢģĤĥĦħÌÍÎÏìíîïĨĩĪīĬĭĮįİıĴĵĶķĸĹĺĻļĽľĿŀŁłÑñŃńŅņŇňʼnŊŋÒÓÔÕÖØòóôõöøŌōŎŏŐőŔŕŖŗŘřŚśŜŝŞşŠšſŢţŤťŦŧÙÚÛÜùúûüŨũŪūŬŭŮůŰűŲųŴŵÝýÿŶŷŸŹźŻżŽž'; + const EQUIVALENT_ASCII_CHARS = 'AAAAAAaaaaaaAaAaAaCcCcCcCcCcDdDdDdEEEEeeeeEeEeEeEeEeGgGgGgGgHhHhIIIIiiiiIiIiIiIiIiJjKkkLlLlLlLlLlNnNnNnNnnNnOOOOOOooooooOoOoOoRrRrRrSsSsSsSssTtTtTtUUUUuuuuUuUuUuUuUuUuWwYyyYyYZzZzZz'; + + /** + * @param {string} str + * @return {string} + */ + const foldToASCII = str => { + const regex = new RegExp(NON_ASCII_CHARS.split('').join('|'), 'g'); + + return str.replace(regex, match => { + const index = NON_ASCII_CHARS.indexOf(match); + return EQUIVALENT_ASCII_CHARS[index]; + }); + }; + + /** + * @param {string} str + * @return {string} + */ + const normalizeHashtag = str => { + return foldToASCII(str.normalize('NFKC').toLowerCase()).replace(/[^\p{L}\p{N}_\u00b7\u200c]/gu, ''); + }; + /** * @param {any} req * @param {string} name @@ -968,7 +996,7 @@ const startWorker = async (workerId) => { reject('No tag for stream provided'); } else { resolve({ - channelIds: [`timeline:hashtag:${params.tag.toLowerCase()}`], + channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`], options: { needsFiltering: true }, }); } @@ -979,7 +1007,7 @@ const startWorker = async (workerId) => { reject('No tag for stream provided'); } else { resolve({ - channelIds: [`timeline:hashtag:${params.tag.toLowerCase()}:local`], + channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`], options: { needsFiltering: true }, }); } -- cgit From c3f0621a59a74d0e20e6db6170894871c48e8f0f Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 17 Jul 2022 13:49:29 +0200 Subject: Add ability to follow hashtags (#18809) --- .../api/v1/featured_tags/suggestions_controller.rb | 2 +- app/controllers/api/v1/followed_tags_controller.rb | 52 ++++++++++++++ app/controllers/api/v1/tags_controller.rb | 29 ++++++++ app/controllers/api/v1/trends/tags_controller.rb | 2 +- app/lib/feed_manager.rb | 36 ++++++---- app/models/tag.rb | 5 +- app/models/tag_follow.rb | 24 +++++++ app/presenters/tag_relationships_presenter.rb | 15 ++++ app/serializers/rest/tag_serializer.rb | 14 ++++ app/services/fan_out_on_write_service.rb | 15 +++- app/workers/feed_insert_worker.rb | 8 ++- config/routes.rb | 9 +++ db/migrate/20220714171049_create_tag_follows.rb | 12 ++++ db/schema.rb | 13 +++- .../api/v1/followed_tags_controller_spec.rb | 23 ++++++ spec/controllers/api/v1/tags_controller_spec.rb | 82 ++++++++++++++++++++++ spec/fabricators/tag_follow_fabricator.rb | 4 ++ spec/models/tag_follow_spec.rb | 4 ++ 18 files changed, 329 insertions(+), 20 deletions(-) create mode 100644 app/controllers/api/v1/followed_tags_controller.rb create mode 100644 app/controllers/api/v1/tags_controller.rb create mode 100644 app/models/tag_follow.rb create mode 100644 app/presenters/tag_relationships_presenter.rb create mode 100644 db/migrate/20220714171049_create_tag_follows.rb create mode 100644 spec/controllers/api/v1/followed_tags_controller_spec.rb create mode 100644 spec/controllers/api/v1/tags_controller_spec.rb create mode 100644 spec/fabricators/tag_follow_fabricator.rb create mode 100644 spec/models/tag_follow_spec.rb (limited to 'db/migrate') diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 75545d3c7..76633210a 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -6,7 +6,7 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action :set_recently_used_tags, only: :index def index - render json: @recently_used_tags, each_serializer: REST::TagSerializer + render json: @recently_used_tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@recently_used_tags, current_user&.account_id) end private diff --git a/app/controllers/api/v1/followed_tags_controller.rb b/app/controllers/api/v1/followed_tags_controller.rb new file mode 100644 index 000000000..f0dfd044c --- /dev/null +++ b/app/controllers/api/v1/followed_tags_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class Api::V1::FollowedTagsController < Api::BaseController + TAGS_LIMIT = 100 + + before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }, except: :show + before_action :require_user! + before_action :set_results + + after_action :insert_pagination_headers, only: :show + + def index + render json: @results.map(&:tag), each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@results.map(&:tag), current_user&.account_id) + end + + private + + def set_results + @results = TagFollow.where(account: current_account).joins(:tag).eager_load(:tag).to_a_paginated_by_id( + limit_param(TAGS_LIMIT), + params_slice(:max_id, :since_id, :min_id) + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_followed_tags_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_followed_tags_url pagination_params(since_id: pagination_since_id) unless @results.empty? + end + + def pagination_max_id + @results.last.id + end + + def pagination_since_id + @results.first.id + end + + def records_continue? + @results.size == limit_param(TAG_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/tags_controller.rb b/app/controllers/api/v1/tags_controller.rb new file mode 100644 index 000000000..d45015ff5 --- /dev/null +++ b/app/controllers/api/v1/tags_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Api::V1::TagsController < Api::BaseController + before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, except: :show + before_action :require_user!, except: :show + before_action :set_or_create_tag + + override_rate_limit_headers :follow, family: :follows + + def show + render json: @tag, serializer: REST::TagSerializer + end + + def follow + TagFollow.create!(tag: @tag, account: current_account, rate_limit: true) + render json: @tag, serializer: REST::TagSerializer + end + + def unfollow + TagFollow.find_by(account: current_account, tag: @tag)&.destroy! + render json: @tag, serializer: REST::TagSerializer + end + + private + + def set_or_create_tag + @tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id]) + end +end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 41f9ffac1..21adfa2a1 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -8,7 +8,7 @@ class Api::V1::Trends::TagsController < Api::BaseController DEFAULT_TAGS_LIMIT = 10 def index - render json: @tags, each_serializer: REST::TagSerializer + render json: @tags, each_serializer: REST::TagSerializer, relationships: TagRelationshipsPresenter.new(@tags, current_user&.account_id) end private diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 2eb4ba2f4..145352fe8 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -45,6 +45,8 @@ class FeedManager filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status])) when :mentions filter_from_mentions?(status, receiver.id) + when :tags + filter_from_tags?(status, receiver.id, build_crutches(receiver.id, [status])) else false end @@ -56,7 +58,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_home(account, status, update: false) - return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + return false unless add_to_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) trim(:home, account.id) PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}", { 'update' => update }) if push_update_required?("timeline:#{account.id}") @@ -69,7 +71,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def unpush_from_home(account, status, update: false) - return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) + return false unless remove_from_feed(:home, account.id, status, aggregate_reblogs: account.user&.aggregates_reblogs?) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true @@ -81,7 +83,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def push_to_list(list, status, update: false) - return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) trim(:list, list.id) PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}", { 'update' => update }) if push_update_required?("timeline:list:#{list.id}") @@ -94,7 +96,7 @@ class FeedManager # @param [Boolean] update # @return [Boolean] def unpush_from_list(list, status, update: false) - return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false unless remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) unless update true @@ -120,7 +122,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, into_account.id, crutches) - add_to_feed(:home, into_account.id, status, aggregate) + add_to_feed(:home, into_account.id, status, aggregate_reblogs: aggregate) end trim(:home, into_account.id) @@ -146,7 +148,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list) - add_to_feed(:list, list.id, status, aggregate) + add_to_feed(:list, list.id, status, aggregate_reblogs: aggregate) end trim(:list, list.id) @@ -161,7 +163,7 @@ class FeedManager timeline_status_ids = redis.zrange(timeline_key, 0, -1) from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| - remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?) + remove_from_feed(:home, into_account.id, status, aggregate_reblogs: into_account.user&.aggregates_reblogs?) end end @@ -174,7 +176,7 @@ class FeedManager timeline_status_ids = redis.zrange(timeline_key, 0, -1) from_account.statuses.select('id, reblog_of_id').where(id: timeline_status_ids).reorder(nil).find_each do |status| - remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + remove_from_feed(:list, list.id, status, aggregate_reblogs: list.account.user&.aggregates_reblogs?) end end @@ -237,7 +239,7 @@ class FeedManager timeline_key = key(:home, account.id) account.statuses.limit(limit).each do |status| - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end account.following.includes(:account_stat).find_each do |target_account| @@ -257,7 +259,7 @@ class FeedManager statuses.each do |status| next if filter_from_home?(status, account.id, crutches) - add_to_feed(:home, account.id, status, aggregate) + add_to_feed(:home, account.id, status, aggregate_reblogs: aggregate) end trim(:home, account.id) @@ -416,6 +418,16 @@ class FeedManager false end + # Check if a status should not be added to the home feed when it comes + # from a followed hashtag + # @param [Status] status + # @param [Integer] receiver_id + # @param [Hash] crutches + # @return [Boolean] + def filter_from_tags?(status, receiver_id, crutches) + receiver_id != status.account_id && (((crutches[:active_mentions][status.id] || []) + [status.account_id]).any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } || crutches[:blocked_by][status.account_id] || crutches[:domain_blocking][status.account.domain]) + end + # Adds a status to an account's feed, returning true if a status was # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if @@ -425,7 +437,7 @@ class FeedManager # @param [Status] status # @param [Boolean] aggregate_reblogs # @return [Boolean] - def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def add_to_feed(timeline_type, account_id, status, aggregate_reblogs: true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') @@ -473,7 +485,7 @@ class FeedManager # @param [Status] status # @param [Boolean] aggregate_reblogs # @return [Boolean] - def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true) + def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs: true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') diff --git a/app/models/tag.rb b/app/models/tag.rb index f078007f2..eebf3b47d 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -22,13 +22,16 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses has_and_belongs_to_many :accounts + has_many :passive_relationships, class_name: 'TagFollow', inverse_of: :tag, dependent: :destroy has_many :featured_tags, dependent: :destroy, inverse_of: :tag + has_many :followers, through: :passive_relationships, source: :account HASHTAG_SEPARATORS = "_\u00B7\u200c" HASHTAG_NAME_RE = "([[:alnum:]_][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:alnum:]#{HASHTAG_SEPARATORS}]*[[:alnum:]_])|([[:alnum:]_]*[[:alpha:]][[:alnum:]_]*)" HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } + validates :display_name, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } validate :validate_name_change, if: -> { !new_record? && name_changed? } validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? } @@ -99,7 +102,7 @@ class Tag < ApplicationRecord names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first) names.map do |(normalized_name, display_name)| - tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name) + tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name.gsub(/[^[:alnum:]#{HASHTAG_SEPARATORS}]/, '')) yield tag if block_given? diff --git a/app/models/tag_follow.rb b/app/models/tag_follow.rb new file mode 100644 index 000000000..abe36cd17 --- /dev/null +++ b/app/models/tag_follow.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tag_follows +# +# id :bigint(8) not null, primary key +# tag_id :bigint(8) not null +# account_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class TagFollow < ApplicationRecord + include RateLimitable + include Paginable + + belongs_to :tag + belongs_to :account + + accepts_nested_attributes_for :tag + + rate_limit by: :account, family: :follows +end diff --git a/app/presenters/tag_relationships_presenter.rb b/app/presenters/tag_relationships_presenter.rb new file mode 100644 index 000000000..c3bdbaf07 --- /dev/null +++ b/app/presenters/tag_relationships_presenter.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class TagRelationshipsPresenter + attr_reader :following_map + + def initialize(tags, current_account_id = nil, **options) + @following_map = begin + if current_account_id.nil? + {} + else + TagFollow.select(:tag_id).where(tag_id: tags.map(&:id), account_id: current_account_id).each_with_object({}) { |f, h| h[f.tag_id] = true }.merge(options[:following_map] || {}) + end + end + end +end diff --git a/app/serializers/rest/tag_serializer.rb b/app/serializers/rest/tag_serializer.rb index 52bfaa4ce..7801e77d1 100644 --- a/app/serializers/rest/tag_serializer.rb +++ b/app/serializers/rest/tag_serializer.rb @@ -5,6 +5,8 @@ class REST::TagSerializer < ActiveModel::Serializer attributes :name, :url, :history + attribute :following, if: :current_user? + def url tag_url(object) end @@ -12,4 +14,16 @@ class REST::TagSerializer < ActiveModel::Serializer def name object.display_name end + + def following + if instance_options && instance_options[:relationships] + instance_options[:relationships].following_map[object.id] || false + else + TagFollow.where(tag_id: object.id, account_id: current_user.account_id).exists? + end + end + + def current_user? + !current_user.nil? + end end diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index de5c5ebe4..ce20a146e 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -16,6 +16,7 @@ class FanOutOnWriteService < BaseService check_race_condition! fan_out_to_local_recipients! + fan_out_to_public_recipients! if broadcastable? fan_out_to_public_streams! if broadcastable? end @@ -50,6 +51,10 @@ class FanOutOnWriteService < BaseService end end + def fan_out_to_public_recipients! + deliver_to_hashtag_followers! + end + def fan_out_to_public_streams! broadcast_to_hashtag_streams! broadcast_to_public_streams! @@ -83,6 +88,14 @@ class FanOutOnWriteService < BaseService end end + def deliver_to_hashtag_followers! + TagFollow.where(tag_id: @status.tags.map(&:id)).select(:id, :account_id).reorder(nil).find_in_batches do |follows| + FeedInsertWorker.push_bulk(follows) do |follow| + [@status.id, follow.account_id, 'tags', { 'update' => update? }] + end + end + end + def deliver_to_lists! @account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists| FeedInsertWorker.push_bulk(lists) do |list| @@ -100,7 +113,7 @@ class FanOutOnWriteService < BaseService end def broadcast_to_hashtag_streams! - @status.tags.pluck(:name).each do |hashtag| + @status.tags.map(&:name).each do |hashtag| redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", anonymous_payload) redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", anonymous_payload) if @status.local? end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 40bc9cb6e..758cebd4b 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -9,7 +9,7 @@ class FeedInsertWorker @options = options.symbolize_keys case @type - when :home + when :home, :tags @follower = Account.find(id) when :list @list = List.find(id) @@ -36,6 +36,8 @@ class FeedInsertWorker case @type when :home FeedManager.instance.filter?(:home, @status, @follower) + when :tags + FeedManager.instance.filter?(:tags, @status, @follower) when :list FeedManager.instance.filter?(:list, @status, @list) end @@ -49,7 +51,7 @@ class FeedInsertWorker def perform_push case @type - when :home + when :home, :tags FeedManager.instance.push_to_home(@follower, @status, update: update?) when :list FeedManager.instance.push_to_list(@list, @status, update: update?) @@ -58,7 +60,7 @@ class FeedInsertWorker def perform_unpush case @type - when :home + when :home, :tags FeedManager.instance.unpush_from_home(@follower, @status, update: true) when :list FeedManager.instance.unpush_from_list(@list, @status, update: true) diff --git a/config/routes.rb b/config/routes.rb index 177c1cff4..7a902b1f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -530,6 +530,15 @@ Rails.application.routes.draw do resource :note, only: :create, controller: 'accounts/notes' end + resources :tags, only: [:show], constraints: { id: /#{Tag::HASHTAG_NAME_RE}/ } do + member do + post :follow + post :unfollow + end + end + + resources :followed_tags, only: [:index] + resources :lists, only: [:index, :create, :show, :update, :destroy] do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end diff --git a/db/migrate/20220714171049_create_tag_follows.rb b/db/migrate/20220714171049_create_tag_follows.rb new file mode 100644 index 000000000..a393e90f5 --- /dev/null +++ b/db/migrate/20220714171049_create_tag_follows.rb @@ -0,0 +1,12 @@ +class CreateTagFollows < ActiveRecord::Migration[6.1] + def change + create_table :tag_follows do |t| + t.belongs_to :tag, null: false, foreign_key: { on_delete: :cascade } + t.belongs_to :account, null: false, foreign_key: { on_delete: :cascade }, index: false + + t.timestamps + end + + add_index :tag_follows, [:account_id, :tag_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9b465b674..2263dc7d7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2022_07_10_102457) do +ActiveRecord::Schema.define(version: 2022_07_14_171049) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -928,6 +928,15 @@ ActiveRecord::Schema.define(version: 2022_07_10_102457) do t.datetime "updated_at", null: false end + create_table "tag_follows", force: :cascade do |t| + t.bigint "tag_id", null: false + t.bigint "account_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["account_id", "tag_id"], name: "index_tag_follows_on_account_id_and_tag_id", unique: true + t.index ["tag_id"], name: "index_tag_follows_on_tag_id" + end + create_table "tags", force: :cascade do |t| t.string "name", default: "", null: false t.datetime "created_at", null: false @@ -1167,6 +1176,8 @@ ActiveRecord::Schema.define(version: 2022_07_10_102457) do add_foreign_key "statuses", "statuses", column: "reblog_of_id", on_delete: :cascade add_foreign_key "statuses_tags", "statuses", on_delete: :cascade add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade + add_foreign_key "tag_follows", "accounts", on_delete: :cascade + add_foreign_key "tag_follows", "tags", on_delete: :cascade add_foreign_key "tombstones", "accounts", on_delete: :cascade add_foreign_key "user_invite_requests", "users", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade diff --git a/spec/controllers/api/v1/followed_tags_controller_spec.rb b/spec/controllers/api/v1/followed_tags_controller_spec.rb new file mode 100644 index 000000000..2191350ef --- /dev/null +++ b/spec/controllers/api/v1/followed_tags_controller_spec.rb @@ -0,0 +1,23 @@ +require 'rails_helper' + +RSpec.describe Api::V1::FollowedTagsController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:scopes) { 'read:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'GET #index' do + let!(:tag_follows) { Fabricate.times(5, :tag_follow, account: user.account) } + + before do + get :index, params: { limit: 1 } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/controllers/api/v1/tags_controller_spec.rb b/spec/controllers/api/v1/tags_controller_spec.rb new file mode 100644 index 000000000..ac42660df --- /dev/null +++ b/spec/controllers/api/v1/tags_controller_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe Api::V1::TagsController, type: :controller do + render_views + + let(:user) { Fabricate(:user) } + let(:scopes) { 'write:follows' } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) } + + before { allow(controller).to receive(:doorkeeper_token) { token } } + + describe 'GET #show' do + before do + get :show, params: { id: name } + end + + context 'with existing tag' do + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + + context 'with non-existing tag' do + let(:name) { 'hoge' } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + end + end + + describe 'POST #follow' do + before do + post :follow, params: { id: name } + end + + context 'with existing tag' do + let!(:tag) { Fabricate(:tag) } + let(:name) { tag.name } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'creates follow' do + expect(TagFollow.where(tag: tag, account: user.account).exists?).to be true + end + end + + context 'with non-existing tag' do + let(:name) { 'hoge' } + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'creates follow' do + expect(TagFollow.where(tag: Tag.find_by!(name: name), account: user.account).exists?).to be true + end + end + end + + describe 'POST #unfollow' do + let!(:tag) { Fabricate(:tag, name: 'foo') } + let!(:tag_follow) { Fabricate(:tag_follow, account: user.account, tag: tag) } + + before do + post :unfollow, params: { id: tag.name } + end + + it 'returns http success' do + expect(response).to have_http_status(:success) + end + + it 'removes the follow' do + expect(TagFollow.where(tag: tag, account: user.account).exists?).to be false + end + end +end diff --git a/spec/fabricators/tag_follow_fabricator.rb b/spec/fabricators/tag_follow_fabricator.rb new file mode 100644 index 000000000..a2cccb07a --- /dev/null +++ b/spec/fabricators/tag_follow_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:tag_follow) do + tag + account +end diff --git a/spec/models/tag_follow_spec.rb b/spec/models/tag_follow_spec.rb new file mode 100644 index 000000000..50c04d2e4 --- /dev/null +++ b/spec/models/tag_follow_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe TagFollow, type: :model do +end -- cgit