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 --- .../api/v1/admin/account_actions_controller.rb | 7 ++++++- app/controllers/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 +- app/controllers/api/v1/admin/measures_controller.rb | 6 +++++- app/controllers/api/v1/admin/reports_controller.rb | 2 +- app/controllers/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 +++++++--- app/controllers/api/v1/trends/statuses_controller.rb | 10 +++++++--- app/controllers/api/v1/trends/tags_controller.rb | 12 ++++++++++-- 14 files changed, 85 insertions(+), 44 deletions(-) (limited to 'app/controllers/api/v1') 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 -- 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 'app/controllers/api/v1') 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 'app/controllers/api/v1') 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