Use likes and shares totalItems on status creations and updates (#32620)

This commit is contained in:
Jonny Saunders 2024-10-27 21:55:18 -07:00 committed by GitHub
parent 77cd16f4ee
commit 9074c1fac9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 326 additions and 14 deletions

View file

@ -53,6 +53,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ApplicationRecord.transaction do ApplicationRecord.transaction do
@status = Status.create!(@params) @status = Status.create!(@params)
attach_tags(@status) attach_tags(@status)
attach_counts(@status)
end end
resolve_thread(@status) resolve_thread(@status)
@ -166,6 +167,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end end
end end
def attach_counts(status)
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count
return if likes.nil? && shares.nil?
status.status_stat.tap do |status_stat|
status_stat.untrusted_reblogs_count = shares unless shares.nil?
status_stat.untrusted_favourites_count = likes unless likes.nil?
status_stat.save if status_stat.changed?
end
end
def process_tags def process_tags
return if @object['tag'].nil? return if @object['tag'].nil?

View file

@ -93,6 +93,14 @@ class ActivityPub::Parser::StatusParser
lang.presence && NORMALIZED_LOCALE_NAMES.fetch(lang.downcase.to_sym, lang) lang.presence && NORMALIZED_LOCALE_NAMES.fetch(lang.downcase.to_sym, lang)
end end
def favourites_count
@object.dig(:likes, :totalItems)
end
def reblogs_count
@object.dig(:shares, :totalItems)
end
private private
def raw_language_code def raw_language_code

View file

@ -303,13 +303,35 @@ class Status < ApplicationRecord
status_stat&.favourites_count || 0 status_stat&.favourites_count || 0
end end
# Reblogs count received from an external instance
def untrusted_reblogs_count
status_stat&.untrusted_reblogs_count unless local?
end
# Favourites count received from an external instance
def untrusted_favourites_count
status_stat&.untrusted_favourites_count unless local?
end
def increment_count!(key) def increment_count!(key)
if key == :favourites_count && !untrusted_favourites_count.nil?
update_status_stat!(favourites_count: favourites_count + 1, untrusted_favourites_count: untrusted_favourites_count + 1)
elsif key == :reblogs_count && !untrusted_reblogs_count.nil?
update_status_stat!(reblogs_count: reblogs_count + 1, untrusted_reblogs_count: untrusted_reblogs_count + 1)
else
update_status_stat!(key => public_send(key) + 1) update_status_stat!(key => public_send(key) + 1)
end end
end
def decrement_count!(key) def decrement_count!(key)
if key == :favourites_count && !untrusted_favourites_count.nil?
update_status_stat!(favourites_count: [favourites_count - 1, 0].max, untrusted_favourites_count: [untrusted_favourites_count - 1, 0].max)
elsif key == :reblogs_count && !untrusted_reblogs_count.nil?
update_status_stat!(reblogs_count: [reblogs_count - 1, 0].max, untrusted_reblogs_count: [untrusted_reblogs_count - 1, 0].max)
else
update_status_stat!(key => [public_send(key) - 1, 0].max) update_status_stat!(key => [public_send(key) - 1, 0].max)
end end
end
def trendable? def trendable?
if attributes['trendable'].nil? if attributes['trendable'].nil?

View file

@ -11,11 +11,17 @@
# favourites_count :bigint(8) default(0), not null # favourites_count :bigint(8) default(0), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# untrusted_favourites_count :bigint(8)
# untrusted_reblogs_count :bigint(8)
# #
class StatusStat < ApplicationRecord class StatusStat < ApplicationRecord
belongs_to :status, inverse_of: :status_stat belongs_to :status, inverse_of: :status_stat
before_validation :clamp_untrusted_counts
MAX_UNTRUSTED_COUNT = 100_000_000
def replies_count def replies_count
[attributes['replies_count'], 0].max [attributes['replies_count'], 0].max
end end
@ -27,4 +33,11 @@ class StatusStat < ApplicationRecord
def favourites_count def favourites_count
[attributes['favourites_count'], 0].max [attributes['favourites_count'], 0].max
end end
private
def clamp_untrusted_counts
self.untrusted_favourites_count = untrusted_favourites_count.to_i.clamp(0, MAX_UNTRUSTED_COUNT) if untrusted_favourites_count.present?
self.untrusted_reblogs_count = untrusted_reblogs_count.to_i.clamp(0, MAX_UNTRUSTED_COUNT) if untrusted_reblogs_count.present?
end
end end

View file

@ -84,11 +84,11 @@ class REST::StatusSerializer < ActiveModel::Serializer
end end
def reblogs_count def reblogs_count
relationships&.attributes_map&.dig(object.id, :reblogs_count) || object.reblogs_count object.untrusted_reblogs_count || relationships&.attributes_map&.dig(object.id, :reblogs_count) || object.reblogs_count
end end
def favourites_count def favourites_count
relationships&.attributes_map&.dig(object.id, :favourites_count) || object.favourites_count object.untrusted_favourites_count || relationships&.attributes_map&.dig(object.id, :favourites_count) || object.favourites_count
end end
def favourited def favourited

View file

@ -43,6 +43,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
update_poll! update_poll!
update_immediate_attributes! update_immediate_attributes!
update_metadata! update_metadata!
update_counts!
create_edits! create_edits!
end end
@ -62,6 +63,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
with_redis_lock("create:#{@uri}") do with_redis_lock("create:#{@uri}") do
update_poll!(allow_significant_changes: false) update_poll!(allow_significant_changes: false)
queue_poll_notifications! queue_poll_notifications!
update_counts!
end end
end end
@ -239,6 +241,19 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end end
end end
def update_counts!
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count
return if likes.nil? && shares.nil?
@status.status_stat.tap do |status_stat|
status_stat.untrusted_reblogs_count = shares unless shares.nil?
status_stat.untrusted_favourites_count = likes unless likes.nil?
status_stat.save if status_stat.changed?
end
end
def expected_type? def expected_type?
equals_or_includes_any?(@json['type'], %w(Note Question)) equals_or_includes_any?(@json['type'], %w(Note Question))
end end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddUntrustedFavouritesCountAndUntrustedReblogsCountToStatusStat < ActiveRecord::Migration[7.1]
def change
add_column :status_stats, :untrusted_favourites_count, :bigint, null: true
add_column :status_stats, :untrusted_reblogs_count, :bigint, null: true
end
end

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_10_14_010506) do ActiveRecord::Schema[7.1].define(version: 2024_10_22_214312) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -1008,6 +1008,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_10_14_010506) do
t.bigint "favourites_count", default: 0, null: false t.bigint "favourites_count", default: 0, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.bigint "untrusted_favourites_count"
t.bigint "untrusted_reblogs_count"
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
end end

View file

@ -928,6 +928,32 @@ RSpec.describe ActivityPub::Activity::Create do
expect(poll.votes.first).to be_nil expect(poll.votes.first).to be_nil
end end
end end
context 'with counts' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
likes: {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar', '/likes'].join,
type: 'Collection',
totalItems: 50,
},
shares: {
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar', '/shares'].join,
type: 'Collection',
totalItems: 100,
},
}
end
it 'uses the counts from the created object' do
status = sender.statuses.first
expect(status.untrusted_favourites_count).to eq 50
expect(status.untrusted_reblogs_count).to eq 100
end
end
end end
context 'when object URI uses bearcaps' do context 'when object URI uses bearcaps' do

View file

@ -115,5 +115,69 @@ RSpec.describe ActivityPub::Activity::Update do
expect(status.edited_at).to be_nil expect(status.edited_at).to be_nil
end end
end end
context 'with a Note object' do
let(:updated) { nil }
let(:favourites) { 50 }
let(:reblogs) { 100 }
let!(:status) { Fabricate(:status, uri: 'https://example.com/statuses/poll', account: sender) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Update',
actor: sender.uri,
object: {
type: 'Note',
id: status.uri,
content: 'Foo',
updated: updated,
likes: {
id: "#{status.uri}/likes",
type: 'Collection',
totalItems: favourites,
},
shares: {
id: "#{status.uri}/shares",
type: 'Collection',
totalItems: reblogs,
},
},
}.with_indifferent_access
end
shared_examples 'updates counts' do
it 'updates the reblog count' do
expect(status.untrusted_reblogs_count).to eq reblogs
end
it 'updates the favourites count' do
expect(status.untrusted_favourites_count).to eq favourites
end
end
context 'with an implicit update' do
before do
status.update!(uri: ActivityPub::TagManager.instance.uri_for(status))
subject.perform
end
it_behaves_like 'updates counts'
end
context 'with an explicit update' do
let(:favourites) { 150 }
let(:reblogs) { 200 }
let(:updated) { Time.now.utc.iso8601 }
before do
status.update!(uri: ActivityPub::TagManager.instance.uri_for(status))
subject.perform
end
it_behaves_like 'updates counts'
end
end
end end
end end

View file

@ -164,6 +164,31 @@ RSpec.describe Status do
end end
end end
describe '#untrusted_reblogs_count' do
before do
alice.update(domain: 'example.com')
subject.status_stat.tap do |status_stat|
status_stat.untrusted_reblogs_count = 0
status_stat.save
end
subject.save
end
it 'is incremented by the number of reblogs' do
Fabricate(:status, account: bob, reblog: subject)
Fabricate(:status, account: alice, reblog: subject)
expect(subject.untrusted_reblogs_count).to eq 2
end
it 'is decremented when reblog is removed' do
reblog = Fabricate(:status, account: bob, reblog: subject)
expect(subject.untrusted_reblogs_count).to eq 1
reblog.destroy
expect(subject.untrusted_reblogs_count).to eq 0
end
end
describe '#replies_count' do describe '#replies_count' do
it 'is the number of replies' do it 'is the number of replies' do
Fabricate(:status, account: bob, thread: subject) Fabricate(:status, account: bob, thread: subject)
@ -194,6 +219,31 @@ RSpec.describe Status do
end end
end end
describe '#untrusted_favourites_count' do
before do
alice.update(domain: 'example.com')
subject.status_stat.tap do |status_stat|
status_stat.untrusted_favourites_count = 0
status_stat.save
end
subject.save
end
it 'is incremented by favorites' do
Fabricate(:favourite, account: bob, status: subject)
Fabricate(:favourite, account: alice, status: subject)
expect(subject.untrusted_favourites_count).to eq 2
end
it 'is decremented when favourite is removed' do
favourite = Fabricate(:favourite, account: bob, status: subject)
expect(subject.untrusted_favourites_count).to eq 1
favourite.destroy
expect(subject.untrusted_favourites_count).to eq 0
end
end
describe '#proper' do describe '#proper' do
it 'is itself for original statuses' do it 'is itself for original statuses' do
expect(subject.proper).to eq subject expect(subject.proper).to eq subject

View file

@ -39,6 +39,42 @@ RSpec.describe 'API V1 Trends Statuses' do
end end
Trends::Statuses.new(threshold: 1, decay_threshold: -1).refresh Trends::Statuses.new(threshold: 1, decay_threshold: -1).refresh
end end
context 'with a comically inflated external interactions count' do
def prepare_fake_trends
fake_remote_account = Fabricate(:account, domain: 'other.com')
fake_status = Fabricate(:status, account: fake_remote_account, text: 'I am a big faker', trendable: true, language: 'en')
fake_status.status_stat.tap do |status_stat|
status_stat.reblogs_count = 0
status_stat.favourites_count = 0
status_stat.untrusted_reblogs_count = 1_000_000_000
status_stat.untrusted_favourites_count = 1_000_000_000
status_stat.save
end
real_remote_account = Fabricate(:account, domain: 'other.com')
real_status = Fabricate(:status, account: real_remote_account, text: 'I make real friends online', trendable: true, language: 'en')
real_status.status_stat.tap do |status_stat|
status_stat.reblogs_count = 10
status_stat.favourites_count = 10
status_stat.untrusted_reblogs_count = 10
status_stat.untrusted_favourites_count = 10
status_stat.save
end
Trends.statuses.add(fake_status, 100)
Trends.statuses.add(real_status, 101)
Trends::Statuses.new(threshold: 1, decay_threshold: 1).refresh
end
it 'ignores the feeble attempts at deception' do
prepare_fake_trends
stub_const('Api::BaseController::DEFAULT_STATUSES_LIMIT', 10)
get '/api/v1/trends/statuses'
expect(response).to have_http_status(200)
expect(response.parsed_body.length).to eq(1)
expect(response.parsed_body[0]['content']).to eq('I make real friends online')
end
end
end end
end end
end end

View file

@ -0,0 +1,55 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::StatusSerializer do
subject do
serialized_record_json(
status,
described_class,
options: {
scope: current_user,
scope_name: :current_user,
}
)
end
let(:current_user) { Fabricate(:user) }
let(:alice) { Fabricate(:account, username: 'alice') }
let(:bob) { Fabricate(:account, username: 'bob', domain: 'other.com') }
let(:status) { Fabricate(:status, account: alice) }
context 'with a remote status' do
let(:status) { Fabricate(:status, account: bob) }
before do
status.status_stat.tap do |status_stat|
status_stat.reblogs_count = 10
status_stat.favourites_count = 20
status_stat.save
end
end
context 'with only trusted counts' do
it 'shows the trusted counts' do
expect(subject['reblogs_count']).to eq(10)
expect(subject['favourites_count']).to eq(20)
end
end
context 'with untrusted counts' do
before do
status.status_stat.tap do |status_stat|
status_stat.untrusted_reblogs_count = 30
status_stat.untrusted_favourites_count = 40
status_stat.save
end
end
it 'shows the untrusted counts' do
expect(subject['reblogs_count']).to eq(30)
expect(subject['favourites_count']).to eq(40)
end
end
end
end

View file

@ -34,8 +34,8 @@ RSpec.configure do |config|
end end
end end
def serialized_record_json(record, serializer, adapter: nil) def serialized_record_json(record, serializer, adapter: nil, options: {})
options = { serializer: serializer } options[:serializer] = serializer
options[:adapter] = adapter if adapter.present? options[:adapter] = adapter if adapter.present?
JSON.parse( JSON.parse(
ActiveModelSerializers::SerializableResource.new( ActiveModelSerializers::SerializableResource.new(