From 27965ce5edff20db2de1dd233c88f8393bb0da0b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 25 Feb 2022 00:34:14 +0100 Subject: Add trending statuses (#17431) * Add trending statuses * Fix dangling items with stale scores in localized sets * Various fixes and improvements - Change approve_all/reject_all to approve_accounts/reject_accounts - Change Trends::Query methods to not mutate the original query - Change Trends::Query#skip to offset - Change follow recommendations to be refreshed in a transaction * Add tests for trending statuses filtering behaviour * Fix not applying filtering scope in controller --- .../api/v1/admin/trends/links_controller.rb | 19 +++++++++++++++ .../api/v1/admin/trends/statuses_controller.rb | 19 +++++++++++++++ .../api/v1/admin/trends/tags_controller.rb | 2 +- app/controllers/api/v1/trends/links_controller.rb | 6 ++++- .../api/v1/trends/statuses_controller.rb | 27 ++++++++++++++++++++++ app/controllers/api/v1/trends/tags_controller.rb | 2 +- 6 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 app/controllers/api/v1/admin/trends/links_controller.rb create mode 100644 app/controllers/api/v1/admin/trends/statuses_controller.rb create mode 100644 app/controllers/api/v1/trends/statuses_controller.rb (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb new file mode 100644 index 000000000..63b3d9358 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/links_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V1::Admin::Trends::LinksController < Api::BaseController + protect_from_forgery with: :exception + + 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)) + end +end diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb new file mode 100644 index 000000000..86633cc74 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Api::V1::Admin::Trends::StatusesController < Api::BaseController + protect_from_forgery with: :exception + + 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) + 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 4815af31e..5cc4c269d 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController private def set_tags - @tags = Trends.tags.get(false, limit_param(10)) + @tags = Trends.tags.query.limit(limit_param(10)) end end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 1c3ab1e1c..ad20e7f8b 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController def set_links @links = begin if Setting.trends - Trends.links.get(true, limit_param(10)) + links_from_trends else [] end end end + + def links_from_trends + Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10)) + end end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb new file mode 100644 index 000000000..d4ec97ae5 --- /dev/null +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Api::V1::Trends::StatusesController < Api::BaseController + before_action :set_statuses + + def index + render json: @statuses, each_serializer: REST::StatusSerializer + end + + private + + def set_statuses + @statuses = begin + if Setting.trends + cache_collection(statuses_from_trends, Status) + else + [] + end + end + end + + def statuses_from_trends + scope = Trends.statuses.query.allowed.in_locale(content_locale) + scope = scope.filtered_for(current_account) if user_signed_in? + scope.limit(limit_param(DEFAULT_STATUSES_LIMIT)) + end +end diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 947b53de2..1334b72d2 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController def set_tags @tags = begin if Setting.trends - Trends.tags.get(true, limit_param(10)) + Trends.tags.query.allowed.limit(limit_param(10)) else [] end -- cgit From 50ea54b3ed125477656893a67d9f552bb53e8ba5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 1 Mar 2022 16:48:58 +0100 Subject: Change authorized applications page (#17656) * Change authorized applications page * Hide revoke button for superapps and suspended accounts * Clean up db/schema.rb --- app/controllers/api/base_controller.rb | 1 + .../concerns/access_token_tracking_concern.rb | 21 +++++ .../concerns/session_tracking_concern.rb | 4 +- app/controllers/concerns/user_tracking_concern.rb | 4 +- app/helpers/application_helper.rb | 15 ++++ app/javascript/styles/mastodon/admin.scss | 10 +++ app/javascript/styles/mastodon/containers.scss | 13 ++-- app/javascript/styles/mastodon/forms.scss | 71 ++++++++++++++++- app/lib/access_token_extension.rb | 4 + app/lib/application_extension.rb | 4 + app/lib/scope_parser.rb | 10 +++ app/lib/scope_transformer.rb | 40 ++++++++++ app/views/layouts/modal.html.haml | 3 +- app/views/oauth/authorizations/new.html.haml | 50 +++++++----- .../oauth/authorized_applications/index.html.haml | 62 ++++++++++----- app/workers/scheduler/ip_cleanup_scheduler.rb | 1 + config/locales/doorkeeper.en.yml | 43 +++++++++-- ...1951_add_last_used_at_to_oauth_access_tokens.rb | 6 ++ db/schema.rb | 4 +- spec/lib/scope_transformer_spec.rb | 89 ++++++++++++++++++++++ 20 files changed, 393 insertions(+), 62 deletions(-) create mode 100644 app/controllers/concerns/access_token_tracking_concern.rb create mode 100644 app/lib/scope_parser.rb create mode 100644 app/lib/scope_transformer.rb create mode 100644 db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb create mode 100644 spec/lib/scope_transformer_spec.rb (limited to 'app/controllers/api') diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index b863d8643..72c30dec7 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -5,6 +5,7 @@ class Api::BaseController < ApplicationController DEFAULT_ACCOUNTS_LIMIT = 40 include RateLimitHeaders + include AccessTokenTrackingConcern skip_before_action :store_current_location skip_before_action :require_functional!, unless: :whitelist_mode? diff --git a/app/controllers/concerns/access_token_tracking_concern.rb b/app/controllers/concerns/access_token_tracking_concern.rb new file mode 100644 index 000000000..cf60cfb99 --- /dev/null +++ b/app/controllers/concerns/access_token_tracking_concern.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AccessTokenTrackingConcern + extend ActiveSupport::Concern + + ACCESS_TOKEN_UPDATE_FREQUENCY = 24.hours.freeze + + included do + before_action :update_access_token_last_used + end + + private + + def update_access_token_last_used + doorkeeper_token.update_last_used(request) if access_token_needs_update? + end + + def access_token_needs_update? + doorkeeper_token.present? && (doorkeeper_token.last_used_at.nil? || doorkeeper_token.last_used_at < ACCESS_TOKEN_UPDATE_FREQUENCY.ago) + end +end diff --git a/app/controllers/concerns/session_tracking_concern.rb b/app/controllers/concerns/session_tracking_concern.rb index 45361b019..eaaa4ac59 100644 --- a/app/controllers/concerns/session_tracking_concern.rb +++ b/app/controllers/concerns/session_tracking_concern.rb @@ -3,7 +3,7 @@ module SessionTrackingConcern extend ActiveSupport::Concern - UPDATE_SIGN_IN_HOURS = 24 + SESSION_UPDATE_FREQUENCY = 24.hours.freeze included do before_action :set_session_activity @@ -17,6 +17,6 @@ module SessionTrackingConcern end def session_needs_update? - !current_session.nil? && current_session.updated_at < UPDATE_SIGN_IN_HOURS.hours.ago + !current_session.nil? && current_session.updated_at < SESSION_UPDATE_FREQUENCY.ago end end diff --git a/app/controllers/concerns/user_tracking_concern.rb b/app/controllers/concerns/user_tracking_concern.rb index 45f3aab0d..e960cce53 100644 --- a/app/controllers/concerns/user_tracking_concern.rb +++ b/app/controllers/concerns/user_tracking_concern.rb @@ -3,7 +3,7 @@ module UserTrackingConcern extend ActiveSupport::Concern - UPDATE_SIGN_IN_FREQUENCY = 24.hours.freeze + SIGN_IN_UPDATE_FREQUENCY = 24.hours.freeze included do before_action :update_user_sign_in @@ -16,6 +16,6 @@ module UserTrackingConcern end def user_needs_sign_in_update? - user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < UPDATE_SIGN_IN_FREQUENCY.ago) + user_signed_in? && (current_user.current_sign_in_at.nil? || current_user.current_sign_in_at < SIGN_IN_UPDATE_FREQUENCY.ago) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 36c66b7d1..c5d9bbc19 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -224,4 +224,19 @@ module ApplicationHelper content_tag(:script, json_escape(json).html_safe, id: 'initial-state', type: 'application/json') # rubocop:enable Rails/OutputSafety end + + def grouped_scopes(scopes) + scope_parser = ScopeParser.new + scope_transformer = ScopeTransformer.new + + scopes.each_with_object({}) do |str, h| + scope = scope_transformer.apply(scope_parser.parse(str)) + + if h[scope.key] + h[scope.key].merge!(scope) + else + h[scope.key] = scope + end + end.values + end end diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 2e212eca5..f49a354dc 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -907,6 +907,12 @@ a.name-tag, text-decoration: none; margin-bottom: 10px; + .account-role { + vertical-align: middle; + } + } + + a.announcements-list__item__title { &:hover, &:focus, &:active { @@ -925,6 +931,10 @@ a.name-tag, align-items: center; } + &__permissions { + margin-top: 10px; + } + &:last-child { border-bottom: 0; } diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index e40ad18ff..a180df437 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -1,7 +1,6 @@ .container-alt { width: 700px; margin: 0 auto; - margin-top: 40px; @media screen and (max-width: 740px) { width: 100%; @@ -67,22 +66,20 @@ line-height: 18px; box-sizing: border-box; padding: 20px 0; - padding-bottom: 0; - margin-bottom: -30px; margin-top: 40px; + margin-bottom: 10px; + border-bottom: 1px solid $ui-base-color; @media screen and (max-width: 440px) { width: 100%; margin: 0; - margin-bottom: 10px; padding: 20px; - padding-bottom: 0; } .avatar { width: 40px; height: 40px; - margin-right: 8px; + margin-right: 10px; img { width: 100%; @@ -96,7 +93,7 @@ .name { flex: 1 1 auto; color: $secondary-text-color; - width: calc(100% - 88px); + width: calc(100% - 90px); .username { display: block; @@ -110,7 +107,7 @@ display: block; font-size: 32px; line-height: 40px; - margin-left: 8px; + margin-left: 10px; } } diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 65f53471d..6e02e2332 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -800,9 +800,41 @@ code { } } } +} - @media screen and (max-width: 740px) and (min-width: 441px) { - margin-top: 40px; +.oauth-prompt { + h3 { + color: $ui-secondary-color; + font-size: 17px; + line-height: 22px; + font-weight: 500; + margin-bottom: 30px; + } + + p { + font-size: 14px; + line-height: 18px; + margin-bottom: 30px; + } + + .permissions-list { + border: 1px solid $ui-base-color; + border-radius: 4px; + background: darken($ui-base-color, 4%); + margin-bottom: 30px; + } + + .actions { + margin: 0 -10px; + display: flex; + + form { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + width: 50%; + } } } @@ -1005,3 +1037,38 @@ code { display: none; } } + +.permissions-list { + &__item { + padding: 15px; + color: $ui-secondary-color; + border-bottom: 1px solid lighten($ui-base-color, 4%); + display: flex; + align-items: center; + + &__text { + flex: 1 1 auto; + + &__title { + font-weight: 500; + } + + &__type { + color: $darker-text-color; + } + } + + &__icon { + flex: 0 0 auto; + font-size: 18px; + width: 30px; + color: $valid-value-color; + display: flex; + align-items: center; + } + + &:last-child { + border-bottom: 0; + } + } +} diff --git a/app/lib/access_token_extension.rb b/app/lib/access_token_extension.rb index 3e184e775..2cafaaa20 100644 --- a/app/lib/access_token_extension.rb +++ b/app/lib/access_token_extension.rb @@ -11,6 +11,10 @@ module AccessTokenExtension update(revoked_at: clock.now.utc) end + def update_last_used(request, clock = Time) + update(last_used_at: clock.now.utc, last_used_ip: request.remote_ip) + end + def push_to_streaming_api Redis.current.publish("timeline:access_token:#{id}", Oj.dump(event: :kill)) if revoked? || destroyed? end diff --git a/app/lib/application_extension.rb b/app/lib/application_extension.rb index e61cd0721..a1fea6430 100644 --- a/app/lib/application_extension.rb +++ b/app/lib/application_extension.rb @@ -8,4 +8,8 @@ module ApplicationExtension validates :website, url: true, length: { maximum: 2_000 }, if: :website? validates :redirect_uri, length: { maximum: 2_000 } end + + def most_recently_used_access_token + @most_recently_used_access_token ||= access_tokens.where.not(last_used_at: nil).order(last_used_at: :desc).first + end end diff --git a/app/lib/scope_parser.rb b/app/lib/scope_parser.rb new file mode 100644 index 000000000..d268688c8 --- /dev/null +++ b/app/lib/scope_parser.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ScopeParser < Parslet::Parser + rule(:term) { match('[a-z]').repeat(1).as(:term) } + rule(:colon) { str(':') } + rule(:access) { (str('write') | str('read')).as(:access) } + rule(:namespace) { str('admin').as(:namespace) } + rule(:scope) { ((namespace >> colon).maybe >> ((access >> colon >> term) | access | term)).as(:scope) } + root(:scope) +end diff --git a/app/lib/scope_transformer.rb b/app/lib/scope_transformer.rb new file mode 100644 index 000000000..fdfc6cf13 --- /dev/null +++ b/app/lib/scope_transformer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class ScopeTransformer < Parslet::Transform + class Scope + DEFAULT_TERM = 'all' + DEFAULT_ACCESS = %w(read write).freeze + + attr_reader :namespace, :term + + def initialize(scope) + @namespace = scope[:namespace]&.to_s + @access = scope[:access] ? [scope[:access].to_s] : DEFAULT_ACCESS.dup + @term = scope[:term]&.to_s || DEFAULT_TERM + end + + def key + @key ||= [@namespace, @term].compact.join('/') + end + + def access + @access.join('/') + end + + def merge(other_scope) + clone.merge!(other_scope) + end + + def merge!(other_scope) + raise ArgumentError unless other_scope.namespace == namespace && other_scope.term == term + + @access.concat(other_scope.instance_variable_get('@access')) + @access.uniq! + @access.sort! + + self + end + end + + rule(scope: subtree(:scope)) { Scope.new(scope) } +end diff --git a/app/views/layouts/modal.html.haml b/app/views/layouts/modal.html.haml index a2cd1193f..c0ea211ff 100644 --- a/app/views/layouts/modal.html.haml +++ b/app/views/layouts/modal.html.haml @@ -12,8 +12,9 @@ = fa_icon 'sign-out' .container-alt= yield + .modal-layout__mastodon %div - %img{alt:'', draggable:'false', src:"#{mascot_url}"} + %img{alt: '', draggable: 'false', src: mascot_url } = render template: 'layouts/application' diff --git a/app/views/oauth/authorizations/new.html.haml b/app/views/oauth/authorizations/new.html.haml index 05ff9582e..50f671b26 100644 --- a/app/views/oauth/authorizations/new.html.haml +++ b/app/views/oauth/authorizations/new.html.haml @@ -1,26 +1,38 @@ - content_for :page_title do = t('doorkeeper.authorizations.new.title') -.form-container +.form-container.simple_form .oauth-prompt - %h2= t('doorkeeper.authorizations.new.prompt', client_name: @pre_auth.client.name) + %h3= t('doorkeeper.authorizations.new.title') - %p - = t('doorkeeper.authorizations.new.able_to') - != @pre_auth.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.map { |s| "#{s}" }.to_sentence + %p= t('doorkeeper.authorizations.new.prompt_html', client_name: content_tag(:strong, @pre_auth.client.name)) - = form_tag oauth_authorization_path, method: :post, class: 'simple_form' do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit + %h3= t('doorkeeper.authorizations.new.review_permissions') - = form_tag oauth_authorization_path, method: :delete, class: 'simple_form' do - = hidden_field_tag :client_id, @pre_auth.client.uid - = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri - = hidden_field_tag :state, @pre_auth.state - = hidden_field_tag :response_type, @pre_auth.response_type - = hidden_field_tag :scope, @pre_auth.scope - = button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative' + %ul.permissions-list + - grouped_scopes(@pre_auth.scopes).each do |scope| + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('check') + .permissions-list__item__text + .permissions-list__item__text__title + = t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title]) + .permissions-list__item__text__type + = t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access]) + + .actions + = form_tag oauth_authorization_path, method: :post do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = button_tag t('doorkeeper.authorizations.buttons.authorize'), type: :submit + + = form_tag oauth_authorization_path, method: :delete do + = hidden_field_tag :client_id, @pre_auth.client.uid + = hidden_field_tag :redirect_uri, @pre_auth.redirect_uri + = hidden_field_tag :state, @pre_auth.state + = hidden_field_tag :response_type, @pre_auth.response_type + = hidden_field_tag :scope, @pre_auth.scope + = button_tag t('doorkeeper.authorizations.buttons.deny'), type: :submit, class: 'negative' diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml index fbb733db4..fead56f4a 100644 --- a/app/views/oauth/authorized_applications/index.html.haml +++ b/app/views/oauth/authorized_applications/index.html.haml @@ -1,24 +1,44 @@ - content_for :page_title do = t('doorkeeper.authorized_applications.index.title') -.table-wrapper - %table.table - %thead - %tr - %th= t('doorkeeper.authorized_applications.index.application') - %th= t('doorkeeper.authorized_applications.index.scopes') - %th= t('doorkeeper.authorized_applications.index.created_at') - %th - %tbody - - @applications.each do |application| - %tr - %td - - if application.website.blank? - = application.name - - else - = link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer' - %th!= application.scopes.map { |scope| t(scope, scope: [:doorkeeper, :scopes]) }.join(', ') - %td= l application.created_at - %td - - unless application.superapp? || current_account.suspended? - = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } +%p= t('doorkeeper.authorized_applications.index.description_html') + +%hr.spacer/ + +.announcements-list + - @applications.each do |application| + .announcements-list__item + - if application.website.present? + = link_to application.name, application.website, target: '_blank', rel: 'noopener noreferrer', class: 'announcements-list__item__title' + - else + %strong.announcements-list__item__title + = application.name + - if application.superapp? + %span.account-role.moderator= t('doorkeeper.authorized_applications.index.superapp') + + .announcements-list__item__action-bar + .announcements-list__item__meta + - if application.most_recently_used_access_token + = t('doorkeeper.authorized_applications.index.last_used_at', date: l(application.most_recently_used_access_token.last_used_at.to_date)) + - else + = t('doorkeeper.authorized_applications.index.never_used') + + • + + = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date)) + + - unless application.superapp? || current_account.suspended? + %div + = table_link_to 'times', t('doorkeeper.authorized_applications.buttons.revoke'), oauth_authorized_application_path(application), method: :delete, data: { confirm: t('doorkeeper.authorized_applications.confirmations.revoke') } + + .announcements-list__item__permissions + %ul.permissions-list + - grouped_scopes(application.scopes).each do |scope| + %li.permissions-list__item + .permissions-list__item__icon + = fa_icon('check') + .permissions-list__item__text + .permissions-list__item__text__title + = t(scope.key, scope: [:doorkeeper, :grouped_scopes, :title]) + .permissions-list__item__text__type + = t(scope.access, scope: [:doorkeeper, :grouped_scopes, :access]) diff --git a/app/workers/scheduler/ip_cleanup_scheduler.rb b/app/workers/scheduler/ip_cleanup_scheduler.rb index adc99c605..7afad2f58 100644 --- a/app/workers/scheduler/ip_cleanup_scheduler.rb +++ b/app/workers/scheduler/ip_cleanup_scheduler.rb @@ -18,6 +18,7 @@ class Scheduler::IpCleanupScheduler SessionActivation.where('updated_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all User.where('current_sign_in_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(sign_up_ip: nil) LoginActivity.where('created_at < ?', IP_RETENTION_PERIOD.ago).in_batches.destroy_all + Doorkeeper::AccessToken.where('last_used_at < ?', IP_RETENTION_PERIOD.ago).in_batches.update_all(last_used_ip: nil) end def clean_expired_ip_blocks! diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index 8aa099284..5567724ae 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -60,8 +60,8 @@ en: error: title: An error has occurred new: - able_to: It will be able to - prompt: Application %{client_name} requests access to your account + prompt_html: "%{client_name} would like permission to access your account. It is a third-party application. If you do not trust it, then you should not authorize it." + review_permissions: Review permissions title: Authorization required show: title: Copy this authorization code and paste it to the application. @@ -71,10 +71,12 @@ en: confirmations: revoke: Are you sure? index: - application: Application - created_at: Authorized - date_format: "%Y-%m-%d %H:%M:%S" - scopes: Scopes + authorized_at: Authorized on %{date} + description_html: These are applications that can access your account using the API. If there are applications you do not recognize here, or an application is misbehaving, you can revoke its access. + last_used_at: Last used on %{date} + never_used: Never used + scopes: Permissions + superapp: Internal title: Your authorized applications errors: messages: @@ -110,6 +112,33 @@ en: authorized_applications: destroy: notice: Application revoked. + grouped_scopes: + access: + read: Read-only access + read/write: Read and write access + write: Write-only access + title: + accounts: Accounts + admin/accounts: Administration of accounts + admin/all: All administrative functions + admin/reports: Administration of reports + all: Everything + blocks: Blocks + bookmarks: Bookmarks + conversations: Conversations + crypto: End-to-end encryption + favourites: Favourites + filters: Filters + follow: Relationships + follows: Follows + lists: Lists + media: Media attachments + mutes: Mutes + notifications: Notifications + push: Push notifications + reports: Reports + search: Search + statuses: Posts layouts: admin: nav: @@ -124,6 +153,7 @@ en: admin:write: modify all data on the server admin:write:accounts: perform moderation actions on accounts admin:write:reports: perform moderation actions on reports + crypto: use end-to-end encryption follow: modify account relationships push: receive your push notifications read: read all your account's data @@ -143,6 +173,7 @@ en: write:accounts: modify your profile write:blocks: block accounts and domains write:bookmarks: bookmark posts + write:conversations: mute and delete conversations write:favourites: favourite posts write:filters: create filters write:follows: follow people diff --git a/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb b/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb new file mode 100644 index 000000000..6b46e60a8 --- /dev/null +++ b/db/migrate/20220227041951_add_last_used_at_to_oauth_access_tokens.rb @@ -0,0 +1,6 @@ +class AddLastUsedAtToOauthAccessTokens < ActiveRecord::Migration[6.1] + def change + add_column :oauth_access_tokens, :last_used_at, :datetime + add_column :oauth_access_tokens, :last_used_ip, :inet + end +end diff --git a/db/schema.rb b/db/schema.rb index e54de5b37..756e5e9ab 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_02_24_010024) do +ActiveRecord::Schema.define(version: 2022_02_27_041951) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -630,6 +630,8 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do t.string "scopes" t.bigint "application_id" t.bigint "resource_owner_id" + t.datetime "last_used_at" + t.inet "last_used_ip" t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id" t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true diff --git a/spec/lib/scope_transformer_spec.rb b/spec/lib/scope_transformer_spec.rb new file mode 100644 index 000000000..e5a992144 --- /dev/null +++ b/spec/lib/scope_transformer_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ScopeTransformer do + describe '#apply' do + subject { described_class.new.apply(ScopeParser.new.parse(input)) } + + shared_examples 'a scope' do |namespace, term, access| + it 'parses the term' do + expect(subject.term).to eq term + end + + it 'parses the namespace' do + expect(subject.namespace).to eq namespace + end + + it 'parses the access' do + expect(subject.access).to eq access + end + end + + context 'for scope "read"' do + let(:input) { 'read' } + + it_behaves_like 'a scope', nil, 'all', 'read' + end + + context 'for scope "write"' do + let(:input) { 'write' } + + it_behaves_like 'a scope', nil, 'all', 'write' + end + + context 'for scope "follow"' do + let(:input) { 'follow' } + + it_behaves_like 'a scope', nil, 'follow', 'read/write' + end + + context 'for scope "crypto"' do + let(:input) { 'crypto' } + + it_behaves_like 'a scope', nil, 'crypto', 'read/write' + end + + context 'for scope "push"' do + let(:input) { 'push' } + + it_behaves_like 'a scope', nil, 'push', 'read/write' + end + + context 'for scope "admin:read"' do + let(:input) { 'admin:read' } + + it_behaves_like 'a scope', 'admin', 'all', 'read' + end + + context 'for scope "admin:write"' do + let(:input) { 'admin:write' } + + it_behaves_like 'a scope', 'admin', 'all', 'write' + end + + context 'for scope "admin:read:accounts"' do + let(:input) { 'admin:read:accounts' } + + it_behaves_like 'a scope', 'admin', 'accounts', 'read' + end + + context 'for scope "admin:write:accounts"' do + let(:input) { 'admin:write:accounts' } + + it_behaves_like 'a scope', 'admin', 'accounts', 'write' + end + + context 'for scope "read:accounts"' do + let(:input) { 'read:accounts' } + + it_behaves_like 'a scope', nil, 'accounts', 'read' + end + + context 'for scope "write:accounts"' do + let(:input) { 'write:accounts' } + + it_behaves_like 'a scope', nil, 'accounts', 'write' + end + end +end -- cgit From 02b8d63fcef2d30e2514111ec89308a9435dd2ed Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 2 Mar 2022 18:57:08 +0100 Subject: Fix report category not being saved in REST API (#17682) --- app/controllers/api/v1/reports_controller.rb | 12 +---- app/services/report_service.rb | 14 ++++-- spec/controllers/api/v1/reports_controller_spec.rb | 54 +++++++++++++++++++--- spec/fabricators/rule_fabricator.rb | 8 ++-- 4 files changed, 62 insertions(+), 26 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 052d70cc8..8ff6c8fe5 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -10,9 +10,7 @@ class Api::V1::ReportsController < Api::BaseController @report = ReportService.new.call( current_account, reported_account, - status_ids: reported_status_ids, - comment: report_params[:comment], - forward: report_params[:forward] + report_params ) render json: @report, serializer: REST::ReportSerializer @@ -20,14 +18,6 @@ class Api::V1::ReportsController < Api::BaseController private - def reported_status_ids - reported_account.statuses.with_discarded.find(status_ids).pluck(:id) - end - - def status_ids - Array(report_params[:status_ids]) - end - def reported_account Account.find(report_params[:account_id]) end diff --git a/app/services/report_service.rb b/app/services/report_service.rb index caf99ab6e..9d784c341 100644 --- a/app/services/report_service.rb +++ b/app/services/report_service.rb @@ -6,10 +6,10 @@ class ReportService < BaseService def call(source_account, target_account, options = {}) @source_account = source_account @target_account = target_account - @status_ids = options.delete(:status_ids) || [] - @comment = options.delete(:comment) || '' - @category = options.delete(:category) || 'other' - @rule_ids = options.delete(:rule_ids) + @status_ids = options.delete(:status_ids).presence || [] + @comment = options.delete(:comment).presence || '' + @category = options.delete(:category).presence || 'other' + @rule_ids = options.delete(:rule_ids).presence @options = options raise ActiveRecord::RecordNotFound if @target_account.suspended? @@ -26,7 +26,7 @@ class ReportService < BaseService def create_report! @report = @source_account.reports.create!( target_account: @target_account, - status_ids: @status_ids, + status_ids: reported_status_ids, comment: @comment, uri: @options[:uri], forwarded: forward?, @@ -56,6 +56,10 @@ class ReportService < BaseService !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward]) end + def reported_status_ids + @target_account.statuses.with_discarded.find(Array(@status_ids)).pluck(:id) + end + def payload Oj.dump(serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account)) end diff --git a/spec/controllers/api/v1/reports_controller_spec.rb b/spec/controllers/api/v1/reports_controller_spec.rb index a13de1370..b5baf60e1 100644 --- a/spec/controllers/api/v1/reports_controller_spec.rb +++ b/spec/controllers/api/v1/reports_controller_spec.rb @@ -13,22 +13,64 @@ RSpec.describe Api::V1::ReportsController, type: :controller do end describe 'POST #create' do - let(:scopes) { 'write:reports' } - let!(:status) { Fabricate(:status) } - let!(:admin) { Fabricate(:user, admin: true) } + let!(:admin) { Fabricate(:user, admin: true) } + + let(:scopes) { 'write:reports' } + let(:status) { Fabricate(:status) } + let(:target_account) { status.account } + let(:category) { nil } + let(:forward) { nil } + let(:rule_ids){ nil } before do allow(AdminMailer).to receive(:new_report).and_return(double('email', deliver_later: nil)) - post :create, params: { status_ids: [status.id], account_id: status.account.id, comment: 'reasons' } + post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward } end - it 'creates a report' do - expect(status.reload.account.targeted_reports).not_to be_empty + it 'returns http success' do expect(response).to have_http_status(200) end + it 'creates a report' do + expect(target_account.targeted_reports).to_not be_empty + end + + it 'saves comment' do + expect(target_account.targeted_reports.first.comment).to eq 'reasons' + end + it 'sends e-mails to admins' do expect(AdminMailer).to have_received(:new_report).with(admin.account, Report) end + + context 'when a status does not belong to the reported account' do + let(:target_account) { Fabricate(:account) } + + it 'returns http not found' do + expect(response).to have_http_status(404) + end + end + + context 'when a category is chosen' do + let(:category) { 'spam' } + + it 'saves category' do + expect(target_account.targeted_reports.first.spam?).to be true + end + end + + context 'when violated rules are chosen' do + let(:rule) { Fabricate(:rule) } + let(:category) { 'violation' } + let(:rule_ids) { [rule.id] } + + it 'saves category' do + expect(target_account.targeted_reports.first.violation?).to be true + end + + it 'saves rule_ids' do + expect(target_account.targeted_reports.first.rule_ids).to match_array([rule.id]) + end + end end end diff --git a/spec/fabricators/rule_fabricator.rb b/spec/fabricators/rule_fabricator.rb index 4bdfd05e0..bc29bc48e 100644 --- a/spec/fabricators/rule_fabricator.rb +++ b/spec/fabricators/rule_fabricator.rb @@ -1,5 +1,5 @@ Fabricator(:rule) do - priority "" - deleted_at "2021-02-21 05:51:09" - text "MyText" -end \ No newline at end of file + priority 0 + deleted_at nil + text { Faker::Lorem.paragraph } +end -- cgit From e24b14cc74034585b29ca92bbb9623df32328bf3 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Wed, 2 Mar 2022 18:57:26 +0100 Subject: Fix leak of existence of otherwise inaccessible statuses in REST API (#17684) --- app/controllers/api/v1/statuses_controller.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 2d82a7a99..f48aeb945 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -92,8 +92,9 @@ class Api::V1::StatusesController < Api::BaseController end def set_thread - @thread = status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]) - rescue ActiveRecord::RecordNotFound + @thread = Status.find(status_params[:in_reply_to_id]) if status_params[:in_reply_to_id].present? + authorize(@thread, :show?) if @thread.present? + rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end -- cgit From 631e495a7900e9638b218474cbc753dd5d2a033e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 3 Mar 2022 16:13:40 +0100 Subject: Change `follow` scope to be covered by `read` and `write` scopes in REST API (#17678) Deprecate `follow` scope --- app/controllers/api/v1/accounts_controller.rb | 6 +++--- app/controllers/api/v1/blocks_controller.rb | 2 +- app/controllers/api/v1/domain_blocks_controller.rb | 4 ++-- app/controllers/api/v1/follow_requests_controller.rb | 4 ++-- app/controllers/api/v1/mutes_controller.rb | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5c47158e0..5134bfb94 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -2,9 +2,9 @@ class Api::V1::AccountsController < Api::BaseController before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute] - before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers] - before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute] - before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock] + before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers] + before_action -> { doorkeeper_authorize! :follow, :write, :'write:mutes' }, only: [:mute, :unmute] + before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }, only: [:block, :unblock] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create] before_action :require_user!, except: [:show, :create] diff --git a/app/controllers/api/v1/blocks_controller.rb b/app/controllers/api/v1/blocks_controller.rb index 586cdfca9..a65e762c9 100644 --- a/app/controllers/api/v1/blocks_controller.rb +++ b/app/controllers/api/v1/blocks_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::BlocksController < Api::BaseController - before_action -> { doorkeeper_authorize! :follow, :'read:blocks' } + before_action -> { doorkeeper_authorize! :follow, :read, :'read:blocks' } before_action :require_user! after_action :insert_pagination_headers diff --git a/app/controllers/api/v1/domain_blocks_controller.rb b/app/controllers/api/v1/domain_blocks_controller.rb index 5bb02d834..1891261b9 100644 --- a/app/controllers/api/v1/domain_blocks_controller.rb +++ b/app/controllers/api/v1/domain_blocks_controller.rb @@ -3,8 +3,8 @@ class Api::V1::DomainBlocksController < Api::BaseController BLOCK_LIMIT = 100 - before_action -> { doorkeeper_authorize! :follow, :'read:blocks' }, only: :show - before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, except: :show + before_action -> { doorkeeper_authorize! :follow, :read, :'read:blocks' }, only: :show + before_action -> { doorkeeper_authorize! :follow, :write, :'write:blocks' }, except: :show before_action :require_user! after_action :insert_pagination_headers, only: :show diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index f4b2a74d0..8276245a3 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Api::V1::FollowRequestsController < Api::BaseController - before_action -> { doorkeeper_authorize! :follow, :'read:follows' }, only: :index - before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, except: :index + before_action -> { doorkeeper_authorize! :follow, :read, :'read:follows' }, only: :index + before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, except: :index before_action :require_user! after_action :insert_pagination_headers, only: :index diff --git a/app/controllers/api/v1/mutes_controller.rb b/app/controllers/api/v1/mutes_controller.rb index fd52511d7..6cde53a2a 100644 --- a/app/controllers/api/v1/mutes_controller.rb +++ b/app/controllers/api/v1/mutes_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Api::V1::MutesController < Api::BaseController - before_action -> { doorkeeper_authorize! :follow, :'read:mutes' } + before_action -> { doorkeeper_authorize! :follow, :read, :'read:mutes' } before_action :require_user! after_action :insert_pagination_headers -- cgit From ff43e54a495461eb1a539a1887125190b74754f5 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 3 Mar 2022 16:13:58 +0100 Subject: Allow editing media attachments for scheduled toots (#17690) Fixes #17676 --- app/controllers/api/v1/media_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/media_controller.rb b/app/controllers/api/v1/media_controller.rb index 72094790f..f9c935bf3 100644 --- a/app/controllers/api/v1/media_controller.rb +++ b/app/controllers/api/v1/media_controller.rb @@ -31,7 +31,7 @@ class Api::V1::MediaController < Api::BaseController end def set_media_attachment - @media_attachment = current_account.media_attachments.unattached.find(params[:id]) + @media_attachment = current_account.media_attachments.where(status_id: nil).find(params[:id]) end def check_processing -- cgit From 2ea754b8610b50cc93aeb1921ecdf7415efaf17e Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 4 Mar 2022 01:06:33 +0100 Subject: Fix duplicate notifications being possible after poll expiration (#17697) --- app/controllers/api/v1/follow_requests_controller.rb | 2 +- app/lib/activitypub/activity/announce.rb | 2 +- app/lib/activitypub/activity/follow.rb | 4 ++-- app/lib/activitypub/activity/like.rb | 2 +- app/services/bootstrap_timeline_service.rb | 2 +- app/services/favourite_service.rb | 2 +- app/workers/feed_insert_worker.rb | 2 +- app/workers/poll_expiration_notify_worker.rb | 8 +++++--- 8 files changed, 13 insertions(+), 11 deletions(-) (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/follow_requests_controller.rb b/app/controllers/api/v1/follow_requests_controller.rb index 8276245a3..54ff0e11d 100644 --- a/app/controllers/api/v1/follow_requests_controller.rb +++ b/app/controllers/api/v1/follow_requests_controller.rb @@ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController def authorize AuthorizeFollowService.new.call(account, current_account) - NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account)) + LocalNotificationWorker.perform_async(current_account.id, Follow.find_by(account: account, target_account: current_account).id, 'Follow', 'follow') render json: account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 7cd5a41e8..0674b1083 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -35,7 +35,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity def distribute # Notify the author of the original status if that status is local - NotifyService.new.call(@status.reblog.account, :reblog, @status) if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) + LocalNotificationWorker.perform_async(@status.reblog.account_id, @status.id, 'Status', 'reblog') if reblog_of_local_account?(@status) && !reblog_by_following_group_account?(@status) # Distribute into home and list feeds ::DistributionWorker.perform_async(@status.id) if @options[:override_timestamps] || @status.within_realtime_window? diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb index 4efb84b8c..97e41ab78 100644 --- a/app/lib/activitypub/activity/follow.rb +++ b/app/lib/activitypub/activity/follow.rb @@ -31,10 +31,10 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id']) if target_account.locked? || @account.silenced? - NotifyService.new.call(target_account, :follow_request, follow_request) + LocalNotificationWorker.perform_async(target_account.id, follow_request.id, 'FollowRequest', 'follow_request') else AuthorizeFollowService.new.call(@account, target_account) - NotifyService.new.call(target_account, :follow, ::Follow.find_by(account: @account, target_account: target_account)) + LocalNotificationWorker.perform_async(target_account.id, ::Follow.find_by(account: @account, target_account: target_account).id, 'Follow', 'follow') end end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index ebbda15b9..aa1dc3040 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -8,7 +8,7 @@ class ActivityPub::Activity::Like < ActivityPub::Activity favourite = original_status.favourites.create!(account: @account) - NotifyService.new.call(original_status.account, :favourite, favourite) + LocalNotificationWorker.perform_async(original_status.account_id, favourite.id, 'Favourite', 'favourite') Trends.statuses.register(original_status) end end diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb index 312c163e4..a02e55a6d 100644 --- a/app/services/bootstrap_timeline_service.rb +++ b/app/services/bootstrap_timeline_service.rb @@ -18,7 +18,7 @@ class BootstrapTimelineService < BaseService def notify_staff! User.staff.includes(:account).find_each do |user| - NotifyService.new.call(user.account, :'admin.sign_up', @source_account) + LocalNotificationWorker.perform_async(user.account_id, @source_account.id, 'Account', 'admin.sign_up') end end end diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index 0ca0081b4..dc7fe8855 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -31,7 +31,7 @@ class FavouriteService < BaseService status = favourite.status if status.account.local? - NotifyService.new.call(status.account, :favourite, favourite) + LocalNotificationWorker.perform_async(status.account_id, favourite.id, 'Favourite', 'favourite') elsif status.account.activitypub? ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url) end diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 6e3472d57..40bc9cb6e 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -66,7 +66,7 @@ class FeedInsertWorker end def perform_notify - NotifyService.new.call(@follower, :status, @status) + LocalNotificationWorker.perform_async(@follower.id, @status.id, 'Status', 'status') end def update? diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb index 7613ed5f1..0e29a5f60 100644 --- a/app/workers/poll_expiration_notify_worker.rb +++ b/app/workers/poll_expiration_notify_worker.rb @@ -38,12 +38,14 @@ class PollExpirationNotifyWorker def notify_remote_voters_and_owner! ActivityPub::DistributePollUpdateWorker.perform_async(@poll.status.id) - NotifyService.new.call(@poll.account, :poll, @poll) + LocalNotificationWorker.perform_async(@poll.account_id, @poll.id, 'Poll', 'poll') end def notify_local_voters! - @poll.voters.merge(Account.local).find_each do |account| - NotifyService.new.call(account, :poll, @poll) + @poll.voters.merge(Account.local).select(:id).find_in_batches do |accounts| + LocalNotificationWorker.push_bulk(accounts) do |account| + [account.id, @poll.id, 'Poll', 'poll'] + end end end end -- cgit From edf09ec747ebba5a170e27eb13663462a116ec6c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 7 Mar 2022 09:36:47 +0100 Subject: Add `/api/v1/accounts/familiar_followers` to REST API (#17700) * Add `/api/v1/accounts/familiar_followers` to REST API * Change hide network preference to be stored consistently for local and remote accounts * Add dummy classes to migration * Apply suggestions from code review Co-authored-by: Claire Co-authored-by: Claire --- .../v1/accounts/familiar_followers_controller.rb | 25 ++++++++++ app/controllers/follower_accounts_controller.rb | 6 +-- app/controllers/following_accounts_controller.rb | 6 +-- app/controllers/settings/preferences_controller.rb | 1 - app/controllers/settings/profiles_controller.rb | 2 +- app/lib/user_settings_decorator.rb | 5 -- app/models/account.rb | 4 +- app/models/user.rb | 6 +-- app/presenters/familiar_followers_presenter.rb | 17 +++++++ .../rest/familiar_followers_serializer.rb | 11 ++++ app/views/follower_accounts/index.html.haml | 2 +- app/views/following_accounts/index.html.haml | 2 +- .../settings/preferences/other/show.html.haml | 3 -- app/views/settings/profiles/show.html.haml | 5 +- config/locales/simple_form.en.yml | 3 +- config/routes.rb | 1 + config/settings.yml | 1 - ...220304195405_migrate_hide_network_preference.rb | 37 ++++++++++++++ db/schema.rb | 2 +- .../follower_accounts_controller_spec.rb | 2 +- .../following_accounts_controller_spec.rb | 2 +- .../familiar_followers_presenter_spec.rb | 58 ++++++++++++++++++++++ 22 files changed, 169 insertions(+), 32 deletions(-) create mode 100644 app/controllers/api/v1/accounts/familiar_followers_controller.rb create mode 100644 app/presenters/familiar_followers_presenter.rb create mode 100644 app/serializers/rest/familiar_followers_serializer.rb create mode 100644 db/migrate/20220304195405_migrate_hide_network_preference.rb create mode 100644 spec/presenters/familiar_followers_presenter_spec.rb (limited to 'app/controllers/api') diff --git a/app/controllers/api/v1/accounts/familiar_followers_controller.rb b/app/controllers/api/v1/accounts/familiar_followers_controller.rb new file mode 100644 index 000000000..b0bd8018a --- /dev/null +++ b/app/controllers/api/v1/accounts/familiar_followers_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::FamiliarFollowersController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:follows' } + before_action :require_user! + before_action :set_accounts + + def index + render json: familiar_followers.accounts, each_serializer: REST::FamiliarFollowersSerializer + end + + private + + def set_accounts + @accounts = Account.without_suspended.where(id: account_ids).select('id, hide_collections').index_by(&:id).values_at(*account_ids).compact + end + + def familiar_followers + FamiliarFollowersPresenter.new(@accounts, current_user.account_id) + end + + def account_ids + Array(params[:id]).map(&:to_i) + end +end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index b3589a39f..f3f8336c9 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -15,13 +15,13 @@ class FollowerAccountsController < ApplicationController format.html do expires_in 0, public: true unless user_signed_in? - next if @account.user_hides_network? + next if @account.hide_collections? follows end format.json do - raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections? expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) @@ -82,7 +82,7 @@ class FollowerAccountsController < ApplicationController end def restrict_fields_to - if page_requested? || !@account.user_hides_network? + if page_requested? || !@account.hide_collections? # Return all fields else %i(id type total_items) diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index 8a72dc475..9d7f4c9bf 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -15,13 +15,13 @@ class FollowingAccountsController < ApplicationController format.html do expires_in 0, public: true unless user_signed_in? - next if @account.user_hides_network? + next if @account.hide_collections? follows end format.json do - raise Mastodon::NotPermittedError if page_requested? && @account.user_hides_network? + raise Mastodon::NotPermittedError if page_requested? && @account.hide_collections? expires_in(page_requested? ? 0 : 3.minutes, public: public_fetch_mode?) @@ -82,7 +82,7 @@ class FollowingAccountsController < ApplicationController end def restrict_fields_to - if page_requested? || !@account.user_hides_network? + if page_requested? || !@account.hide_collections? # Return all fields else %i(id type total_items) diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 32b5d7948..c7492700c 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -47,7 +47,6 @@ class Settings::PreferencesController < Settings::BaseController :setting_system_font_ui, :setting_noindex, :setting_theme, - :setting_hide_network, :setting_aggregate_reblogs, :setting_show_application, :setting_advanced_layout, diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 0c15447a6..be5b4f302 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -20,7 +20,7 @@ class Settings::ProfilesController < Settings::BaseController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, fields_attributes: [:name, :value]) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, :bot, :discoverable, :hide_collections, fields_attributes: [:name, :value]) end def set_account diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index e37bc6d9f..de054e403 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -31,7 +31,6 @@ class UserSettingsDecorator user.settings['system_font_ui'] = system_font_ui_preference if change?('setting_system_font_ui') user.settings['noindex'] = noindex_preference if change?('setting_noindex') user.settings['theme'] = theme_preference if change?('setting_theme') - user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') user.settings['aggregate_reblogs'] = aggregate_reblogs_preference if change?('setting_aggregate_reblogs') user.settings['show_application'] = show_application_preference if change?('setting_show_application') user.settings['advanced_layout'] = advanced_layout_preference if change?('setting_advanced_layout') @@ -97,10 +96,6 @@ class UserSettingsDecorator boolean_cast_setting 'setting_noindex' end - def hide_network_preference - boolean_cast_setting 'setting_hide_network' - end - def show_application_preference boolean_cast_setting 'setting_show_application' end diff --git a/app/models/account.rb b/app/models/account.rb index dfdf9045f..1717f1605 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -349,11 +349,11 @@ class Account < ApplicationRecord end def hides_followers? - hide_collections? || user_hides_network? + hide_collections? end def hides_following? - hide_collections? || user_hides_network? + hide_collections? end def object_type diff --git a/app/models/user.rb b/app/models/user.rb index bbf850d84..146bdcd2a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -126,7 +126,7 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, - :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, + :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, :disable_swiping, @@ -273,10 +273,6 @@ class User < ApplicationRecord settings.notification_emails['trending_tag'] end - def hides_network? - @hides_network ||= settings.hide_network - end - def aggregates_reblogs? @aggregates_reblogs ||= settings.aggregate_reblogs end diff --git a/app/presenters/familiar_followers_presenter.rb b/app/presenters/familiar_followers_presenter.rb new file mode 100644 index 000000000..c1d944b80 --- /dev/null +++ b/app/presenters/familiar_followers_presenter.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class FamiliarFollowersPresenter + class Result < ActiveModelSerializers::Model + attributes :id, :accounts + end + + def initialize(accounts, current_account_id) + @accounts = accounts + @current_account_id = current_account_id + end + + def accounts + map = Follow.includes(account: :account_stat).where(target_account_id: @accounts.map(&:id)).where(account_id: Follow.where(account_id: @current_account_id).joins(:target_account).merge(Account.where(hide_collections: [nil, false])).select(:target_account_id)).group_by(&:target_account_id) + @accounts.map { |account| Result.new(id: account.id, accounts: (account.hide_collections? ? [] : (map[account.id] || [])).map(&:account)) } + end +end diff --git a/app/serializers/rest/familiar_followers_serializer.rb b/app/serializers/rest/familiar_followers_serializer.rb new file mode 100644 index 000000000..0a7e923f8 --- /dev/null +++ b/app/serializers/rest/familiar_followers_serializer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class REST::FamiliarFollowersSerializer < ActiveModel::Serializer + attribute :id + + has_many :accounts, serializer: REST::AccountSerializer + + def id + object.id.to_s + end +end diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml index 645dd2de1..92de35a9f 100644 --- a/app/views/follower_accounts/index.html.haml +++ b/app/views/follower_accounts/index.html.haml @@ -7,7 +7,7 @@ = render 'accounts/header', account: @account -- if @account.user_hides_network? +- if @account.hide_collections? .nothing-here= t('accounts.network_hidden') - elsif user_signed_in? && @account.blocking?(current_account) .nothing-here= t('accounts.unavailable') diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml index 17fe79018..9bb1a9edd 100644 --- a/app/views/following_accounts/index.html.haml +++ b/app/views/following_accounts/index.html.haml @@ -7,7 +7,7 @@ = render 'accounts/header', account: @account -- if @account.user_hides_network? +- if @account.hide_collections? .nothing-here= t('accounts.network_hidden') - elsif user_signed_in? && @account.blocking?(current_account) .nothing-here= t('accounts.unavailable') diff --git a/app/views/settings/preferences/other/show.html.haml b/app/views/settings/preferences/other/show.html.haml index b7ae3d2ef..44f4af2eb 100644 --- a/app/views/settings/preferences/other/show.html.haml +++ b/app/views/settings/preferences/other/show.html.haml @@ -10,9 +10,6 @@ .fields-group = f.input :setting_noindex, as: :boolean, wrapper: :with_label - .fields-group - = f.input :setting_hide_network, as: :boolean, wrapper: :with_label - .fields-group = f.input :setting_aggregate_reblogs, as: :boolean, wrapper: :with_label, recommended: true diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index d325a9ea5..fe9666d84 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -30,7 +30,10 @@ = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot') .fields-group - = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t(Setting.profile_directory ? 'simple_form.hints.defaults.discoverable' : 'simple_form.hints.defaults.discoverable_no_directory'), recommended: true + = f.input :discoverable, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.discoverable'), recommended: true + + .fields-group + = f.input :hide_collections, as: :boolean, wrapper: :with_label, label: t('simple_form.labels.defaults.setting_hide_network'), hint: t('simple_form.hints.defaults.setting_hide_network') %hr.spacer/ diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c5e75b408..b19b7891f 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -37,8 +37,7 @@ en: current_password: For security purposes please enter the password of the current account current_username: To confirm, please enter the username of the current account digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence - discoverable: Allow your account to be discovered by strangers through recommendations, profile directory and other features - discoverable_no_directory: Allow your account to be discovered by strangers through recommendations and other features + discoverable: Allow your account to be discovered by strangers through recommendations, trends and other features email: You will be sent a confirmation e-mail fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px diff --git a/config/routes.rb b/config/routes.rb index 25eb1558f..9e2f7a648 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -493,6 +493,7 @@ Rails.application.routes.draw do resource :search, only: :show, controller: :search resource :lookup, only: :show, controller: :lookup resources :relationships, only: :index + resources :familiar_followers, only: :index end resources :accounts, only: [:create, :show] do diff --git a/config/settings.yml b/config/settings.yml index e63788ba2..06dd2b3f3 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -17,7 +17,6 @@ defaults: &defaults timeline_preview: true show_staff_badge: true default_sensitive: false - hide_network: false unfollow_modal: false boost_modal: false delete_modal: true diff --git a/db/migrate/20220304195405_migrate_hide_network_preference.rb b/db/migrate/20220304195405_migrate_hide_network_preference.rb new file mode 100644 index 000000000..102ee46d6 --- /dev/null +++ b/db/migrate/20220304195405_migrate_hide_network_preference.rb @@ -0,0 +1,37 @@ +class MigrateHideNetworkPreference < ActiveRecord::Migration[6.1] + disable_ddl_transaction! + + # Dummy classes, to make migration possible across version changes + class Account < ApplicationRecord + has_one :user, inverse_of: :account + scope :local, -> { where(domain: nil) } + end + + class User < ApplicationRecord + belongs_to :account + end + + def up + Account.reset_column_information + + Setting.unscoped.where(thing_type: 'User', var: 'hide_network').find_each do |setting| + account = User.find(setting.thing_id).account + + ApplicationRecord.transaction do + account.update(hide_collections: setting.value) + setting.delete + end + rescue ActiveRecord::RecordNotFound + next + end + end + + def down + Account.local.where(hide_collections: true).includes(:user).find_each do |account| + ApplicationRecord.transaction do + Setting.create(thing_type: 'User', thing_id: account.user.id, var: 'hide_network', value: account.hide_collections?) + account.update(hide_collections: nil) + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 756e5e9ab..3666804ee 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_02_27_041951) do +ActiveRecord::Schema.define(version: 2022_03_04_195405) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/spec/controllers/follower_accounts_controller_spec.rb b/spec/controllers/follower_accounts_controller_spec.rb index eb095cf30..4d2a6e01a 100644 --- a/spec/controllers/follower_accounts_controller_spec.rb +++ b/spec/controllers/follower_accounts_controller_spec.rb @@ -103,7 +103,7 @@ describe FollowerAccountsController do context 'when account hides their network' do before do - alice.user.settings.hide_network = true + alice.update(hide_collections: true) end it 'returns followers count' do diff --git a/spec/controllers/following_accounts_controller_spec.rb b/spec/controllers/following_accounts_controller_spec.rb index af5ce0787..bb6d221ca 100644 --- a/spec/controllers/following_accounts_controller_spec.rb +++ b/spec/controllers/following_accounts_controller_spec.rb @@ -103,7 +103,7 @@ describe FollowingAccountsController do context 'when account hides their network' do before do - alice.user.settings.hide_network = true + alice.update(hide_collections: true) end it 'returns followers count' do diff --git a/spec/presenters/familiar_followers_presenter_spec.rb b/spec/presenters/familiar_followers_presenter_spec.rb new file mode 100644 index 000000000..17be4b971 --- /dev/null +++ b/spec/presenters/familiar_followers_presenter_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe FamiliarFollowersPresenter do + describe '#accounts' do + let(:account) { Fabricate(:account) } + let(:familiar_follower) { Fabricate(:account) } + let(:requested_accounts) { Fabricate.times(2, :account) } + + subject { described_class.new(requested_accounts, account.id) } + + before do + familiar_follower.follow!(requested_accounts.first) + account.follow!(familiar_follower) + end + + it 'returns a result for each requested account' do + expect(subject.accounts.map(&:id)).to eq requested_accounts.map(&:id) + end + + it 'returns followers you follow' do + result = subject.accounts.first + + expect(result).to_not be_nil + expect(result.id).to eq requested_accounts.first.id + expect(result.accounts).to match_array([familiar_follower]) + end + + context 'when requested account hides followers' do + before do + requested_accounts.first.update(hide_collections: true) + end + + it 'does not return followers you follow' do + result = subject.accounts.first + + expect(result).to_not be_nil + expect(result.id).to eq requested_accounts.first.id + expect(result.accounts).to be_empty + end + end + + context 'when familiar follower hides follows' do + before do + familiar_follower.update(hide_collections: true) + end + + it 'does not return followers you follow' do + result = subject.accounts.first + + expect(result).to_not be_nil + expect(result.id).to eq requested_accounts.first.id + expect(result.accounts).to be_empty + end + end + end +end -- cgit From 8f6c67bfdeddd1c2c1085067e3dc549fb53f6ff4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 8 Mar 2022 09:14:39 +0100 Subject: Fix performance of account timelines (#17709) * Fix performance of account timelines * Various fixes and improvements * Fix duplicate results being returned Co-authored-by: Claire * Fix grouping for pinned statuses scope Co-authored-by: Claire --- app/controllers/activitypub/outboxes_controller.rb | 2 +- .../api/v1/accounts/statuses_controller.rb | 41 +--- app/models/account_statuses_filter.rb | 134 ++++++++++++ app/models/status.rb | 22 -- spec/models/account_statuses_filter_spec.rb | 229 +++++++++++++++++++++ spec/models/status_spec.rb | 53 ----- 6 files changed, 366 insertions(+), 115 deletions(-) create mode 100644 app/models/account_statuses_filter.rb create mode 100644 spec/models/account_statuses_filter_spec.rb (limited to 'app/controllers/api') diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb index b2aab56a5..cd3992502 100644 --- a/app/controllers/activitypub/outboxes_controller.rb +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -62,7 +62,7 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController return unless page_requested? @statuses = cache_collection_paginated_by_id( - @account.statuses.permitted_for(@account, signed_request_account), + AccountStatusesFilter.new(@account, signed_request_account).results, Status, LIMIT, params_slice(:max_id, :min_id, :since_id) diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 2c027ea76..38c9f5a20 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -22,53 +22,16 @@ class Api::V1::Accounts::StatusesController < Api::BaseController end def cached_account_statuses - statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses - - statuses.merge!(only_media_scope) if truthy_param?(:only_media) - statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies) - statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs) - statuses.merge!(hashtag_scope) if params[:tagged].present? - cache_collection_paginated_by_id( - statuses, + AccountStatusesFilter.new(@account, current_account, params).results, Status, limit_param(DEFAULT_STATUSES_LIMIT), params_slice(:max_id, :since_id, :min_id) ) end - def permitted_account_statuses - @account.statuses.permitted_for(@account, current_account) - end - - def only_media_scope - Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id) - end - - def pinned_scope - @account.pinned_statuses.permitted_for(@account, current_account) - end - - def no_replies_scope - Status.without_replies - end - - def no_reblogs_scope - Status.without_reblogs - end - - def hashtag_scope - tag = Tag.find_normalized(params[:tagged]) - - if tag - Status.tagged_with(tag.id) - else - Status.none - end - end - def pagination_params(core_params) - params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params) + params.slice(:limit, *AccountStatusesFilter::KEYS).permit(:limit, *AccountStatusesFilter::KEYS).merge(core_params) end def insert_pagination_headers diff --git a/app/models/account_statuses_filter.rb b/app/models/account_statuses_filter.rb new file mode 100644 index 000000000..211f41478 --- /dev/null +++ b/app/models/account_statuses_filter.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +class AccountStatusesFilter + KEYS = %i( + pinned + tagged + only_media + exclude_replies + exclude_reblogs + ).freeze + + attr_reader :params, :account, :current_account + + def initialize(account, current_account, params = {}) + @account = account + @current_account = current_account + @params = params + end + + def results + scope = initial_scope + + scope.merge!(pinned_scope) if pinned? + scope.merge!(only_media_scope) if only_media? + scope.merge!(no_replies_scope) if exclude_replies? + scope.merge!(no_reblogs_scope) if exclude_reblogs? + scope.merge!(hashtag_scope) if tagged? + + scope + end + + private + + def initial_scope + if suspended? + Status.none + elsif anonymous? + account.statuses.where(visibility: %i(public unlisted)) + elsif author? + account.statuses.all # NOTE: #merge! does not work without the #all + elsif blocked? + Status.none + else + filtered_scope + end + end + + def filtered_scope + scope = account.statuses.left_outer_joins(:mentions) + + scope.merge!(scope.where(visibility: follower? ? %i(public unlisted private) : %i(public unlisted)).or(scope.where(mentions: { account_id: current_account.id })).group(Status.arel_table[:id])) + scope.merge!(filtered_reblogs_scope) if reblogs_may_occur? + + scope + end + + def filtered_reblogs_scope + Status.left_outer_joins(:reblog).where(reblog_of_id: nil).or(Status.where.not(reblogs_statuses: { account_id: current_account.excluded_from_timeline_account_ids })) + end + + def only_media_scope + Status.joins(:media_attachments).merge(account.media_attachments.reorder(nil)).group(Status.arel_table[:id]) + end + + def no_replies_scope + Status.without_replies + end + + def no_reblogs_scope + Status.without_reblogs + end + + def pinned_scope + account.pinned_statuses.group(Status.arel_table[:id], StatusPin.arel_table[:created_at]) + end + + def hashtag_scope + tag = Tag.find_normalized(params[:tagged]) + + if tag + Status.tagged_with(tag.id) + else + Status.none + end + end + + def suspended? + account.suspended? + end + + def anonymous? + current_account.nil? + end + + def author? + current_account.id == account.id + end + + def blocked? + account.blocking?(current_account) || (current_account.domain.present? && account.domain_blocking?(current_account.domain)) + end + + def follower? + current_account.following?(account) + end + + def reblogs_may_occur? + !exclude_reblogs? && !only_media? && !tagged? + end + + def pinned? + truthy_param?(:pinned) + end + + def only_media? + truthy_param?(:only_media) + end + + def exclude_replies? + truthy_param?(:exclude_replies) + end + + def exclude_reblogs? + truthy_param?(:exclude_reblogs) + end + + def tagged? + params[:tagged].present? + end + + def truthy_param?(key) + ActiveModel::Type::Boolean.new.cast(params[key]) + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 60dde5045..af3e645dc 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -345,28 +345,6 @@ class Status < ApplicationRecord end end - def permitted_for(target_account, account) - visibility = [:public, :unlisted] - - if account.nil? - where(visibility: visibility) - elsif target_account.blocking?(account) || (account.domain.present? && target_account.domain_blocking?(account.domain)) # get rid of blocked peeps - none - elsif account.id == target_account.id # author can see own stuff - all - else - # followers can see followers-only stuff, but also things they are mentioned in. - # non-followers can see everything that isn't private/direct, but can see stuff they are mentioned in. - visibility.push(:private) if account.following?(target_account) - - scope = left_outer_joins(:reblog) - - scope.where(visibility: visibility) - .or(scope.where(id: account.mentions.select(:status_id))) - .merge(scope.where(reblog_of_id: nil).or(scope.where.not(reblogs_statuses: { account_id: account.excluded_from_timeline_account_ids }))) - end - end - def from_text(text) return [] if text.blank? diff --git a/spec/models/account_statuses_filter_spec.rb b/spec/models/account_statuses_filter_spec.rb new file mode 100644 index 000000000..03f0ffeb0 --- /dev/null +++ b/spec/models/account_statuses_filter_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe AccountStatusesFilter do + let(:account) { Fabricate(:account) } + let(:current_account) { nil } + let(:params) { {} } + + subject { described_class.new(account, current_account, params) } + + def status!(visibility) + Fabricate(:status, account: account, visibility: visibility) + end + + def status_with_tag!(visibility, tag) + Fabricate(:status, account: account, visibility: visibility, tags: [tag]) + end + + def status_with_parent!(visibility) + Fabricate(:status, account: account, visibility: visibility, thread: Fabricate(:status)) + end + + def status_with_reblog!(visibility) + Fabricate(:status, account: account, visibility: visibility, reblog: Fabricate(:status)) + end + + def status_with_mention!(visibility, mentioned_account = nil) + Fabricate(:status, account: account, visibility: visibility).tap do |status| + Fabricate(:mention, status: status, account: mentioned_account || Fabricate(:account)) + end + end + + def status_with_media_attachment!(visibility) + Fabricate(:status, account: account, visibility: visibility).tap do |status| + Fabricate(:media_attachment, account: account, status: status) + end + end + + describe '#results' do + let(:tag) { Fabricate(:tag) } + + before do + status!(:public) + status!(:unlisted) + status!(:private) + status_with_parent!(:public) + status_with_reblog!(:public) + status_with_tag!(:public, tag) + status_with_mention!(:direct) + status_with_media_attachment!(:public) + end + + shared_examples 'filter params' do + context 'with only_media param' do + let(:params) { { only_media: true } } + + it 'returns only statuses with media' do + expect(subject.results.all?(&:with_media?)).to be true + end + end + + context 'with tagged param' do + let(:params) { { tagged: tag.name } } + + it 'returns only statuses with tag' do + expect(subject.results.all? { |s| s.tags.include?(tag) }).to be true + end + end + + context 'with exclude_replies param' do + let(:params) { { exclude_replies: true } } + + it 'returns only statuses that are not replies' do + expect(subject.results.none?(&:reply?)).to be true + end + end + + context 'with exclude_reblogs param' do + let(:params) { { exclude_reblogs: true } } + + it 'returns only statuses that are not reblogs' do + expect(subject.results.none?(&:reblog?)).to be true + end + end + end + + context 'when accessed anonymously' do + let(:current_account) { nil } + let(:direct_status) { nil } + + it 'returns only public statuses' do + expect(subject.results.pluck(:visibility).uniq).to match_array %w(unlisted public) + end + + it 'returns public replies' do + expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty + end + + it 'returns public reblogs' do + expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + end + + it_behaves_like 'filter params' + end + + context 'when accessed with a blocked account' do + let(:current_account) { Fabricate(:account) } + + before do + account.block!(current_account) + end + + it 'returns nothing' do + expect(subject.results.to_a).to be_empty + end + end + + context 'when accessed by self' do + let(:current_account) { account } + + it 'returns everything' do + expect(subject.results.pluck(:visibility).uniq).to match_array %w(direct private unlisted public) + end + + it 'returns replies' do + expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty + end + + it 'returns reblogs' do + expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + end + + it_behaves_like 'filter params' + end + + context 'when accessed by a follower' do + let(:current_account) { Fabricate(:account) } + + before do + current_account.follow!(account) + end + + it 'returns private statuses' do + expect(subject.results.pluck(:visibility).uniq).to match_array %w(private unlisted public) + end + + it 'returns replies' do + expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty + end + + it 'returns reblogs' do + expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + end + + context 'when there is a direct status mentioning the non-follower' do + let!(:direct_status) { status_with_mention!(:direct, current_account) } + + it 'returns the direct status' do + expect(subject.results.pluck(:id)).to include(direct_status.id) + end + end + + it_behaves_like 'filter params' + end + + context 'when accessed by a non-follower' do + let(:current_account) { Fabricate(:account) } + + it 'returns only public statuses' do + expect(subject.results.pluck(:visibility).uniq).to match_array %w(unlisted public) + end + + it 'returns public replies' do + expect(subject.results.pluck(:in_reply_to_id)).to_not be_empty + end + + it 'returns public reblogs' do + expect(subject.results.pluck(:reblog_of_id)).to_not be_empty + end + + context 'when there is a private status mentioning the non-follower' do + let!(:private_status) { status_with_mention!(:private, current_account) } + + it 'returns the private status' do + expect(subject.results.pluck(:id)).to include(private_status.id) + end + end + + context 'when blocking a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + current_account.block!(reblog.reblog.account) + end + + it 'does not return reblog of blocked account' do + expect(subject.results.pluck(:id)).to_not include(reblog.id) + end + end + + context 'when muting a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + current_account.mute!(reblog.reblog.account) + end + + it 'does not return reblog of muted account' do + expect(subject.results.pluck(:id)).to_not include(reblog.id) + end + end + + context 'when blocked by a reblogged account' do + let(:reblog) { status_with_reblog!('public') } + + before do + reblog.reblog.account.block!(current_account) + end + + it 'does not return reblog of blocked-by account' do + expect(subject.results.pluck(:id)).to_not include(reblog.id) + end + end + + it_behaves_like 'filter params' + end + end +end diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 67af6d06d..130f4d03f 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -348,59 +348,6 @@ RSpec.describe Status, type: :model do end end - describe '.permitted_for' do - subject { described_class.permitted_for(target_account, account).pluck(:visibility) } - - let(:target_account) { alice } - let(:account) { bob } - let!(:public_status) { Fabricate(:status, account: target_account, visibility: 'public') } - let!(:unlisted_status) { Fabricate(:status, account: target_account, visibility: 'unlisted') } - let!(:private_status) { Fabricate(:status, account: target_account, visibility: 'private') } - - let!(:direct_status) do - Fabricate(:status, account: target_account, visibility: 'direct').tap do |status| - Fabricate(:mention, status: status, account: account) - end - end - - let!(:other_direct_status) do - Fabricate(:status, account: target_account, visibility: 'direct').tap do |status| - Fabricate(:mention, status: status) - end - end - - context 'given nil' do - let(:account) { nil } - let(:direct_status) { nil } - it { is_expected.to eq(%w(unlisted public)) } - end - - context 'given blocked account' do - before do - target_account.block!(account) - end - - it { is_expected.to be_empty } - end - - context 'given same account' do - let(:account) { target_account } - it { is_expected.to eq(%w(direct direct private unlisted public)) } - end - - context 'given followed account' do - before do - account.follow!(target_account) - end - - it { is_expected.to eq(%w(direct private unlisted public)) } - end - - context 'given unfollowed account' do - it { is_expected.to eq(%w(direct unlisted public)) } - end - end - describe 'before_validation' do it 'sets account being replied to correctly over intermediary nodes' do first_status = Fabricate(:status, account: bob) -- cgit