about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authormultiple creatures <dev@multiple-creature.party>2020-01-13 21:57:24 -0600
committermultiple creatures <dev@multiple-creature.party>2020-01-13 21:57:24 -0600
commit67516a07db44196cc4bb94bf94abe5c6f611cf07 (patch)
treef65b7c79f859329a0438d806e777fa1d7aa7f400 /app
parent1fbe7c340201ddff5232c7931a265136943ffd59 (diff)
add new privacy option to auto-defederate after a given timespan + add options to defederate and/or delete past posts + add `defed_in`/`parent:defed_in`/`thread:defed_in` bangtags + ui indicator for posts marked for auto-defederation
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/settings/preferences_controller.rb7
-rw-r--r--app/javascript/flavours/glitch/components/status_icons.js3
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js11
-rw-r--r--app/lib/bangtags.rb59
-rw-r--r--app/lib/user_settings_decorator.rb15
-rw-r--r--app/models/account.rb3
-rw-r--r--app/models/defederating_status.rb21
-rw-r--r--app/models/destructing_status.rb1
-rw-r--r--app/models/status.rb19
-rw-r--r--app/models/user.rb15
-rw-r--r--app/serializers/rest/status_serializer.rb5
-rw-r--r--app/services/post_status_service.rb15
-rw-r--r--app/services/remove_status_service.rb37
-rw-r--r--app/views/settings/preferences/show.html.haml18
-rw-r--r--app/workers/defederate_status_worker.rb17
-rw-r--r--app/workers/mark_expired_statuses_worker.rb44
-rw-r--r--app/workers/scheduler/defederating_statuses_scheduler.rb19
18 files changed, 291 insertions, 20 deletions
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index c3f2422ce..255c48e62 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -53,6 +53,7 @@ class Api::V1::StatusesController < Api::BaseController
                                          visibility: status_params[:visibility],
                                          scheduled_at: status_params[:scheduled_at],
                                          delete_after: status_params[:delete_after],
+                                         defederate_after: status_params[:defederate_after],
                                          sharekey: status_params[:sharekey],
                                          application: doorkeeper_token.application,
                                          poll: status_params[:poll],
@@ -100,6 +101,7 @@ class Api::V1::StatusesController < Api::BaseController
       :sharekey,
       :scheduled_at,
       :delete_after,
+      :defederate_after,
       :content_type,
       media_ids: [],
       poll: [
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 8573624f2..295fc3129 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -12,6 +12,12 @@ class Settings::PreferencesController < Settings::BaseController
   def update
     user_settings.update(user_settings_params.to_h)
 
+    MarkExpiredStatusesWorker.perform_async(
+      current_account.id,
+      truthy_param?(:setting_defederate_old),
+      truthy_param?(:setting_lifespan_old)
+    )
+
     if current_user.update(user_params)
       I18n.locale = current_user.locale
       toggle_filters
@@ -85,6 +91,7 @@ class Settings::PreferencesController < Settings::BaseController
       :setting_max_public_history,
       :setting_max_public_access,
       :setting_roar_lifespan,
+      :setting_roar_defederate,
       :setting_delayed_roars,
       :setting_delayed_for,
       :setting_boost_interval,
diff --git a/app/javascript/flavours/glitch/components/status_icons.js b/app/javascript/flavours/glitch/components/status_icons.js
index f439afbe6..bba2fac87 100644
--- a/app/javascript/flavours/glitch/components/status_icons.js
+++ b/app/javascript/flavours/glitch/components/status_icons.js
@@ -64,6 +64,9 @@ export default class StatusIcons extends React.PureComponent {
         {status.get('delete_after') ? (
           <i className='fa fa-clock-o' title={new Date(status.get('delete_after'))} aria-hidden='true' />
         ) : null}
+        {status.get('defederate_after') ? (
+          <i className='fa fa-calendar-times-o' title={new Date(status.get('defederate_after'))} aria-hidden='true' />
+        ) : null}
         {status.get('reject_replies') ? (
           <i className='fa fa-microphone-slash' title='Rejecting replies' aria-hidden='true' />
         ) : null}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index cde6e4348..bda5e7152 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -131,6 +131,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     let favouriteLink = '';
     let sharekeyLinks = '';
     let destructIcon = '';
+    let defederateIcon = '';
     let rejectIcon = '';
 
     if (this.props.measureHeight) {
@@ -256,6 +257,14 @@ export default class DetailedStatus extends ImmutablePureComponent {
       )
     }
 
+    if (status.get('defederate_after')) {
+      defederateIcon = (
+        <span>
+          <i className='fa fa-calendar-times-o' title={new Date(status.get('defederate_after'))} /> ·
+        </span>
+      )
+    }
+
     if (status.get('reject_replies')) {
       rejectIcon = (
         <span>
@@ -285,7 +294,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           />
 
           <div className='detailed-status__meta'>
-            {sharekeyLinks} {reblogLink} · {favouriteLink} · {destructIcon} {rejectIcon} <VisibilityIcon visibility={status.get('visibility')} />
+            {sharekeyLinks} {reblogLink} · {favouriteLink} · {defederateIcon} {destructIcon} {rejectIcon} <VisibilityIcon visibility={status.get('visibility')} />
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
               <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
             </a>
diff --git a/app/lib/bangtags.rb b/app/lib/bangtags.rb
index 9fec00d9d..42dd661e6 100644
--- a/app/lib/bangtags.rb
+++ b/app/lib/bangtags.rb
@@ -59,6 +59,21 @@ class Bangtags
       ['all', 'live'] => ['live', 'all'],
       ['all', 'lifespan'] => ['lifespan', 'all'],
       ['all', 'delete_in'] => ['delete_in', 'all'],
+
+      ['parent', 'd'] => ['defederate_in', 'parent'],
+      ['parent', 'defed'] => ['defederate_in', 'parent'],
+      ['parent', 'defed_in'] => ['defederate_in', 'parent'],
+      ['parent', 'defederate'] => ['defederate_in', 'parent'],
+
+      ['thread', 'd'] => ['defederate_in', 'thread'],
+      ['thread', 'defed'] => ['defederate_in', 'thread'],
+      ['thread', 'defed_in'] => ['defederate_in', 'thread'],
+      ['thread', 'defederate'] => ['defederate_in', 'thread'],
+
+      ['all', 'd'] => ['defederate_in', 'all'],
+      ['all', 'defed'] => ['defederate_in', 'all'],
+      ['all', 'defed_in'] => ['defederate_in', 'all'],
+      ['all', 'defederate'] => ['defederate_in', 'all'],
     }
 
     # sections of the final status text
@@ -730,6 +745,50 @@ class Bangtags
             s.delete_after = delete_after
             Rails.cache.delete("statuses/#{s.id}")
           end
+        when 'd', 'defed', 'defed_in', 'defederate', 'defederate_in'
+          chunk = nil
+          next if cmd[1].nil?
+          case cmd[1].downcase
+          when 'parent', 'thread', 'all'
+            s = cmd[1].downcase.to_sym
+            s = @parent_status if s == :parent
+            next unless s == :all ||  @parent_status.present?
+            next unless s == :thread || s == :all || @parent_status.account_id == @account.id
+            i = cmd[2].to_i
+            unit = cmd[3].present? ? cmd[3].downcase : 'minutes'
+          else
+            s = @status
+            i = cmd[1].to_i
+            unit = cmd[2].present? ? cmd[2].downcase : 'minutes'
+          end
+          defederate_after = case unit
+                         when 'min', 'mins', 'minute', 'minutes'
+                           i.minutes
+                         when 'h', 'hr', 'hrs', 'hour', 'hours'
+                           i.hours
+                         when 'd', 'dy', 'dys', 'day', 'days'
+                           i.days
+                         when 'w', 'wk', 'wks', 'week', 'weeks'
+                           i.weeks
+                         when 'm', 'mn', 'mns', 'month', 'months'
+                           i.months
+                         when 'y', 'yr', 'yrs', 'year', 'years'
+                           i.years
+                         end
+          if s == :thread
+            @parent_status.conversation.statuses.where(account_id: @account.id).find_each do |s|
+              s.defederate_after = defederate_after
+              Rails.cache.delete("statuses/#{s.id}")
+            end
+          elsif s == :all
+            @account.statuses.find_each do |s|
+              s.defederate_after = defederate_after
+              Rails.cache.delete("statuses/#{s.id}")
+            end
+          else
+            s.defederate_after = defederate_after
+            Rails.cache.delete("statuses/#{s.id}")
+          end
         when 'keysmash'
           keyboard = [
             'asdf', 'jkl;',
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 5f800adc6..56a35a8af 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -42,6 +42,9 @@ class UserSettingsDecorator
     user.settings['max_public_history']  = max_public_history_preference if change?('setting_max_public_history')
     user.settings['max_public_access']   = max_public_access_preference if change?('setting_max_public_access')
     user.settings['roar_lifespan']       = roar_lifespan_preference if change?('setting_roar_lifespan')
+    user.settings['roar_lifespan_old']   = roar_lifespan_old_preference if change?('setting_roar_lifespan_old')
+    user.settings['roar_defederate']     = roar_defederate_preference if change?('setting_roar_defederate')
+    user.settings['roar_defederate_old'] = roar_defederate_old_preference if change?('setting_roar_defederate_old')
     user.settings['delayed_roars']       = delayed_roars_preference if change?('setting_delayed_roars')
     user.settings['delayed_for']         = delayed_for_preference if change?('setting_delayed_for')
     user.settings['boost_interval']      = boost_interval_preference if change?('setting_boost_interval')
@@ -160,6 +163,18 @@ class UserSettingsDecorator
     settings['setting_roar_lifespan']
   end
 
+  def roar_lifespan_old_preference
+    settings['setting_roar_lifespan_old']
+  end
+
+  def roar_defederate_preference
+    settings['setting_roar_defederate']
+  end
+
+  def roar_defederate_old_preference
+    settings['setting_roar_defederate_old']
+  end
+
   def delayed_for_preference
     settings['setting_delayed_for']
   end
diff --git a/app/models/account.rb b/app/models/account.rb
index 59685a13b..9cfacc1c0 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -135,6 +135,9 @@ class Account < ApplicationRecord
            :max_public_history,
            :max_public_access,
            :roar_lifespan,
+           :roar_lifespan_old,
+           :roar_defederate,
+           :roar_defederate_old,
            :delayed_roars?,
 
            :hides_public_profile?,
diff --git a/app/models/defederating_status.rb b/app/models/defederating_status.rb
new file mode 100644
index 000000000..e6af6c1b6
--- /dev/null
+++ b/app/models/defederating_status.rb
@@ -0,0 +1,21 @@
+# == Schema Information
+#
+# Table name: defederating_statuses
+#
+#  id               :bigint(8)        not null, primary key
+#  status_id        :bigint(8)
+#  defederate_after :datetime
+#
+
+class DefederatingStatus < ApplicationRecord
+  belongs_to :status, inverse_of: :defederating_status
+
+  validate :validate_future_date
+  validates :status_id, uniqueness: true
+
+  private
+
+  def validate_future_date
+    errors.add(:defederate_after, I18n.t('defederating_statuses.too_soon')) if defederate_after.present? && defederate_after < Time.now.utc + PostStatusService::MIN_DESTRUCT_OFFSET
+  end
+end
diff --git a/app/models/destructing_status.rb b/app/models/destructing_status.rb
index 349e276cb..e22fa402c 100644
--- a/app/models/destructing_status.rb
+++ b/app/models/destructing_status.rb
@@ -11,6 +11,7 @@ class DestructingStatus < ApplicationRecord
   belongs_to :status, inverse_of: :destructing_status
 
   validate :validate_future_date
+  validates :status_id, uniqueness: true
 
   private
 
diff --git a/app/models/status.rb b/app/models/status.rb
index 001437795..f21a2087e 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -82,6 +82,7 @@ class Status < ApplicationRecord
   has_one :status_stat, inverse_of: :status
   has_one :poll, inverse_of: :status, dependent: :destroy
   has_one :destructing_status, inverse_of: :status, dependent: :destroy
+  has_one :defederating_status, inverse_of: :status, dependent: :destroy
   has_one :imported_status, inverse_of: :status, dependent: :destroy
   has_one :sharekey, inverse_of: :status, dependent: :destroy
 
@@ -284,6 +285,10 @@ class Status < ApplicationRecord
   end
 
   def delete_after=(value)
+    if defederate_after && defederate_after < (Time.now.utc + 5.minutes + value)
+      value = 5.minutes + value
+    end
+
     if destructing_status.nil?
       DestructingStatus.create!(status_id: id, delete_after: Time.now.utc + value)
     else
@@ -291,6 +296,20 @@ class Status < ApplicationRecord
     end
   end
 
+  def defederate_after
+    defederating_status&.defederate_after
+  end
+
+  def defederate_after=(value)
+    return unless delete_after.nil? || delete_after >= (Time.now.utc + 5.minutes + value)
+
+    if defederating_status.nil?
+      DefederatingStatus.create!(status_id: id, defederate_after: Time.now.utc + value)
+    else
+      defederating_status.defederate_after = Time.now.utc + value
+    end
+  end
+
   def mark_for_mass_destruction!
     @marked_for_mass_destruction = true
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6c18898e9..b28e3229f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -148,6 +148,9 @@ class User < ApplicationRecord
     :max_public_history,
     :max_public_access,
     :roar_lifespan,
+    :roar_lifespan_old,
+    :roar_defederate,
+    :roar_defederate_old,
     :delayed_roars,
     :delayed_for,
     :boost_interval,
@@ -340,6 +343,18 @@ class User < ApplicationRecord
     @_roar_lifespan ||= [0, (settings.roar_lifespan || 0).to_i].max
   end
 
+  def roar_lifespan_old
+    @_roar_lifespan_old ||= (settings.roar_lifespan_old || false)
+  end
+
+  def roar_defederate
+    @_roar_defederate ||= [0, (settings.roar_defederate || 0).to_i].max
+  end
+
+  def roar_defederate_old
+    @_roar_defederate_old ||= (settings.roar_defederate_old || false)
+  end
+
   def delayed_roars?
     @delayed_roars ||= (settings.delayed_roars || false)
   end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 32f75caa3..0a98b2ecd 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -16,6 +16,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :local_only if :local?
   attribute :sharekey, if: :has_sharekey?
   attribute :delete_after, if: :current_user?
+  attribute :defederate_after, if: :current_user?
 
   attribute :content, unless: :source_requested?
   attribute :text, if: :source_requested?
@@ -154,6 +155,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
     object.delete_after
   end
 
+  def defederate_after
+    object.defederate_after
+  end
+
   def reject_replies
     object.reject_replies == true
   end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index dc1a3bcdc..8a1ca3eca 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -30,6 +30,7 @@ class PostStatusService < BaseService
   # @option [String] :language
   # @option [String] :scheduled_at
   # @option [String] :delete_after
+  # @option [String] :defederate_after
   # @option [Account] :mentions Optional accounts to mention out-of-body
   # @option [Boolean] :noreplies Author does not accept replies
   # @option [Boolean] :nocrawl Optional skip link card generation
@@ -76,6 +77,7 @@ class PostStatusService < BaseService
         distribute: @options[:distribute],
         nocrawl: @options[:nocrawl],
         delete_after: @delete_after.nil? ? nil : @delete_after + 1.minute,
+        defederate_after: @defederate_after.nil? ? nil : @defederate_after + 1.minute,
         reject_replies: @options[:noreplies] || false,
       }.compact
 
@@ -201,6 +203,18 @@ class PostStatusService < BaseService
     end
     @delete_after = nil if @delete_after.present? && (@delete_after < MIN_DESTRUCT_OFFSET)
 
+    case @options[:defederate_after].class
+    when NilClass
+      @defederate_after = @account.user.setting_roar_defederate.to_i.days
+    when ActiveSupport::Duration
+      @defederate_after = @options[:defederate_after]
+    when Integer
+      @defederate_after = @options[:defederate_after].minutes
+    when Float
+      @defederate_after = @options[:defederate_after].minutes
+    end
+    @defederate_after = nil if @defederate_after.present? && (@defederate_after < MIN_DESTRUCT_OFFSET)
+
   rescue ArgumentError
     raise ActiveRecord::RecordInvalid
   end
@@ -310,6 +324,7 @@ class PostStatusService < BaseService
       visibility: @visibility,
       local_only: @local_only,
       delete_after: @delete_after,
+      defederate_after: @defederate_after,
       reject_replies: @options[:noreplies] || false,
       sharekey: @options[:sharekey],
       language: language_from_option(@options[:language]) || @account.user_default_language&.presence || 'en',
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 20648217d..797794a19 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -16,22 +16,24 @@ class RemoveStatusService < BaseService
     @stream_entry = status.stream_entry
     @options      = options
 
-    RedisLock.acquire(lock_options) do |lock|
-      if lock.acquired?
-        remove_from_queued
-        remove_from_self if status.account.local?
-        remove_from_followers
-        remove_from_lists
-        remove_from_affected
-        remove_reblogs
-        remove_from_hashtags
-        remove_from_public
-        remove_from_media if status.media_attachments.any?
-        remove_from_direct if status.direct_visibility?
-
-        @status.destroy!
-      else
-        raise Mastodon::RaceConditionError
+    unless options[:defederate_only]
+      RedisLock.acquire(lock_options) do |lock|
+        if lock.acquired?
+          remove_from_queued
+          remove_from_self if status.account.local?
+          remove_from_followers
+          remove_from_lists
+          remove_from_affected
+          remove_reblogs
+          remove_from_hashtags
+          remove_from_public
+          remove_from_media if status.media_attachments.any?
+          remove_from_direct if status.direct_visibility?
+
+          @status.destroy!
+        else
+          raise Mastodon::RaceConditionError
+        end
       end
     end
 
@@ -44,6 +46,9 @@ class RemoveStatusService < BaseService
 
     remove_from_remote_followers
     remove_from_remote_affected
+
+    @status.update(local_only: true) if options[:defederate_only]
+    Rails.cache.delete("statuses/#{@status.id}")
   end
 
   private
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index cf9529ac3..3d371c14a 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -17,9 +17,21 @@
     = f.input :setting_default_privacy, collection: Status.selectable_visibilities, wrapper: :with_floating_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
   .fields-group
-    = f.input :setting_max_public_history, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_history
-    = f.input :setting_max_public_access, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_access
-    = f.input :setting_roar_lifespan, collection: [0, 1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.roar_lifespan
+    .fields-row
+      .fields-group.fields-row__column.fields-row__column-6
+        = f.input :setting_max_public_history, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_history
+      .fields-group.fields-row__column.fields-row__column-6
+        = f.input :setting_max_public_access, collection: [1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.max_public_access
+
+  .fields-group
+    .fields-row
+      .fields-group.fields-row__column.fields-row__column-6
+        = f.input :setting_roar_defederate, collection: [0, 1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.roar_defederate
+        = f.input :setting_roar_defederate_old, as: :boolean, wrapper: :with_label
+
+      .fields-group.fields-row__column.fields-row__column-6
+        = f.input :setting_roar_lifespan, collection: [0, 1, 3, 6, 7, 14, 30, 60, 90, 180, 365, 730, 1095, 2190], wrapper: :with_label, include_blank: false, label_method: lambda { |item| safe_join([t("simple_form.labels.lifespan.#{item}")]) }, selected: current_user.roar_lifespan
+        = f.input :setting_roar_lifespan_old, as: :boolean, wrapper: :with_label
 
   .fields-group
     = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label
diff --git a/app/workers/defederate_status_worker.rb b/app/workers/defederate_status_worker.rb
new file mode 100644
index 000000000..bae4be9b8
--- /dev/null
+++ b/app/workers/defederate_status_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class DefederateStatusWorker
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed
+
+  def perform(defederating_status_id)
+    defederating_status = DefederatingStatus.find(defederating_status_id)
+    defederating_status.destroy!
+
+    RemoveStatusService.new.call(defederating_status.status, defederate_only: true)
+    true
+  rescue ActiveRecord::RecordNotFound, ActiveRecord::RecordInvalid
+    true
+  end
+end
diff --git a/app/workers/mark_expired_statuses_worker.rb b/app/workers/mark_expired_statuses_worker.rb
new file mode 100644
index 000000000..9eac58357
--- /dev/null
+++ b/app/workers/mark_expired_statuses_worker.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+class MarkExpiredStatusesWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'bulk'
+
+  def perform(account_id, defederate = false, lifespan = false)
+    @account = Account.find(account_id)
+    return if @account&.user.nil?
+    @user = @account.user
+
+    @roar_defederate = @user.roar_defederate.to_i
+    @roar_lifespan = @user.roar_lifespan.to_i
+
+    defederate = false if @roar_defederate == 0
+    lifespan = false if @roar_lifespan == 0
+
+    return unless defederate || lifespan
+
+    offset = 30.minutes
+
+    @account.statuses.find_each do |status|
+      modified = false
+
+      if defederate && !status.local_only? && status.updated_at < @roar_defederate.days.ago
+        status.defederate_after = offset
+        modified = true
+      end
+
+      if lifespan && status.updated_at < @roar_lifespan.days.ago
+        status.delete_after = offset + 30.minutes
+        modified = true
+      end
+
+      if modified
+        Rails.cache.delete("statuses/#{status.id}")
+        offset += 1.second
+      end
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end
diff --git a/app/workers/scheduler/defederating_statuses_scheduler.rb b/app/workers/scheduler/defederating_statuses_scheduler.rb
new file mode 100644
index 000000000..91a644e02
--- /dev/null
+++ b/app/workers/scheduler/defederating_statuses_scheduler.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Scheduler::DefederatingStatusesScheduler
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed, retry: 0
+
+  def perform
+    due_statuses.find_each do |defederating_status|
+      DefederateStatusWorker.perform_async(defederating_status.id)
+    end
+  end
+
+  private
+
+  def due_statuses
+    DefederatingStatus.where('defederate_after <= ?', Time.now.utc)
+  end
+end