Compare commits

..

6 Commits

Author SHA1 Message Date
Claire
6d15ba8311 [WiP] Handle quote revocations 2025-04-04 16:41:59 +02:00
Claire
009b877023 [WiP] Process incoming quote posts 2025-04-04 16:07:17 +02:00
Claire
ee49cbd492 Add quote_approval_policy to Status 2025-04-04 12:41:47 +02:00
Claire
223d7ced76 [WiP] Add notifications for quoted posts 2025-04-04 12:41:47 +02:00
Claire
1a47c4f265 Add QuoteSerializer 2025-04-04 12:41:47 +02:00
Claire
9ff21c72f5 Add Quote model 2025-04-04 12:41:47 +02:00
24 changed files with 563 additions and 70 deletions

View File

@ -94,7 +94,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.3.2)
aws-partitions (1.1080.0)
aws-partitions (1.1066.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -126,7 +126,7 @@ GEM
blurhash (0.1.8)
bootsnap (1.18.4)
msgpack (~> 1.2)
brakeman (7.0.1)
brakeman (7.0.0)
racc
browser (6.2.0)
brpoplpush-redis_script (0.1.3)
@ -194,7 +194,7 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.6.1)
diff-lcs (1.6.0)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
@ -266,10 +266,10 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (4.30.2)
google-protobuf (4.30.1)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.19.0)
googleapis-common-protos-types (1.18.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
@ -426,7 +426,7 @@ GEM
mime-types (3.6.2)
logger
mime-types-data (~> 3.2015)
mime-types-data (3.2025.0402)
mime-types-data (3.2025.0318)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.5)
@ -688,7 +688,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.13.1)
rdoc (6.12.0)
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (4.8.1)
@ -840,7 +840,7 @@ GEM
stoplight (4.1.1)
redlock (~> 1.0)
stringio (3.1.6)
strong_migrations (2.3.0)
strong_migrations (2.2.1)
activerecord (>= 7)
swd (2.0.3)
activesupport (>= 3)
@ -851,7 +851,7 @@ GEM
temple (0.10.3)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
terrapin (1.1.0)
terrapin (1.0.1)
climate_control
test-prof (1.4.4)
thor (1.3.2)
@ -1085,4 +1085,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.7
2.6.6

View File

@ -80,7 +80,7 @@
"admin.dashboard.retention.cohort_size": "Noví uživatelé",
"admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny",
"admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by jejich uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.title": "Shrnutí dopadu",
"alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.",
"alert.rate_limited.title": "Spojení omezena",
@ -102,7 +102,7 @@
"annual_report.summary.archetype.replier": "Sociální motýlek",
"annual_report.summary.followers.followers": "sledujících",
"annual_report.summary.followers.total": "{count} celkem",
"annual_report.summary.here_it_is": "Zde je tvůj rok {year} v přehledu:",
"annual_report.summary.here_it_is": "Zde je tvůj {year} v přehledu:",
"annual_report.summary.highlighted_post.by_favourites": "nejvíce oblíbený příspěvek",
"annual_report.summary.highlighted_post.by_reblogs": "nejvíce boostovaný příspěvek",
"annual_report.summary.highlighted_post.by_replies": "příspěvek s nejvíce odpověďmi",
@ -268,7 +268,7 @@
"domain_pill.activitypub_like_language": "ActivityPub je jako jazyk, kterým Mastodon mluví s jinými sociálními sítěmi.",
"domain_pill.server": "Server",
"domain_pill.their_handle": "Handle:",
"domain_pill.their_server": "Jejich digitální domov, kde žijí všechny jejich příspěvky.",
"domain_pill.their_server": "Jejich digitální domov, kde žijí jejich všechny příspěvky.",
"domain_pill.their_username": "Jejich jedinečný identifikátor na jejich serveru. Je možné, že na jiných serverech jsou uživatelé se stejným uživatelským jménem.",
"domain_pill.username": "Uživatelské jméno",
"domain_pill.whats_in_a_handle": "Co obsahuje handle?",
@ -573,7 +573,7 @@
"notification.label.private_reply": "Privátní odpověď",
"notification.label.reply": "Odpověď",
"notification.mention": "Zmínka",
"notification.mentioned_you": "{name} vás zmínil*a",
"notification.mentioned_you": "{name} vás zmínil",
"notification.moderation-warning.learn_more": "Zjistit více",
"notification.moderation_warning": "Obdrželi jste varování od moderátorů",
"notification.moderation_warning.action_delete_statuses": "Některé z vašich příspěvků byly odstraněny.",

View File

@ -248,7 +248,6 @@
"filter_modal.select_filter.search": "Nadi neɣ snulfu-d",
"filter_modal.select_filter.title": "Sizdeg tassufeɣt-a",
"filter_modal.title.status": "Sizdeg tassufeɣt",
"filtered_notifications_banner.title": "Ilɣa yettwasizdgen",
"firehose.all": "Akk",
"firehose.local": "Deg uqeddac-ayi",
"firehose.remote": "Iqeddacen nniḍen",
@ -427,7 +426,6 @@
"notification_requests.edit_selection": "Ẓreg",
"notification_requests.exit_selection": "Immed",
"notification_requests.notifications_from": "Alɣuten sɣur {name}",
"notification_requests.title": "Ilɣa yettwasizdgen",
"notifications.clear": "Sfeḍ alɣuten",
"notifications.clear_confirmation": "Tebɣiḍ s tidet ad tekkseḍ akk alɣuten-inek·em i lebda?",
"notifications.column_settings.admin.report": "Ineqqisen imaynuten:",

View File

@ -65,7 +65,6 @@
"account.statuses_counter": "{count, plural, one {{counter} gönderi} other {{counter} gönderi}}",
"account.unblock": "@{name} adlı kişinin engelini kaldır",
"account.unblock_domain": "{domain} alan adının engelini kaldır",
"account.unblock_domain_short": "Engeli kaldır",
"account.unblock_short": "Engeli kaldır",
"account.unendorse": "Profilimde öne çıkarma",
"account.unfollow": "Takibi bırak",

View File

@ -1,34 +1,21 @@
{
"about.blocks": "باشقۇرۇلىدىغان مۇلازىمېتىر",
"about.contact": "ئالاقە:",
"about.disclaimer": "Mastodon ھەقسىز، ئوچۇق كودلۇق يۇمشاق دېتال تاۋار ماركىسى Mastodon gGmbH غا تەۋە.",
"about.domain_blocks.no_reason_available": "سەۋەبىنى ئىشلەتكىلى بولمايدۇ",
"account.badges.bot": "ماشىنا ئادەم",
"account.cancel_follow_request": "ئەگىشىش ئىلتىماسىدىن ۋاز كەچ",
"account.posts": "يازما",
"account.posts_with_replies": "يازما ۋە ئىنكاس",
"account.report": "@{name} نى پاش قىل",
"about.blocks": "ئوتتۇراھال مۇلازىمېتىر",
"about.contact": "ئالاقىلاشقۇچى:",
"account.badges.bot": "Bot",
"account.cancel_follow_request": "Withdraw follow request",
"account.posts": "Toots",
"account.posts_with_replies": "Toots and replies",
"account.requested": "Awaiting approval",
"account_note.placeholder": "چېكىلسە ئىزاھات قوشىدۇ",
"column.pins": "چوققىلانغان يازما",
"community.column_settings.media_only": "ۋاسىتەلا",
"account_note.placeholder": "Click to add a note",
"column.pins": "Pinned toot",
"community.column_settings.media_only": "Media only",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
"compose_form.placeholder": "What is on your mind?",
"compose_form.publish_form": "يېڭى يازما",
"compose_form.reply": "جاۋاب",
"compose_form.save_changes": "يېڭىلا",
"compose_form.spoiler.marked": "مەزمۇن ئاگاھلاندۇرۇشىنى چىقىرىۋەت",
"compose_form.publish_form": "Publish",
"compose_form.spoiler.marked": "Text is hidden behind warning",
"compose_form.spoiler.unmarked": "Text is not hidden",
"compose_form.spoiler_placeholder": "مەزمۇن ئاگاھلاندۇرۇشى (تاللاشچان)",
"confirmation_modal.cancel": "ۋاز كەچ",
"confirmations.block.confirm": "توس",
"confirmations.delete.message": "بۇ يازمىنى راستىنلا ئۆچۈرەمسىز؟",
"confirmations.delete.title": "يازما ئۆچۈرەمدۇ؟",
"confirmations.delete_list.confirm": "ئۆچۈر",
"confirmations.delete_list.message": "بۇ تىزىمنى راستتىنلا مەڭگۈلۈك ئۆچۈرەمسىز؟",
"confirmations.delete_list.title": "تىزىمنى ئۆچۈرەمدۇ؟",
"confirmations.discard_edit_media.confirm": "تاشلىۋەت",
"confirmations.delete.message": "Are you sure you want to delete this status?",
"embed.instructions": "Embed this status on your website by copying the code below.",
"empty_column.account_timeline": "No toots here!",
"empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",

View File

@ -45,6 +45,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@quote = nil
@quote_uri = nil
process_status_params
process_tags
@ -55,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
attach_tags(@status)
attach_mentions(@status)
attach_counts(@status)
attach_quote(@status)
end
resolve_thread(@status)
@ -189,6 +192,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_quote(status)
return if @quote.nil?
@quote.status = status
ActivityPub::VerifyQuoteService.new.call(@quote, expected_quoted_uri: @quote_uri)
@quote.save
end
def process_tags
return if @object['tag'].nil?
@ -199,6 +210,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_mention tag
elsif equals_or_includes?(tag['type'], 'Emoji')
process_emoji tag
elsif equals_or_includes?(tag['type'], 'Link')
process_link tag
end
end
end
@ -246,6 +259,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_link(tag)
return if tag['href'].blank? || @quote.present?
if tag['mediaType'] == 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' && tag['rel'] == 'https://misskey-hub.net/ns#_misskey_quote'
@quote = Quote.new(account: @account, approval_uri: tag['approvedBy'])
@quote_uri = tag['href']
# TODO: error handling
@quote.status = ActivityPub::TagManager.instance.uri_to_resource(tag['href'], Status)
@quote.status ||= ActivityPub::FetchRemoteStatusService.new.call(tag['href'], on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
end
end
def process_attachments
return [] if @object['attachment'].nil?

View File

@ -5,7 +5,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
if @account.uri == object_uri
delete_person
else
delete_note
delete_object
end
end
@ -17,7 +17,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end
end
def delete_note
def delete_object
return if object_uri.nil?
with_redis_lock("delete_status_in_progress:#{object_uri}", raise_on_failure: false) do
@ -32,21 +32,38 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
Tombstone.find_or_create_by(uri: object_uri, account: @account)
end
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
delete_now!
case @object['type']
when 'QuoteAuthorization'
revoke_quote
when 'Note', 'Question'
delete_status
else
delete_status || revoke_quote
end
end
end
def delete_status
@status = Status.find_by(uri: object_uri, account: @account)
@status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
return if @status.nil?
forwarder.forward! if forwarder.forwardable?
RemoveStatusService.new.call(@status, redraft: false)
true
end
def revoke_quote
@quote = Quote.find_by(approval_uri: object_uri, quoted_account: @account)
return if @quote.nil?
ActivityPub::Forwarder.new(@account, @json, @quote.status).forward!
@quote.reject!
end
def forwarder
@forwarder ||= ActivityPub::Forwarder.new(@account, @json, @status)
end
def delete_now!
RemoveStatusService.new.call(@status, redraft: false)
end
end

View File

@ -43,6 +43,9 @@ class Notification < ApplicationRecord
reblog: {
filterable: true,
}.freeze,
quote: {
filterable: true,
}.freeze,
follow: {
filterable: true,
}.freeze,
@ -80,6 +83,7 @@ class Notification < ApplicationRecord
TARGET_STATUS_INCLUDES_BY_TYPE = {
status: :status,
reblog: [status: :reblog],
quote: [status: :quoted_status],
mention: [mention: :status],
favourite: [favourite: :status],
poll: [poll: :status],
@ -118,6 +122,8 @@ class Notification < ApplicationRecord
status
when :reblog
status&.reblog
when :quote
status&.quoted_status
when :favourite
favourite&.status
when :mention

51
app/models/quote.rb Normal file
View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: quotes
#
# id :bigint(8) not null, primary key
# activity_uri :string
# approval_uri :string
# state :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8)
# quoted_account_id :bigint(8)
# quoted_status_id :bigint(8)
# status_id :bigint(8) not null
#
class Quote < ApplicationRecord
enum :state,
{ pending: 0, accepted: 1, rejected: 2, revoked: 3 },
validate: true
belongs_to :status
belongs_to :quoted_status, class_name: 'Status', optional: true
belongs_to :account
belongs_to :quoted_account, class_name: 'Account', optional: true
before_validation :set_accounts!
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
def accept!
update(state: :accepted)
end
def reject!
if accepted?
update(state: :revoked)
elsif !revoked?
update(state: :rejected)
end
end
private
def set_accounts!
self.account = status.account
self.quoted_account = quoted_status&.account
end
end

View File

@ -44,6 +44,13 @@ class Status < ApplicationRecord
MEDIA_ATTACHMENTS_LIMIT = 4
QUOTE_APPROVAL_POLICY_FLAGS = {
public: (1 << 0),
followers: (1 << 1),
followed: (1 << 2),
unknown: (1 << 3),
}.freeze
rate_limit by: :account, family: :statuses
self.discard_column = :deleted_at
@ -93,6 +100,7 @@ class Status < ApplicationRecord
has_one :status_stat, inverse_of: :status, dependent: nil
has_one :poll, inverse_of: :status, dependent: :destroy
has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil
has_one :quote, inverse_of: :status, dependent: :destroy
validates :uri, uniqueness: true, presence: true, unless: :local?
validates :text, presence: true, unless: -> { with_media? || reblog? }
@ -154,6 +162,7 @@ class Status < ApplicationRecord
:status_stat,
:tags,
:preloadable_poll,
quote: { status: { account: [:account_stat, user: :role] } },
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
@ -164,6 +173,7 @@ class Status < ApplicationRecord
:conversation,
:status_stat,
:preloadable_poll,
:quote,
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,

View File

@ -21,7 +21,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
end
def status_type?
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
[:favourite, :reblog, :quote, :status, :mention, :poll, :update].include?(object.type)
end
def report_type?

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class REST::QuoteSerializer < ActiveModel::Serializer
attributes :state
has_one :quoted_status, serializer: REST::StatusSerializer
end

View File

@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer

View File

@ -0,0 +1,91 @@
# frozen_string_literal: true
class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper
def call(quote, expected_quoted_uri: nil, prefetched_body: nil)
@quote = quote
return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
if @json.nil?
# It is possible the proof isn't dereferenceable yet, so only reject if already accepted
quote.reject! unless @quote.pending?
return
end
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
return unless matching_type? && matching_quote_uri?
# Opportunistically import embedded posts if needed
return if import_quoted_post_if_needed!(expected_quoted_uri) && fast_track_approval!
return unless matching_quoted_post? && matching_quoted_author?
quote.accept!
end
private
# FEP-044f defines rules that don't require the approval flow
def fast_track_approval!
return false if @quote.quoted_status_id.blank?
# Always allow someone to quote themselves
if @quote.account_id == @quote.quoted_account_id
@quote.accept!
true
end
# Always allow someone to quote posts in which they are mentioned
if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
@quote.accept!
true
else
false
end
end
def fetch_approval_object(uri, prefetched_body: nil)
if prefetched_body.nil?
fetch_resource(uri, true, @quote.account, raise_on_error: :temporary)
else
body_to_json(prefetched_body, compare_id: uri)
end
end
def matching_type?
supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization')
end
def matching_quote_uri?
ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
end
def import_quoted_post_if_needed!(uri)
return if uri.nil? || @quote.quoted_status_id.present?
return if value_or_id(@json['interactionTarget']) == uri
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
# TODO: import embedded post
if status.present?
quote.update(status: status)
true
else
false
end
end
def matching_quoted_post?
return false if @quote.quoted_status_id.blank?
ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget'])
end
def matching_quoted_author?
ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo'])
end
end

View File

@ -459,9 +459,7 @@ lv:
create: Pievienot domēnu
resolve: Atrisināt domēnu
title: Liegt jaunu e-pasta domēnu
no_email_domain_block_selected: Neviens e-pasta domēna bloks netika mainīts, jo neviens netika atlasīts
not_permitted: Nav atļauta
resolved_dns_records_hint_html: Domēna vārds saistās ar zemāk norādītajiem MX domēniem, kuri beigās ir atbildīgi par e-pasta pieņemšana. MX domēna liegšana liegs reģistrēšanos no jebkuras e-pasta adreses, kas izmanto to pašu MX domēnu, pat ja redzamais domēna vārds ir atšķirīgs. <strong>Jāuzmanās, lai neliegtu galvenos e-pasta pakalpojuma sniedzējus.</strong>
resolved_through_html: Atrisināts, izmantojot %{domain}
title: Bloķētie e-pasta domēni
export_domain_allows:
@ -479,13 +477,6 @@ lv:
new:
title: Importēt bloķētos domēnus
no_file: Nav atlasīts neviens fails
fasp:
debug:
callbacks:
created_at: Izveidots
delete: Izdzēst
ip: IP adrese
request_body: Pieprasījuma saturs
follow_recommendations:
description_html: "<strong>Sekošanas ieteikumi palīdz jauniem lietotājiem ātri arast saistošu saturu</strong>. Kad lietotājs nav pietiekami mijiedarbojies ar citiem, lai veidotos pielāgoti sekošanas iteikumi, tiek ieteikti šie konti. Tie tiek pārskaitļoti ik dienas, izmantojot kontu, kuriem ir augstākās nesenās iesaistīšanās un lielākais vietējo sekotāju skaits norādītajā valodā."
language: Valodai

View File

@ -84,7 +84,7 @@ de:
backups_retention_period: Nutzer*innen haben die Möglichkeit, Archive ihrer Beiträge zu erstellen, die sie später herunterladen können. Wenn ein positiver Wert gesetzt ist, werden diese Archive nach der festgelegten Anzahl von Tagen automatisch aus deinem Speicher gelöscht.
bootstrap_timeline_accounts: Diese Konten werden bei den Follower-Empfehlungen für neu registrierte Nutzer*innen oben angeheftet.
closed_registrations_message: Wird angezeigt, wenn Registrierungen deaktiviert sind
content_cache_retention_period: Sämtliche Beiträge von anderen Servern (einschließlich geteilte Beiträge und Antworten) werden, unabhängig von der Interaktion der lokalen Nutzer*innen mit diesen Beiträgen, nach der festgelegten Anzahl von Tagen gelöscht. Das betrifft auch Beiträge, die von lokalen Nutzer*innen favorisiert oder als Lesezeichen gespeichert wurden. Private Erwähnungen zwischen Nutzer*innen von verschiedenen Servern werden ebenfalls verloren gehen und können nicht wiederhergestellt werden. Diese Option richtet sich ausschließlich an Server mit speziellen Zwecken und wird die allgemeine Nutzungserfahrung beeinträchtigen, wenn sie für den allgemeinen Gebrauch aktiviert ist.
content_cache_retention_period: Sämtliche Beiträge von anderen Servern (einschließlich geteilte Beiträge und Antworten) werden, unabhängig von der Interaktion der lokalen Nutzer*innen mit diesen Beiträgen, nach der festgelegten Anzahl von Tagen gelöscht. Das betrifft auch Beiträge, die von lokalen Nutzer*innen favorisiert oder als Lesezeichen gespeichert wurden. Private Erwähnungen zwischen Nutzer*innen von verschiedenen Servern werden ebenfalls verloren gehen und können nicht wiederhergestellt werden. Das Verwenden dieser Option richtet sich ausschließlich an Server für spezielle Zwecke und wird die allgemeine Nutzungserfahrung beeinträchtigen, wenn sie für den allgemeinen Gebrauch aktiviert ist.
custom_css: Du kannst benutzerdefinierte Stile auf die Web-Version von Mastodon anwenden.
favicon: WEBP, PNG, GIF oder JPG. Überschreibt das Standard-Mastodon-Favicon mit einem eigenen Symbol.
mascot: Überschreibt die Abbildung in der erweiterten Weboberfläche.

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
class CreateQuotes < ActiveRecord::Migration[8.0]
def change
create_table :quotes do |t|
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false
t.belongs_to :status, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
t.belongs_to :quoted_status, foreign_key: { to_table: :statuses, on_delete: :nullify }, null: true
t.belongs_to :quoted_account, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true
t.integer :state, null: false, default: 0
t.string :approval_uri, index: { where: 'approval_uri IS NOT NULL' }
t.string :activity_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' }
t.timestamps
end
add_index :quotes, [:account_id, :quoted_account_id]
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddQuoteApprovalPolicyToStatuses < ActiveRecord::Migration[8.0]
def change
add_column :statuses, :quote_approval_policy, :integer
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
ActiveRecord::Schema[8.0].define(version: 2025_04_04_095029) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -884,6 +884,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
t.string "url"
end
create_table "quotes", force: :cascade do |t|
t.bigint "account_id"
t.bigint "status_id", null: false
t.bigint "quoted_status_id"
t.bigint "quoted_account_id"
t.integer "state", default: 0, null: false
t.string "approval_uri"
t.string "activity_uri"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "quoted_account_id"], name: "index_quotes_on_account_id_and_quoted_account_id"
t.index ["activity_uri"], name: "index_quotes_on_activity_uri", unique: true, where: "(activity_uri IS NOT NULL)"
t.index ["approval_uri"], name: "index_quotes_on_approval_uri", where: "(approval_uri IS NOT NULL)"
t.index ["quoted_account_id"], name: "index_quotes_on_quoted_account_id"
t.index ["quoted_status_id"], name: "index_quotes_on_quoted_status_id"
t.index ["status_id"], name: "index_quotes_on_status_id", unique: true
end
create_table "relationship_severance_events", force: :cascade do |t|
t.integer "type", null: false
t.string "target_name", null: false
@ -1080,6 +1098,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
t.boolean "trendable"
t.bigint "ordered_media_attachment_ids", array: true
t.datetime "fetched_replies_at"
t.integer "quote_approval_policy"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
@ -1364,6 +1383,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
add_foreign_key "polls", "statuses", on_delete: :cascade
add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
add_foreign_key "quotes", "accounts", column: "quoted_account_id", on_delete: :nullify
add_foreign_key "quotes", "accounts", on_delete: :cascade
add_foreign_key "quotes", "statuses", column: "quoted_status_id", on_delete: :nullify
add_foreign_key "quotes", "statuses", on_delete: :cascade
add_foreign_key "report_notes", "accounts", on_delete: :cascade
add_foreign_key "report_notes", "reports", on_delete: :cascade
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:quote) do
status { Fabricate.build(:status) }
quoted_status { Fabricate.build(:status) }
state :pending
end

View File

@ -77,4 +77,61 @@ RSpec.describe ActivityPub::Activity::Delete do
end
end
end
context 'when the deleted object is an account' do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: ActivityPub::TagManager.instance.uri_for(sender),
signature: 'foo',
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
let(:service) { instance_double(DeleteAccountService, call: true) }
before do
allow(DeleteAccountService).to receive(:new).and_return(service)
end
it 'calls the account deletion service' do
subject.perform
expect(service)
.to have_received(:call).with(sender, { reserve_username: false, skip_activitypub: true })
end
end
end
context 'when the deleted object is a quote authorization' do
let(:quoter) { Fabricate(:account, domain: 'b.example.com') }
let(:status) { Fabricate(:status, account: quoter) }
let(:quoted_status) { Fabricate(:status, account: sender, uri: 'https://example.com/statuses/1234') }
let!(:quote) { Fabricate(:quote, approval_uri: 'https://example.com/approvals/1234', state: :accepted, status: status, quoted_status: quoted_status) }
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Delete',
actor: ActivityPub::TagManager.instance.uri_for(sender),
object: quote.approval_uri,
signature: 'foo',
}.with_indifferent_access
end
describe '#perform' do
subject { described_class.new(json, sender) }
it 'revokes the authorization' do
expect { subject.perform }
.to change { quote.reload.state }.to('revoked')
end
end
end
end

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::QuoteSerializer do
subject do
serialized_record_json(
quote,
described_class,
options: {
scope: current_user,
scope_name: :current_user,
}
)
end
let(:current_user) { Fabricate(:user) }
let(:quote) { Fabricate(:quote) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: be_a(Hash),
state: 'pending'
)
end
end

View File

@ -0,0 +1,169 @@
# frozen_string_literal: true
require 'rails_helper'
# TODO: high-level tests
# - fetch unapproved post with a posteriori approval
# - fetch implicitly approved post
# - handle explicit revocation (`Delete`)
# - handle post getting unapproved (`Update`)
RSpec.describe ActivityPub::VerifyQuoteService do
subject { described_class.new }
let(:account) { Fabricate(:account, domain: 'a.example.com') }
let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:status) { Fabricate(:status, account: account) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) }
context 'with an approval URI' do
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
let(:approval_type) { 'QuoteAuthorization' }
let(:approval_id) { approval_uri }
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
let(:json) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
toot: 'http://joinmastodon.org/ns#',
QuoteAuthorization: 'toot:QuoteAuthorization',
gts: 'https://gotosocial.org/ns#',
interactionPolicy: {
'@id': 'gts:interactionPolicy',
'@type': '@id',
},
interactingObject: {
'@id': 'gts:interactingObject',
'@type': '@id',
},
interactionTarget: {
'@id': 'gts:interactionTarget',
'@type': '@id',
},
},
],
type: approval_type,
id: approval_id,
attributedTo: approval_attributed_to,
interactingObject: approval_interacting_object,
interactionTarget: approval_interaction_target,
}.with_indifferent_access
end
before do
stub_request(:get, approval_uri)
.to_return(status: 200, body: Oj.dump(json), headers: { 'Content-Type': 'application/activity+json' })
end
context 'with a valid activity for already-fetched posts' do
it 'updates the status' do
expect { subject.call(quote) }
.to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri))
.to have_been_made.once
end
end
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
it 'updates the status without fetching the activity' do
expect { subject.call(quote, prefetched_body: Oj.dump(json)) }
.to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri))
.to_not have_been_made
end
end
context 'with an unverifiable approval' do
let(:approval_uri) { 'https://evil.com/approvals/1234' }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'with a mismatched ID' do
let(:approval_id) { 'https://evil.com/approvals/1234' }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'with an approval from the wrong account' do
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'with an approval for the wrong quoted post' do
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'with an approval for the wrong quote post' do
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'with an approval of the wrong type' do
let(:approval_type) { 'ReplyAuthorization' }
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
end
context 'with fast-track authorizations' do
let(:approval_uri) { nil }
context 'without any fast-track condition' do
it 'does not update the status' do
expect { subject.call(quote) }
.to_not change(quote, :state)
end
end
context 'when the account and the quoted account are the same' do
let(:quoted_account) { account }
it 'updates the status' do
expect { subject.call(quote) }
.to change(quote, :state).to('accepted')
end
end
context 'when the account is mentioned by the quoted post' do
before do
quoted_status.mentions << Mention.new(account: account)
end
it 'updates the status' do
expect { subject.call(quote) }
.to change(quote, :state).to('accepted')
end
end
end
end

View File

@ -15738,8 +15738,8 @@ __metadata:
linkType: hard
"sass@npm:^1.62.1":
version: 1.86.3
resolution: "sass@npm:1.86.3"
version: 1.86.2
resolution: "sass@npm:1.86.2"
dependencies:
"@parcel/watcher": "npm:^2.4.1"
chokidar: "npm:^4.0.0"
@ -15750,7 +15750,7 @@ __metadata:
optional: true
bin:
sass: sass.js
checksum: 10c0/ba819a0828f732adf7a94cd8ca017bce92bc299ffb878836ed1da80a30612bfbbf56a5e42d6dff3ad80d919c2025afb42948fc7b54a7bc61a9a2d58e1e0c558a
checksum: 10c0/fe40b63a19e867f460369d495bcdc979598c0b105c9d52164a7e9cc4e12ab91c27337702a1b58575f7da8fd08256b85fd2f34cc8275eb7c57699bfc5029f54a3
languageName: node
linkType: hard