]> cat aescling's git repositories - mastodon.git/commitdiff
Improve error handling in PostStatusService
authoraescling <aescling+gitlab@cat.family>
Tue, 6 Jun 2023 16:46:09 +0000 (12:46 -0400)
committeraescling <aescling+gitlab@cat.family>
Tue, 4 Jul 2023 20:26:13 +0000 (16:26 -0400)
* Explicitly catch invalid post visibility, and 422 instead of 500
        - ~~Reject "limited" visibility~~
        - Accept empty visibilities, ignoring them
* Explicitly alert the user when a post is rejected for having both
  media and polls

The former currently allows the visibility property to be blank or null.
I do not know if this is more desirable than, for example, rejecting
empty strings.

Additionally to the above, improve documentation for the call method.

* Improve consistency in documentation
* Improve rubocop assessment in new code we are responsible for
        - Improve documentation

* Correct scheduled post parse error semantics
        - Return 422, not 400
        - Respond with a useful error message

* Allow creating limited visibility posts from the API again

* Add appropriate yard tags to documentation

* Correct erronous documentation, copy-editing it somewhat
- Use a labelled list for Status#visibility
- Improve specificity of "make a note of that"

* Remove PostStatusService#visibility_invalid?
- !visibility_invalid? -> !visibility_valid?

app/models/status.rb
app/services/post_status_service.rb
config/locales-kibicat/en.yml

index 7f74cb9dadd3baf74ae120a49e7ea69514e6f6b7..dea138db7523fd6f0c547f8a728391c5443f26f0 100644 (file)
@@ -50,37 +50,47 @@ class Status < ApplicationRecord
 
   update_index('statuses', :proper)
 
-  # @!attribute [rw] status
-  #   Status visibility affects the audience of a post, its federation
-  #   behavior, and what interactions are allowed on the post.
+  # @!attribute [rw] visibility
+  #   A {https://api.rubyonrails.org/v6.1.7.4/classes/ActiveRecord/Enum.html
+  #   Rails enum}. Status visibility affects the audience of a post, federation
+  #   behavior for the post, and what user interactions are allowed on the
+  #   post. The documented visibilities are as follows:
   #
-  #   A public post is sent to the public timeline; remote instances with their
-  #   own public timelines will also send the post to them. In addition, public
-  #   posts are sent to following users' home timelines. Any user may see a
-  #   public post, and depending on instance configuration, the post may be
-  #   visible via a permalink without any form of user authentication.
+  #   public::
+  #     Sent to the public timeline; remote instances with their
+  #     own public timelines will also send the post to them. In addition,
+  #     public posts are sent to following users' home timelines. Any user may
+  #     see a public post, and depending on instance configuration, the post
+  #     may be visible via a permalink without any form of user authentication.
   #
-  #   An unlisted post is not sent to public timelines, but otherwise has the
-  #   same behavior as a public post.
+  #   unlisted::
+  #     Not sent to public timelines, but otherwise has the same behavior as
+  #     a public post.
   #
-  #   A private post is only visible to following users. Consequently, it
-  #   cannot be boosted, and its permalink is always guarded by user
-  #   authentication.
+  #   private::
+  #     Only visible to following users. Consequently, it cannot be boosted,
+  #     and its permalink is always guarded by user authentication.
   #
-  #   A direct post is a direct message (DM); it is only visible to mentioned
-  #   accounts, and will additionally be sent to the users' direct timelines.
-  #   Its behavior is otherwise like that of a private post.
+  #   direct::
+  #     A direct message (DM). Only visible to mentioned accounts, and will
+  #     additionally be sent to the users' direct timelines. Its behavior is
+  #     otherwise like that of a private post.
   #
-  #   The "limited" visibility is for the visibility of Google+-like circles,
-  #   which ActivityPub strictly supports, and which some Mastodon-compatible
-  #   software does curretly use. As such posts would otherwise have to be
-  #   interpreted as DMs, the limited visibility was created alongside the
-  #   ability to create "silent" mentions, so that a post may explicitly
-  #   specify its entire audience in its mentions without necessarily notifying
-  #   those mentioned users, and without sending the post to any direct
-  #   timelines. The behavior is otherwise like that of a private post;
-  #   limited visibility posts are even serialized to clients as if they were
-  #   private!
+  #   There is an additional, undocumented, "limited" visibility, explained
+  #   below:
+  #
+  #   limited::
+  #     Undocumented, but strictly allowed in the API. Used internally to
+  #     represent statuses whose audience is explicily spelled out in the manner
+  #     of Google+ circles, which ActivityPub strictly supports, and which some
+  #     Mastodon-compatible software does curretly allow. As such posts would
+  #     otherwise have to be interpreted as DMs in order to respect the indeded
+  #     audience, the limited visibility was created alongside the concept of
+  #     "silent" mentions, so that a post may explicitly specify its audience
+  #     in its mentions without necessarily notifying those mentioned users, and
+  #     without sending the post to any direct timelines. The behavior is
+  #     otherwise like that of a private post; limited visibility posts are even
+  #     serialized to clients as if they were private!
   #
   #   @see https://github.com/mastodon/mastodon/pull/8950 Mastodon PR #8950:
   #     Improve support for aspects/circles
@@ -94,7 +104,8 @@ class Status < ApplicationRecord
   #     timeline.
   #
   #   @return [:public, :unlisted, :private, :direct, :limited] The status
-  #     visibility
+  #     visibility, as a {https://api.rubyonrails.org/v6.1.7.4/classes/ActiveRecord/Enum.html
+  #     Rails enum}.
   enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
 
   belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
index 36592a531f03c6b6a997be75b200a59105e9e01c..a4cacf0c4dd1a00075bb268672e73562cc593759 100644 (file)
@@ -6,21 +6,56 @@ class PostStatusService < BaseService
 
   MIN_SCHEDULE_OFFSET = 5.minutes.freeze
 
-  # Post a text status update, fetch and notify remote users mentioned
+  # Validate and, if appropriate, post or schedule a text status update,
+  # fetching and notifying any mentioned remote users. If the new status is
+  # in reply to an account the user is not already following, make a note of
+  # that for account recommendation purposes.
+  #
+  # @note It is invalid to create a post with media attachments and a poll at
+  #   the same time.
+  #
+  # @note Attempting to schedule a status in the past will simply post the
+  #   status immediately.
+  #
+  # @note This is undocumented in the upstream API documentation, but
+  #   attempting to create a status with limited visibility is allowed.
+  #   That is to say, sending a POST request to the status creation endpoint
+  #   with the visibility attribute set to "limited" will create a status
+  #   with the (undocumented) "limited" visibility! The implication of this
+  #   are nonobvious, and, frankly, not at all useful; see {Status#visibility}
+  #   for the details.
+  #
   # @param [Account] account Account from which to post
-  # @param [Hash] options
-  # @option [String] :text Message
-  # @option [Status] :thread Optional status to reply to
-  # @option [Boolean] :sensitive
-  # @option [String] :visibility
-  # @option [String] :spoiler_text
-  # @option [String] :language
-  # @option [String] :scheduled_at
-  # @option [Hash] :poll Optional poll to attach
-  # @option [Enumerable] :media_ids Optional array of media IDs to attach
-  # @option [Doorkeeper::Application] :application
-  # @option [String] :idempotency Optional idempotency key
-  # @option [Boolean] :with_rate_limit
+  # @param [Hash] options Options hash
+  # @option options [String] :text Message to post. Defaults to the empty
+  #   string, unless there is nonblank spoiler text, in which case a single
+  #   period is used for text posts, or an appropriate emoji for media
+  # @option options [Status] :thread Optional status to reply to
+  # @option options [Boolean] :sensitive Optional; forced to true if
+  #   spoiler_text is present. Defaults to the user's default post sensitivity
+  #   setting
+  # @option options [String] :visibility Optional; if present, SHOULD be one of
+  #   "public", "unlisted", "private", or "direct", but see {Status#visibility}.
+  #   Defaults to the user's default post privacy.
+  # @option options [String] :spoiler_text Optional content warning
+  # @option options [String] :language Optional two-letter language code. If
+  #   blank, invalid, or unknown to the backend, defaults to the user's default
+  #   post language if it exists, and then to the default locale
+  # @option options [String] :scheduled_at Optional; if present, should be a
+  #   valid timestamp, and should refer to a time at least {MIN_SCHEDULE_OFFSET}
+  #   into the future. (If determined to be scheduled in the past, the status
+  #   will be created immediately!)
+  # @option options [Hash] :poll Optional poll to attach. Invalid to be present
+  #   when media attachments are also present
+  # @option options [Enumerable] :media_ids Optional array of media IDs to
+  #   attach. Invalid to be present when a poll is also present
+  # @option options [Doorkeeper::Application] :application Optional application
+  #   the status was posted from
+  # @option options [String] :idempotency Optional idempotency key, preventing
+  #   this status from being created if a status with this key already exists
+  # @option options [Boolean] :with_rate_limit Strictly optional, but should be
+  #   forced to true inside a controller. Can be safely ignored if, for
+  #   example, creating a status as part of a test
   # @return [Status]
   def call(account, options = {})
     @account     = account
@@ -60,12 +95,17 @@ class PostStatusService < BaseService
      end
     end
     @sensitive    = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
+
+    if !visibility_valid?
+      raise Mastodon::ValidationError, I18n.t('statuses.validations.visibility', visibility: @options[:visibility])
+    end
+
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
     @visibility   = :unlisted if @visibility&.to_sym == :public && @account.silenced?
     @scheduled_at = @options[:scheduled_at]&.to_datetime
     @scheduled_at = nil if scheduled_in_the_past?
   rescue ArgumentError
-    raise ActiveRecord::RecordInvalid
+    raise Mastodon::ValidationError, I18n.t('statuses.validations.scheduled_at', datetime: @options[:scheduled_at])
   end
 
   def process_status!
@@ -114,7 +154,8 @@ class PostStatusService < BaseService
       return
     end
 
-    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.poll') if @options[:poll].present?
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4
 
     @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
 
@@ -158,6 +199,10 @@ class PostStatusService < BaseService
     @scheduled_at.present? && @scheduled_at <= Time.now.utc + MIN_SCHEDULE_OFFSET
   end
 
+  # If the status was made in reply to an account the user is not already
+  # following, increase the number of recorded interactions that user has had
+  # with the author of the replied status, for account recommendation purposes.
+  # @return void
   def bump_potential_friendship!
     return if !@status.reply? || @account.id == @status.in_reply_to_account_id
     ActivityTracker.increment('activity:interactions')
@@ -205,4 +250,17 @@ class PostStatusService < BaseService
       options_hash[:with_rate_limit] = false
     end
   end
+
+  # If a visibility is present in the options hash, ensure it represents a
+  # proper status visibility
+  # @example Behaviors that are implicitly specified by Mastodon
+  #   @options[:visibility] = nil; visibility_vaild?              # => true
+  #   @options[:visibility] = ""; visibility_vaild?               # => true
+  #   @options[:visibility] = "         "; visibility_vaild?      # => true
+  #   @options[:visibility] = "antohusathue"; visibility_invalid? # => true
+  #   @options[:visibility] = "limited"; visibility_vaild?        # => true
+  # @return [Boolean]
+  def visibility_valid?
+    @options[:visibility].nil? || @options[:visibility].blank? || Status.visibilities.key?(@options[:visibility])
+  end
 end
index 1491cafbfcc2a02a86f809f70dbb0ed73d84bb4e..d200633ab70f561a5ab31ef01a9ec6a1ddd52a2d 100644 (file)
@@ -1,2 +1,9 @@
 ---
 en:
+  media_attachments:
+    validations:
+      poll: Cannot attach media and polls to the same status
+  statuses:
+    validations:
+      scheduled_at: 'Invalid scheduled datetime "%{datetime}"'
+      visibility: 'Invalid status visibility "%{visibility}"'