Merge pull request #3048 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to b33f9ea603
This commit is contained in:
Claire 2025-04-21 15:46:53 +02:00 committed by GitHub
commit d7566c8fa4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1796 additions and 44 deletions

View file

@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.2"
ARG RUBY_VERSION="3.4.3"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22"

View file

@ -13,6 +13,7 @@
- [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md)
- [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md)
- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts
## ActivityPub in Mastodon

View file

@ -160,7 +160,7 @@ GEM
cocoon (1.2.15)
color_diff (0.1)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
connection_pool (2.5.1)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
@ -495,6 +495,8 @@ GEM
opentelemetry-common (~> 0.20)
opentelemetry-sdk (~> 1.2)
opentelemetry-semantic_conventions
opentelemetry-helpers-sql (0.1.1)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation (0.3.0)
opentelemetry-common (~> 0.21)
opentelemetry-instrumentation-action_mailer (0.4.0)
@ -548,8 +550,9 @@ GEM
opentelemetry-instrumentation-net_http (0.23.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-pg (0.30.0)
opentelemetry-instrumentation-pg (0.30.1)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (0.26.0)

View file

@ -381,6 +381,8 @@
"generic.saved": "Wedi'i Gadw",
"getting_started.heading": "Dechrau",
"hashtag.admin_moderation": "Agor rhyngwyneb cymedroli #{name}",
"hashtag.browse": "Pori postiadau yn #{hashtag}",
"hashtag.browse_from_account": "Pori postiadau gan @{name} yn #{hashtag}",
"hashtag.column_header.tag_mode.all": "a {additional}",
"hashtag.column_header.tag_mode.any": "neu {additional}",
"hashtag.column_header.tag_mode.none": "heb {additional}",
@ -394,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {postiad {counter}} other {postiad {counter}}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} postiad} other {{counter} postiad}} heddiw",
"hashtag.follow": "Dilyn hashnod",
"hashtag.mute": "Tewi #{hashtag}",
"hashtag.unfollow": "Dad-ddilyn hashnod",
"hashtags.and_other": "…a {count, plural, other {# arall}}",
"hints.profiles.followers_may_be_missing": "Mae'n bosibl bod dilynwyr y proffil hwn ar goll.",

View file

@ -27,6 +27,9 @@
"account.edit_profile": "Επεξεργασία προφίλ",
"account.enable_notifications": "Ειδοποίησέ με όταν δημοσιεύει ο @{name}",
"account.endorse": "Προβολή στο προφίλ",
"account.featured": "Προτεινόμενα",
"account.featured.hashtags": "Ετικέτες",
"account.featured.posts": "Αναρτήσεις",
"account.featured_tags.last_status_at": "Τελευταία ανάρτηση στις {date}",
"account.featured_tags.last_status_never": "Καμία ανάρτηση",
"account.follow": "Ακολούθησε",
@ -64,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}}",
"account.unblock": "Άρση αποκλεισμού @{name}",
"account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
"account.unblock_domain_short": "Άρση αποκλ.",
"account.unblock_short": "Άρση αποκλεισμού",
"account.unendorse": "Να μην παρέχεται στο προφίλ",
"account.unfollow": "Άρση ακολούθησης",
@ -292,6 +296,7 @@
"emoji_button.search_results": "Αποτελέσματα αναζήτησης",
"emoji_button.symbols": "Σύμβολα",
"emoji_button.travel": "Ταξίδια & Τοποθεσίες",
"empty_column.account_featured": "Αυτή η λίστα είναι κενή",
"empty_column.account_hides_collections": "Αυτός ο χρήστης έχει επιλέξει να μην καταστήσει αυτές τις πληροφορίες διαθέσιμες",
"empty_column.account_suspended": "Λογαριασμός σε αναστολή",
"empty_column.account_timeline": "Δεν έχει αναρτήσεις εδώ!",
@ -376,6 +381,8 @@
"generic.saved": "Αποθηκεύτηκε",
"getting_started.heading": "Ας ξεκινήσουμε",
"hashtag.admin_moderation": "Άνοιγμα διεπαφής συντονισμού για το #{name}",
"hashtag.browse": "Περιήγηση αναρτήσεων με #{hashtag}",
"hashtag.browse_from_account": "Περιήγηση αναρτήσεων από @{name} σε #{hashtag}",
"hashtag.column_header.tag_mode.all": "και {additional}",
"hashtag.column_header.tag_mode.any": "ή {additional}",
"hashtag.column_header.tag_mode.none": "χωρίς {additional}",
@ -389,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} ανάρτηση} other {{counter} αναρτήσεις}} σήμερα",
"hashtag.follow": "Παρακολούθηση ετικέτας",
"hashtag.mute": "Σίγαση #{hashtag}",
"hashtag.unfollow": "Διακοπή παρακολούθησης ετικέτας",
"hashtags.and_other": "…και {count, plural, one {}other {# ακόμη}}",
"hints.profiles.followers_may_be_missing": "Μπορεί να λείπουν ακόλουθοι για αυτό το προφίλ.",
@ -871,7 +879,9 @@
"subscribed_languages.target": "Αλλαγή εγγεγραμμένων γλωσσών για {target}",
"tabs_bar.home": "Αρχική",
"tabs_bar.notifications": "Ειδοποιήσεις",
"terms_of_service.effective_as_of": "Ενεργό από {date}",
"terms_of_service.title": "Όροι Παροχής Υπηρεσιών",
"terms_of_service.upcoming_changes_on": "Επερχόμενες αλλαγές στις {date}",
"time_remaining.days": "απομένουν {number, plural, one {# ημέρα} other {# ημέρες}}",
"time_remaining.hours": "απομένουν {number, plural, one {# ώρα} other {# ώρες}}",
"time_remaining.minutes": "απομένουν {number, plural, one {# λεπτό} other {# λεπτά}}",
@ -902,6 +912,12 @@
"video.expand": "Επέκταση βίντεο",
"video.fullscreen": "Πλήρης οθόνη",
"video.hide": "Απόκρυψη βίντεο",
"video.mute": "Σίγαση",
"video.pause": "Παύση",
"video.play": "Αναπαραγωγή"
"video.play": "Αναπαραγωγή",
"video.skip_backward": "Παράλειψη πίσω",
"video.skip_forward": "Παράλειψη εμπρός",
"video.unmute": "Άρση σίγασης",
"video.volume_down": "Μείωση έντασης",
"video.volume_up": "Αύξηση έντασης"
}

View file

@ -382,7 +382,7 @@
"getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones de @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}",

View file

@ -382,7 +382,7 @@
"getting_started.heading": "Primeros pasos",
"hashtag.admin_moderation": "Abrir interfaz de moderación para #{name}",
"hashtag.browse": "Explorar publicaciones en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones desde @{name} en #{hashtag}",
"hashtag.browse_from_account": "Explorar publicaciones de @{name} en #{hashtag}",
"hashtag.column_header.tag_mode.all": "y {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "sin {additional}",

View file

@ -381,6 +381,8 @@
"generic.saved": "Salvato",
"getting_started.heading": "Per iniziare",
"hashtag.admin_moderation": "Apri l'interfaccia di moderazione per #{name}",
"hashtag.browse": "Sfoglia i post con #{hashtag}",
"hashtag.browse_from_account": "Sfoglia i post da @{name} con #{hashtag}",
"hashtag.column_header.tag_mode.all": "e {additional}",
"hashtag.column_header.tag_mode.any": "o {additional}",
"hashtag.column_header.tag_mode.none": "senza {additional}",
@ -394,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural, one {{counter} post} other {{counter} post}}",
"hashtag.counter_by_uses_today": "{count, plural, one {{counter} post} other {{counter} post}} oggi",
"hashtag.follow": "Segui l'hashtag",
"hashtag.mute": "Silenzia #{hashtag}",
"hashtag.unfollow": "Smetti di seguire l'hashtag",
"hashtags.and_other": "…e {count, plural, other {# in più}}",
"hints.profiles.followers_may_be_missing": "I seguaci per questo profilo potrebbero essere mancanti.",

View file

@ -889,6 +889,8 @@
"video.expand": "Išplėsti vaizdo įrašą",
"video.fullscreen": "Visas ekranas",
"video.hide": "Slėpti vaizdo įrašą",
"video.mute": "Išjungti garsą",
"video.pause": "Pristabdyti",
"video.play": "Leisti"
"video.play": "Leisti",
"video.skip_backward": "Praleisti atgal"
}

View file

@ -58,7 +58,7 @@
"account.no_bio": "Geen beschrijving opgegeven.",
"account.open_original_page": "Originele pagina openen",
"account.posts": "Berichten",
"account.posts_with_replies": "Berichten en reacties",
"account.posts_with_replies": "Reacties",
"account.report": "@{name} rapporteren",
"account.requested": "Wachten op goedkeuring. Klik om het volgverzoek te annuleren",
"account.requested_follow": "{name} wil je graag volgen",

View file

@ -36,9 +36,9 @@
"account.follow_back": "Fylg tilbake",
"account.followers": "Fylgjarar",
"account.followers.empty": "Ingen fylgjer denne brukaren enno.",
"account.followers_counter": "{count, plural, one {{counter} følgjar} other {{counter} følgjarar}}",
"account.followers_counter": "{count, plural, one {{counter} fylgjar} other {{counter} fylgjarar}}",
"account.following": "Fylgjer",
"account.following_counter": "{count, plural, one {{counter} følgjer} other {{counter} følgjer}}",
"account.following_counter": "{count, plural, one {{counter} fylgjar} other {{counter} fylgjarar}}",
"account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
"account.go_to_profile": "Gå til profil",
"account.hide_reblogs": "Gøym framhevingar frå @{name}",
@ -67,6 +67,7 @@
"account.statuses_counter": "{count, plural, one {{counter} innlegg} other {{counter} innlegg}}",
"account.unblock": "Stopp blokkering av @{name}",
"account.unblock_domain": "Stopp blokkering av domenet {domain}",
"account.unblock_domain_short": "Fjern blokkering",
"account.unblock_short": "Stopp blokkering",
"account.unendorse": "Ikkje vis på profil",
"account.unfollow": "Slutt å fylgja",
@ -117,7 +118,7 @@
"annual_report.summary.thanks": "Takk for at du er med i Mastodon!",
"attachments_list.unprocessed": "(ubehandla)",
"audio.hide": "Gøym lyd",
"block_modal.remote_users_caveat": "Me vil be tenaren {domain} om å respektere di avgjerd. Me kan ikkje garantera at det vert gjort, sidan nokre tenarar kan handtera blokkering ulikt. Offentlege innlegg kan framleis vera synlege for ikkje-innlogga brukarar.",
"block_modal.remote_users_caveat": "Me vil be tenaren {domain} om å respektera di avgjerd. Me kan ikkje garantera at det vert gjort, sidan nokre tenarar kan handtera blokkering ulikt. Offentlege innlegg kan framleis vera synlege for ikkje-innlogga brukarar.",
"block_modal.show_less": "Vis mindre",
"block_modal.show_more": "Vis meir",
"block_modal.they_cant_mention": "Dei kan ikkje nemna eller fylgja deg.",
@ -276,7 +277,7 @@
"domain_pill.who_they_are": "Sidan handtak seier kven nokon er og kvar dei er, kan du interagere med folk på tvers av det sosiale nettverket av <button>plattformar som støttar ActivityPub</button>.",
"domain_pill.who_you_are": "Sidan handtaket ditt seier kven du er og kvar du er, kan folk interagere med deg på tvers av det sosiale nettverket av <button>plattformar som støttar ActivityPub</button>.",
"domain_pill.your_handle": "Handtaket ditt:",
"domain_pill.your_server": "Din digitale heim, der alle innlegga dine bur i. Liker du ikkje dette? Byt til ein ny tenar når som helst og ta med fylgjarane dine òg.",
"domain_pill.your_server": "Din digitale heim, der alle innlegga dine bur. Liker du ikkje dette? Byt til ein ny tenar når som helst og ta med fylgjarane dine òg.",
"domain_pill.your_username": "Din unike identifikator på denne tenaren. Det er mogleg å finne brukarar med same brukarnamn på forskjellige tenarar.",
"embed.instructions": "Bygg inn denne statusen på nettsida di ved å kopiera koden nedanfor.",
"embed.preview": "Slik kjem det til å sjå ut:",
@ -380,6 +381,8 @@
"generic.saved": "Lagra",
"getting_started.heading": "Kom i gang",
"hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}",
"hashtag.browse": "Bla gjennom innlegg i #{hashtag}",
"hashtag.browse_from_account": "Bla gjennom innlegg frå @{name} i #{hashtag}",
"hashtag.column_header.tag_mode.all": "og {additional}",
"hashtag.column_header.tag_mode.any": "eller {additional}",
"hashtag.column_header.tag_mode.none": "utan {additional}",
@ -393,6 +396,7 @@
"hashtag.counter_by_uses": "{count, plural,one {{counter} innlegg} other {{counter} innlegg}}",
"hashtag.counter_by_uses_today": "{count, plural,one {{counter} innlegg} other {{counter} innlegg}} i dag",
"hashtag.follow": "Fylg emneknagg",
"hashtag.mute": "Demp @#{hashtag}",
"hashtag.unfollow": "Slutt å fylgje emneknaggen",
"hashtags.and_other": "…og {count, plural, one {}other {# fleire}}",
"hints.profiles.followers_may_be_missing": "Kven som fylgjer denne profilen manglar kanskje.",
@ -805,11 +809,11 @@
"server_banner.about_active_users": "Personar som har brukt denne tenaren dei siste 30 dagane (Månadlege Aktive Brukarar)",
"server_banner.active_users": "aktive brukarar",
"server_banner.administered_by": "Administrert av:",
"server_banner.is_one_of_many": "{domain} er ein av dei mange uavhengige Mastodon-serverane du kan bruke til å delta i Fødiverset.",
"server_banner.is_one_of_many": "{domain} er ein av dei mange uavhengige Mastodon-tenarane du kan bruka til å delta i Allheimen.",
"server_banner.server_stats": "Tenarstatistikk:",
"sign_in_banner.create_account": "Opprett konto",
"sign_in_banner.follow_anyone": "Følg kven som helst på tvers av Fødiverset og sjå alt i kronologisk rekkjefølgje. Ingen algoritmar, reklamar eller clickbait i sikte.",
"sign_in_banner.mastodon_is": "Mastodon er den beste måten å følgje med på det som skjer.",
"sign_in_banner.follow_anyone": "Fylg kven som helst på tvers av Allheimen og sjå alt i kronologisk rekkjefylgje. Ingen algoritmar, reklame eller klikkfeller.",
"sign_in_banner.mastodon_is": "Mastodon er den beste måten å fylgja med på det som skjer.",
"sign_in_banner.sign_in": "Logg inn",
"sign_in_banner.sso_redirect": "Logg inn eller registrer deg",
"status.admin_account": "Opne moderasjonsgrensesnitt for @{name}",
@ -875,7 +879,9 @@
"subscribed_languages.target": "Endre abonnerte språk for {target}",
"tabs_bar.home": "Heim",
"tabs_bar.notifications": "Varsel",
"terms_of_service.effective_as_of": "I kraft frå {date}",
"terms_of_service.title": "Bruksvilkår",
"terms_of_service.upcoming_changes_on": "Komande endringar {date}",
"time_remaining.days": "{number, plural, one {# dag} other {# dagar}} igjen",
"time_remaining.hours": "{number, plural, one {# time} other {# timar}} igjen",
"time_remaining.minutes": "{number, plural, one {# minutt} other {# minutt}} igjen",
@ -906,6 +912,12 @@
"video.expand": "Utvid video",
"video.fullscreen": "Fullskjerm",
"video.hide": "Gøym video",
"video.mute": "Demp",
"video.pause": "Pause",
"video.play": "Spel av"
"video.play": "Spel av",
"video.skip_backward": "Hopp bakover",
"video.skip_forward": "Hopp framover",
"video.unmute": "Opphev demping",
"video.volume_down": "Volum ned",
"video.volume_up": "Volum opp"
}

View file

@ -45,9 +45,12 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@quote = nil
@quote_uri = nil
process_status_params
process_tags
process_quote
process_audience
ApplicationRecord.transaction do
@ -55,6 +58,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 +193,16 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_quote(status)
return if @quote.nil?
@quote.status = status
@quote.save
ActivityPub::VerifyQuoteService.new.call(@quote, fetchable_quoted_uri: @quote_uri, request_id: @options[:request_id])
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, @quote.id, @quote_uri, { 'request_id' => @options[:request_id] })
end
def process_tags
return if @object['tag'].nil?
@ -203,6 +217,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_quote
return unless Mastodon::Feature.inbound_quotes_enabled?
@quote_uri = @status_parser.quote_uri
return if @quote_uri.blank?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
@quote = Quote.new(account: @account, approval_uri: approval_uri)
end
def process_hashtag(tag)
return if tag['name'].blank?

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

@ -103,6 +103,16 @@ class ActivityPub::Parser::StatusParser
@object.dig(:shares, :totalItems)
end
def quote_uri
%w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key|
value_or_id(as_array(@object[key]).first)
end.first
end
def quote_approval_uri
as_array(@object['quoteAuthorization']).first
end
private
def raw_language_code

View file

@ -71,6 +71,23 @@ class StatusCacheHydrator
payload[:bookmarked] = Bookmark.exists?(account_id: account_id, status_id: status.id)
payload[:pinned] = StatusPin.exists?(account_id: account_id, status_id: status.id) if status.account_id == account_id
payload[:filtered] = mapped_applied_custom_filter(account_id, status)
payload[:quote] = hydrate_quote_payload(payload[:quote], status.quote, account_id) if payload[:quote]
end
def hydrate_quote_payload(empty_payload, quote, account_id)
# TODO: properly handle quotes, including visibility and access control
empty_payload.tap do |payload|
# Nothing to do if we're in the shallow (depth limit) case
next unless payload.key?(:quoted_status)
# TODO: handle hiding a rendered status or showing a non-rendered status according to visibility
if quote&.quoted_status.nil?
payload[:quoted_status] = nil
elsif payload[:quoted_status].present?
payload[:quoted_status] = StatusCacheHydrator.new(quote.quoted_status).hydrate(account_id)
end
end
end
def mapped_applied_custom_filter(account_id, status)

View file

@ -26,6 +26,7 @@ module Status::SnapshotConcern
account_id: account_id || self.account_id,
content_type: content_type,
created_at: at_time || edited_at,
quote_id: quote&.id,
rate_limit: rate_limit
)
end

67
app/models/quote.rb Normal file
View file

@ -0,0 +1,67 @@
# 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) not null
# quoted_account_id :bigint(8)
# quoted_status_id :bigint(8)
# status_id :bigint(8) not null
#
class Quote < ApplicationRecord
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
REFRESH_DEADLINE = 6.hours
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? }
validate :validate_visibility
def accept!
update!(state: :accepted)
end
def reject!
if accepted?
update!(state: :revoked)
elsif !revoked?
update!(state: :rejected)
end
end
def schedule_refresh_if_stale!
return unless quoted_status_id.present? && approval_uri.present? && updated_at <= BACKGROUND_REFRESH_INTERVAL.ago
ActivityPub::QuoteRefreshWorker.perform_in(rand(REFRESH_DEADLINE), id)
end
private
def set_accounts
self.account = status.account
self.quoted_account = quoted_status&.account
end
def validate_visibility
return if account_id == quoted_account_id || quoted_status.nil? || quoted_status.distributable?
errors.add(:quoted_status_id, :visibility_mismatch)
end
end

View file

@ -95,6 +95,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? }
@ -161,16 +162,18 @@ 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,
reblog: [
:application,
:tags,
:media_attachments,
:conversation,
: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,

View file

@ -16,6 +16,7 @@
# media_descriptions :text is an Array
# poll_options :string is an Array
# sensitive :boolean
# quote_id :bigint(8)
#
class StatusEdit < ApplicationRecord

View file

@ -16,11 +16,11 @@ class StatusRelationshipsPresenter
@filters_map = {}
else
statuses = statuses.compact
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
conversation_ids = statuses.filter_map(&:conversation_id).uniq
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id, s.proper.quote&.quoted_status_id] }.uniq.compact
conversation_ids = statuses.flat_map { |s| [s.proper.conversation_id, s.proper.quote&.quoted_status&.conversation_id] }.uniq.compact
pinnable_status_ids = statuses.flat_map { |s| [s.proper, s.proper.quote&.quoted_status] }.compact.filter_map { |s| s.id if s.account_id == current_account_id && PINNABLE_VISIBILITIES.include?(s.visibility) }
@filters_map = build_filters_map(statuses, current_account_id).merge(options[:filters_map] || {})
@filters_map = build_filters_map(statuses.flat_map { |s| [s, s.proper.quote&.quoted_status] }.compact.uniq, current_account_id).merge(options[:filters_map] || {})
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
@bookmarks_map = Status.bookmarks_map(status_ids, current_account_id).merge(options[:bookmarks_map] || {})

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class REST::BaseQuoteSerializer < ActiveModel::Serializer
attributes :state
def state
return object.state unless object.accepted?
# Extra states when a status is unavailable
return 'deleted' if object.quoted_status.nil?
return 'unauthorized' if status_filter.filtered?
object.state
end
def quoted_status
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered?
end
private
def status_filter
@status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships] || {})
end
end

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::QuoteSerializer < REST::BaseQuoteSerializer
has_one :quoted_status, serializer: REST::ShallowStatusSerializer
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowQuoteSerializer < REST::BaseQuoteSerializer
attribute :quoted_status_id
def quoted_status_id
quoted_status&.id&.to_s
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class REST::ShallowStatusSerializer < REST::StatusSerializer
has_one :quote, key: :quote, serializer: REST::ShallowQuoteSerializer
# It looks like redefining one `has_one` requires redefining all inherited ones
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
end

View file

@ -10,6 +10,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
has_many :ordered_media_attachments, key: :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_one :quote, serializer: REST::QuoteSerializer, if: -> { object.quote_id.present? }
attribute :poll, if: -> { object.poll_options.present? }
def content
@ -19,4 +21,8 @@ class REST::StatusEditSerializer < ActiveModel::Serializer
def poll
{ options: object.poll_options.map { |title| { title: title } } }
end
def quote
object.quote_id == status.quote&.id ? status.quote : Quote.new(state: :pending)
end
end

View file

@ -31,6 +31,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

@ -16,6 +16,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@account = status.account
@media_attachments_changed = false
@poll_changed = false
@quote_changed = false
@request_id = request_id
# Only native types can be updated at the moment
@ -158,7 +159,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@status.sensitive = @account.sensitized? || @status_parser.sensitive || false
@status.language = @status_parser.language
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed
@significant_changes = text_significantly_changed? || @status.spoiler_text_changed? || @media_attachments_changed || @poll_changed || @quote_changed
@status.edited_at = @status_parser.edited_at if significant_changes?
@ -183,6 +184,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
update_tags!
update_mentions!
update_emojis!
update_quote!
end
def update_tags!
@ -262,6 +264,45 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
end
def update_quote!
return unless Mastodon::Feature.inbound_quotes_enabled?
quote = nil
quote_uri = @status_parser.quote_uri
if quote_uri.present?
approval_uri = @status_parser.quote_approval_uri
approval_uri = nil if unsupported_uri_scheme?(approval_uri)
if @status.quote.present?
# If the quoted post has changed, discard the old object and create a new one
if @status.quote.quoted_status.present? && ActivityPub::TagManager.instance.uri_for(@status.quote.quoted_status) != quote_uri
@status.quote.destroy
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
else
quote = @status.quote
quote.update(approval_uri: approval_uri, state: :pending) if quote.approval_uri != @status_parser.quote_approval_uri
end
else
quote = Quote.create(status: @status, approval_uri: approval_uri)
@quote_changed = true
end
end
if quote.present?
begin
quote.save
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quote_uri, request_id: @request_id)
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS
ActivityPub::RefetchAndVerifyQuoteWorker.perform_in(rand(30..600).seconds, quote.id, quote_uri, { 'request_id' => @request_id })
end
elsif @status.quote.present?
@status.quote.destroy!
@quote_changed = true
end
end
def update_counts!
likes = @status_parser.favourites_count
shares = @status_parser.reblogs_count

View file

@ -0,0 +1,112 @@
# frozen_string_literal: true
class ActivityPub::VerifyQuoteService < BaseService
include JsonLdHelper
# Optionally fetch quoted post, and verify the quote is authorized
def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil)
@request_id = request_id
@quote = quote
@fetching_error = nil
fetch_quoted_post_if_needed!(fetchable_quoted_uri)
return if fast_track_approval! || quote.approval_uri.blank?
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
return quote.reject! if @json.nil?
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!(fetchable_quoted_uri) && fast_track_approval!
# Raise an error if we failed to fetch the status
raise @fetching_error if @quote.status.nil? && @fetching_error
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.followers.local.first, 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 fetch_quoted_post_if_needed!(uri)
return if uri.nil? || @quote.quoted_status.present?
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
@quote.update(quoted_status: status) if status.present?
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
@fetching_error = e
end
def import_quoted_post_if_needed!(uri)
# No need to fetch if we already have a post
return if uri.nil? || @quote.quoted_status_id.present? || !@json['interactionTarget'].is_a?(Hash)
# NOTE: Replacing the object's context by that of the parent activity is
# not sound, but it's consistent with the rest of the codebase
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
if status.present?
@quote.update(quoted_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

@ -0,0 +1,15 @@
# frozen_string_literal: true
class ActivityPub::QuoteRefreshWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
def perform(quote_id)
quote = Quote.find_by(id: quote_id)
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
quote.touch
ActivityPub::VerifyQuoteService.new.call(quote)
end
end

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class ActivityPub::RefetchAndVerifyQuoteWorker
include Sidekiq::Worker
include ExponentialBackoff
include JsonLdHelper
sidekiq_options queue: 'pull', retry: 3
def perform(quote_id, quoted_uri, options = {})
quote = Quote.find(quote_id)
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
rescue ActiveRecord::RecordNotFound
# Do nothing
true
rescue Mastodon::UnexpectedResponseError => e
raise e unless response_error_unsalvageable?(e.response)
end
end

View file

@ -55,6 +55,8 @@ el:
too_soon: είναι πολύ σύντομα, πρέπει να είναι μετά από %{date}
user:
attributes:
date_of_birth:
below_limit: είναι κάτω από το όριο ηλικίας
email:
blocked: χρησιμοποιεί μη επιτρεπόμενο πάροχο e-mail
unreachable: δεν φαίνεται να υπάρχει

View file

@ -49,8 +49,14 @@ nn:
attributes:
reblog:
taken: av innlegg eksisterer allereie
terms_of_service:
attributes:
effective_date:
too_soon: er for snart, må vera seinare enn %{date}
user:
attributes:
date_of_birth:
below_limit: er under aldersgrensa
email:
blocked: bruker ein forboden epostleverandør
unreachable: ser ikkje ut til å eksistere

View file

@ -331,6 +331,7 @@ cy:
create: Creu cyhoeddiad
title: Cyhoeddiad newydd
preview:
disclaimer: Gan nad oes modd i ddefnyddwyr eu hosgoi, dylai hysbysiadau e-bost gael eu cyfyngu i gyhoeddiadau pwysig fel tor-data personol neu hysbysiadau cau gweinydd.
explanation_html: 'Bydd yr e-bost yn cael ei anfon at <strong>%{display_count} defnyddiwr</strong> . Bydd y testun canlynol yn cael ei gynnwys yn yr e-bost:'
title: Hysbysiad rhagolwg cyhoeddiad
publish: Cyhoeddi

View file

@ -319,6 +319,7 @@ el:
create: Δημιουργία ανακοίνωσης
title: Νέα ανακοίνωση
preview:
disclaimer: Δεδομένου ότι οι χρήστες δεν μπορούν να εξαιρεθούν από αυτά, οι ειδοποιήσεις μέσω ηλεκτρονικού ταχυδρομείου θα πρέπει να περιορίζονται σε σημαντικές ανακοινώσεις, όπως η παραβίαση προσωπικών δεδομένων ή οι ειδοποιήσεις κλεισίματος διακομιστή.
explanation_html: 'Το email θα αποσταλεί σε <strong>%{display_count} χρήστες</strong>. Το ακόλουθο κείμενο θα συμπεριληφθεί στο e-mail:'
title: Προεπισκόπηση ειδοποίησης ανακοίνωσης
publish: Δημοσίευση
@ -507,6 +508,8 @@ el:
select_capabilities: Επέλεξε Δυνατότητες
sign_in: Σύνδεση
status: Κατάσταση
title: Πάροχοι Δευτερεύουσας Υπηρεσίας Fediverse
title: FASP
follow_recommendations:
description_html: "<strong>Ακολουθώντας συστάσεις βοηθάει τους νέους χρήστες να βρουν γρήγορα ενδιαφέρον περιεχόμενο</strong>. Όταν ένας χρήστης δεν έχει αλληλεπιδράσει με άλλους αρκετά για να διαμορφώσει εξατομικευμένες συστάσεις, συνιστώνται αυτοί οι λογαριασμοί. Υπολογίζονται εκ νέου σε καθημερινή βάση από ένα σύνολο λογαριασμών με τις υψηλότερες πρόσφατες αλληλεπιδράσεις και μεγαλύτερο αριθμό τοπικών ακόλουθων για μια δεδομένη γλώσσα."
language: Για τη γλώσσα
@ -971,6 +974,7 @@ el:
chance_to_review_html: "<strong>Οι παραγόμενοι όροι υπηρεσίας δε θα δημοσιεύονται αυτόματα.</strong> Θα έχεις την ευκαιρία να εξετάσεις το αποτέλεσμα. Παρακαλούμε συμπλήρωσε τις απαιτούμενες πληροφορίες για να συνεχίσεις."
explanation_html: Το πρότυπο όρων υπηρεσίας που παρέχονται είναι μόνο για ενημερωτικούς σκοπούς και δε θα πρέπει να ερμηνεύονται ως νομικές συμβουλές για οποιοδήποτε θέμα. Παρακαλούμε συμβουλέψου τον νομικό σου σύμβουλο σχετικά με την περίπτωσή σου και τις συγκεκριμένες νομικές ερωτήσεις που έχεις.
title: Ρύθμιση Όρων Παροχής Υπηρεσιών
going_live_on_html: Ενεργό, σε ισχύ από %{date}
history: Ιστορικό
live: Ενεργό
no_history: Δεν υπάρχουν ακόμα καταγεγραμμένες αλλαγές στους όρους παροχής υπηρεσιών.
@ -1936,6 +1940,10 @@ el:
recovery_instructions_html: Αν ποτέ δεν έχεις πρόσβαση στο κινητό σου, μπορείς να χρησιμοποιήσεις έναν από τους παρακάτω κωδικούς ανάκτησης για να αποκτήσεις πρόσβαση στο λογαριασμό σου. <strong>Διαφύλαξε τους κωδικούς ανάκτησης</strong>. Για παράδειγμα, μπορείς να τους εκτυπώσεις και να τους φυλάξεις μαζί με άλλα σημαντικά σου έγγραφα.
webauthn: Κλειδιά ασφαλείας
user_mailer:
announcement_published:
description: 'Οι διαχειριστές του %{domain} κάνουν μια ανακοίνωση:'
subject: Ανακοίνωση διακομιστή
title: Ανακοίνωση διακομιστή %{domain}
appeal_approved:
action: Ρυθμίσεις Λογαριασμού
explanation: Η έφεση του παραπτώματος εναντίον του λογαριασμού σου στις %{strike_date}, που υπέβαλες στις %{appeal_date} έχει εγκριθεί. Ο λογαριασμός σου είναι και πάλι σε καλή κατάσταση.
@ -1968,6 +1976,8 @@ el:
terms_of_service_changed:
agreement: Συνεχίζοντας να χρησιμοποιείς το %{domain}, συμφωνείς με αυτούς τους όρους. Αν διαφωνείς με τους ενημερωμένους όρους, μπορείς να τερματίσεις τη συμφωνία σου με το %{domain} ανά πάσα στιγμή διαγράφοντας τον λογαριασμό σου.
changelog: 'Με μια ματιά, αυτό σημαίνει αυτή η ενημέρωση για σένα:'
description: 'Λαμβάνεις αυτό το email επειδή κάνουμε κάποιες αλλαγές στους όρους παροχής υπηρεσιών μας στο %{domain}. Αυτές οι ενημερώσεις θα τεθούν σε ισχύ στις %{date}. Σε ενθαρρύνουμε να εξετάσεις πλήρως τους ενημερωμένους όρους εδώ:'
description_html: Λαμβάνεις αυτό το email επειδή κάνουμε κάποιες αλλαγές στους όρους παροχής υπηρεσιών μας στο %{domain}. Αυτές οι ενημερώσεις θα τεθούν σε ισχύ στις <strong>%{date}</strong>. Σε ενθαρρύνουμε να εξετάσεις πλήρως τους <a href="%{path}" target="_blank">ενημερωμένους όρους εδώ</a>.
sign_off: Η ομάδα του %{domain}
subject: Ενημερώσεις στους όρους παροχής υπηρεσιών μας
subtitle: Οι όροι παροχής υπηρεσιών του %{domain} αλλάζουν

View file

@ -319,6 +319,7 @@ it:
create: Crea annuncio
title: Nuovo annuncio
preview:
disclaimer: Poiché gli utenti non possono disattivarle, le notifiche e-mail dovrebbero essere limitate ad annunci importanti, come notifiche di violazione dei dati personali o di chiusura del server.
explanation_html: 'L''e-mail verrà inviata a <strong>%{display_count} utenti</strong>. Il seguente testo sarà incluso nell''e-mail:'
title: Anteprima della notifica dell'annuncio
publish: Pubblica

View file

@ -2056,7 +2056,10 @@ lv:
edit_profile_title: Pielāgo savu profilu
explanation: Šeit ir daži padomi, kā sākt darbu
feature_action: Uzzināt vairāk
feature_audience_title: Veido savu sekotāju pulku ar pārliecību
feature_control_title: Turi savu laika joslu savā pārvaldībā
feature_creativity: Mastodon nodrošina skaņas, video un attēlu ierakstus, pieejamības aprakstus, aptaujas, satura brīdinājumus, animētus profila attēlus, pielāgotas emocijzīmes, sīktēlu apgriešanas vadīklas un vēl, lai palīdzētu Tev sevi izpaust tiešsaistē. Vai Tu izplati savu mākslu, mūziku vai aplādes, Mastodon ir šeit ar Tevi.
feature_creativity_title: Nepārspējams radošums
feature_moderation: Mastodon nodod lēmumu pieņemšanu atpakaļ Tavās rokās. Katrs serveris izveido savus noteikumus un nosacījumus, kas tiek nodrošināti vietēji, ne kā lieliem uzņēmumiem piederošos sabiedriskajos medijiem, padarot katru serveri par vispielāgojamāko un visatsaucīgāko dažādu cilvēku kopu vajadzībām. Pievienojies serverim, kura noteikumiem Tu piekrīti, vai izvieto savu!
feature_moderation_title: Satura pārraudzība, kādai tai būtu jābūt
follow_action: Sekot

View file

@ -21,7 +21,7 @@ nn:
one: Tut
other: Tut
posts_tab_heading: Tut
self_follow_error: Det er ikkje tillate å følgje din eigen konto
self_follow_error: Du kan ikkje fylgja deg sjølv
admin:
account_actions:
action: Utfør
@ -309,6 +309,7 @@ nn:
title: Revisionslogg
unavailable_instance: "(domenenamn er utilgjengeleg)"
announcements:
back: Tilbake til kunngjeringane
destroyed_msg: Kunngjøringen er slettet!
edit:
title: Rediger kunngjøring
@ -317,6 +318,10 @@ nn:
new:
create: Lag kunngjøring
title: Ny kunngjøring
preview:
disclaimer: Av di folk ikkje kan velja bort epostvarsel, bør du avgrensa dei til viktige kunngjeringar som datainnbrot eller varsel om at tenaren skal stengja.
explanation_html: 'Denne eposten blir send til <strong>%{display_count} folk</strong>. Denne teksten vil stå i eposten:'
title: Førehandsvis kunngjeringa
publish: Publiser
published_msg: Kunngjøring publisert!
scheduled_for: Planlagt for %{time}
@ -475,11 +480,41 @@ nn:
new:
title: Importer domeneblokkeringar
no_file: Inga fil vald
fasp:
debug:
callbacks:
created_at: Oppretta
delete: Slett
ip: IP-adresse
request_body: Meldingskropp i førespurnaden
title: Avlusingstilbakekall
providers:
active: Aktiv
base_url: Basisadresse
callback: Tilbakekall
delete: Slett
edit: Rediger leverandør
finish_registration: Fullfør registrering
name: Namn
providers: Leverandørar
public_key_fingerprint: Offentleg nøkkelavtrykk
registration_requested: Nokon vil registrera seg
registrations:
confirm: Stadfest
description: Du har fått ei registrering frå ein tenesteleverandør. Avslå registreringa viss du ikkje sette i gang dette. Viss du sette i gang registreringa, må du samanlikna namnet og nøkkelavtrykket nøye før du stadfestar registreringa.
reject: Avslå
title: Stadfest registrering via tenestetilbydar
save: Lagre
select_capabilities: Vel eigenskapar
sign_in: Logg inn
status: Status
title: Tilleggstenesteleverandørar for allheimen
title: Tenestleverandør
follow_recommendations:
description_html: "<strong>Fylgjeforslag hjelper nye brukarar å finna interessant innhald raskt</strong>. Om ein brukar ikkje har samhandla nok med andre til å få tilpassa fylgjeforslag, blir desse kontoane føreslått i staden. Dei blir rekna ut på nytt kvar dag ut frå ei blanding av kva kontoar som har mykje nyleg aktivitet og høgast tal på fylgjarar på eit bestemt språk."
language: For språk
status: Status
suppress: Demp følgjeforslag
suppress: Demp fylgjeforslag
suppressed: Dempa
title: Fylgjeforslag
unsuppress: Nullstill fylgjeforslag
@ -939,6 +974,7 @@ nn:
chance_to_review_html: "<strong>Dei genererte bruksvilkåra blir ikkje lagde ut automatisk</strong> Du får høve til å sjå gjennom resultatet og fylla inn dei detaljane som trengst."
explanation_html: Malen for bruksvilkår er berre til informasjon, og du bør ikkje gå ut frå han som juridiske råd. Viss du har spørsmål om lovverk, bør du spørja ein advokat.
title: Oppsett for bruksvilkår
going_live_on_html: I bruk frå %{date}
history: Historikk
live: Direkte
no_history: Det er ikkje registrert nokon endringar i bruksvilkåra enno.
@ -1681,9 +1717,9 @@ nn:
confirm_remove_selected_follows: Er du sikker på at du ikkje vil fylgja desse?
dormant: I dvale
follow_failure: Greidde ikkje fylgja alle kontoane du valde.
follow_selected_followers: Følg valgte tilhengere
follow_selected_followers: Fylg desse som fylgjer deg
followers: Fylgjarar
following: Følginger
following: Folk du fylgjer
invited: Innboden
last_active: Sist aktiv
most_recent: Sist
@ -1904,6 +1940,10 @@ nn:
recovery_instructions_html: Hvis du skulle miste tilgang til telefonen din, kan du bruke en av gjenopprettingskodene nedenfor til å gjenopprette tilgang til din konto. <strong>Oppbevar gjenopprettingskodene sikkert</strong>, for eksempel ved å skrive dem ut og gjemme dem på et lurt sted bare du vet om.
webauthn: Sikkerhetsnøkler
user_mailer:
announcement_published:
description: 'Styrarane på %{domain} har ei kunngjering:'
subject: Kunngjering om tenesta
title: Kunngjering frå %{domain}
appeal_approved:
action: Kontoinnstillingar
explanation: Apellen på prikken mot din kontor på %{strike_date} som du la inn på %{appeal_date} har blitt godkjend. Din konto er nok ein gong i god stand.
@ -1936,6 +1976,8 @@ nn:
terms_of_service_changed:
agreement: Viss du held fram å bruka %{domain}, seier du deg einig i vilkåra. Viss du er usamd i dei oppdaterte vilkåra, kan du slutta å bruka %{domain} når du vil ved å sletta brukarkontoen din.
changelog: 'Denne oppdateringa, kort fortalt:'
description: 'Du får denne eposten fordi me har endra tenestvilkåra på %{domain}. Desse endringane kjem i kraft %{date}. Me oppmodar deg til å sjå på dei oppdaterte vilkåra her:'
description_html: Du får denne eposten fordi me har endra tenestvilkåra på %{domain}. Desse endringane kjem i kraft <strong>%{date}</strong>. Me oppmodar deg til å sjå på <a href="%{path}" target="_blank">dei oppdaterte vilkåra her</a>.
sign_off: Folka på %{domain}
subject: Endra bruksvilkår
subtitle: Bruksvilkåra på %{domain} er endra
@ -1945,7 +1987,7 @@ nn:
appeal_description: Om du meiner dette er ein feil, kan du sende inn ei klage til gjengen i %{instance}.
categories:
spam: Søppelpost
violation: Innhald bryter følgjande retningslinjer
violation: Innhaldet bryt med desse retningslinene
explanation:
delete_statuses: Nokre av innlegga dine er bryt éin eller fleire retningslinjer, og har så blitt fjerna av moderatorene på %{instance}.
disable: Du kan ikkje lenger bruke kontoen, men profilen din og andre data er intakt. Du kan be om ein sikkerhetskopi av dine data, endre kontoinnstillingar eller slette din konto.

View file

@ -75,6 +75,7 @@ el:
filters:
action: Επιλέξτε ποια ενέργεια θα εκτελεστεί όταν μια δημοσίευση ταιριάζει με το φίλτρο
actions:
blur: Απόκρυψη πολυμέσων πίσω από μια προειδοποίηση, χωρίς να κρύβεται το ίδιο το κείμενο
hide: Πλήρης αποκρυψη του φιλτραρισμένου περιεχομένου, συμπεριφέρεται σαν να μην υπήρχε
warn: Απόκρυψη φιλτραρισμένου περιεχομένου πίσω από μια προειδοποίηση που αναφέρει τον τίτλο του φίλτρου
form_admin_settings:
@ -88,6 +89,7 @@ el:
favicon: WEBP, PNG, GIF ή JPG. Παρακάμπτει το προεπιλεγμένο favicon του Mastodon με ένα προσαρμοσμένο εικονίδιο.
mascot: Παρακάμπτει την εικονογραφία στην προηγμένη διεπαφή ιστού.
media_cache_retention_period: Τα αρχεία πολυμέσων από αναρτήσεις που γίνονται από απομακρυσμένους χρήστες αποθηκεύονται προσωρινά στο διακομιστή σου. Όταν οριστεί μια θετική τιμή, τα μέσα θα διαγραφούν μετά τον καθορισμένο αριθμό ημερών. Αν τα δεδομένα πολυμέσων ζητηθούν μετά τη διαγραφή τους, θα γίνει ε, αν το πηγαίο περιεχόμενο είναι ακόμα διαθέσιμο. Λόγω περιορισμών σχετικά με το πόσο συχνά οι κάρτες προεπισκόπησης συνδέσμων συνδέονται σε ιστοσελίδες τρίτων, συνιστάται να ορίσεις αυτή την τιμή σε τουλάχιστον 14 ημέρες ή οι κάρτες προεπισκόπησης συνδέσμων δεν θα ενημερώνονται κατ' απάιτηση πριν από εκείνη την ώρα.
min_age: Οι χρήστες θα κληθούν να επιβεβαιώσουν την ημερομηνία γέννησής τους κατά την εγγραφή
peers_api_enabled: Μια λίστα με ονόματα τομέα που συνάντησε αυτός ο διακομιστής στο fediverse. Δεν περιλαμβάνονται δεδομένα εδώ για το αν συναλλάσσετε με ένα συγκεκριμένο διακομιστή, μόνο ότι ο διακομιστής σας το ξέρει. Χρησιμοποιείται από υπηρεσίες που συλλέγουν στατιστικά στοιχεία για την συναλλαγή με γενική έννοια.
profile_directory: Ο κατάλογος προφίλ παραθέτει όλους τους χρήστες που έχουν επιλέξει να είναι ανακαλύψιμοι.
require_invite_text: 'Όταν η εγγραφή απαιτεί χειροκίνητη έγκριση, κάνε το πεδίο κειμένου: «Γιατί θέλετε να συμμετάσχετε;» υποχρεωτικό αντί για προαιρετικό'
@ -132,14 +134,21 @@ el:
name: Μπορείς να αλλάξεις μόνο το πλαίσιο των χαρακτήρων, για παράδειγμα για να γίνει περισσότερο ευανάγνωστο
terms_of_service:
changelog: Μπορεί να δομηθεί με σύνταξη Markdown.
effective_date: Ένα λογικό χρονικό πλαίσιο μπορεί να κυμαίνεται οποτεδήποτε από 10 έως 30 ημέρες από την ημερομηνία που ενημερώνετε τους χρήστες σας.
text: Μπορεί να δομηθεί με σύνταξη Markdown.
terms_of_service_generator:
admin_email: Οι νομικές ανακοινώσεις περιλαμβάνουν αντικρούσεις, δικαστικές αποφάσεις, αιτήματα που έχουν ληφθεί και αιτήματα επιβολής του νόμου.
arbitration_address: Μπορεί να είναι το ίδιο με τη φυσική διεύθυνση παραπάνω, ή “Μ/Δ” εάν χρησιμοποιείται email.
arbitration_website: Μπορεί να είναι μια φόρμα ιστού ή “Μ/Δ” εάν χρησιμοποιείται email.
choice_of_law: Η πόλη, η περιοχή, το έδαφος ή οι εσωτερικοί ουσιαστικοί νόμοι των οποίων διέπουν όλες τις αξιώσεις.
dmca_address: Για τους φορείς των ΗΠΑ, χρησιμοποιήστε τη διεύθυνση που έχει καταχωρηθεί στο DMCA Designated Agent Directory. A P.O. Η λίστα είναι διαθέσιμη κατόπιν απευθείας αιτήματος, Χρησιμοποιήστε το αίτημα απαλλαγής από την άδεια χρήσης του καθορισμένου από το DMCA Agent Post Office Box για να στείλετε email στο Γραφείο Πνευματικών Δικαιωμάτων και περιγράψτε ότι είστε συντονιστής περιεχομένου με βάση το σπίτι, ο οποίος φοβάται την εκδίκηση ή την απόδοση για τις ενέργειές σας και πρέπει να χρησιμοποιήσετε ένα P.. Box για να αφαιρέσετε τη διεύθυνση οικίας σας από τη δημόσια προβολή.
dmca_email: Μπορεί να είναι το ίδιο email που χρησιμοποιείται για “Διεύθυνση email για νομικές ανακοινώσεις” παραπάνω.
domain: Μοναδικό αναγνωριστικό της διαδικτυακής υπηρεσίας που παρέχεις.
jurisdiction: Ανέφερε τη χώρα όπου ζει αυτός που πληρώνει τους λογαριασμούς. Εάν πρόκειται για εταιρεία ή άλλη οντότητα, ανέφερε τη χώρα όπου υφίσταται, και την πόλη, περιοχή, έδαφος ή πολιτεία ανάλογα με την περίπτωση.
min_age: Δεν πρέπει να είναι κάτω από την ελάχιστη ηλικία που απαιτείται από τους νόμους της δικαιοδοσίας σας.
user:
chosen_languages: Όταν ενεργοποιηθεί, στη δημόσια ροή θα εμφανίζονται τουτ μόνο από τις επιλεγμένες γλώσσες
date_of_birth: Πρέπει να βεβαιωθείς ότι είσαι τουλάχιστον %{age} για να χρησιμοποιήσεις το Mastodon. Δεν θα το αποθηκεύσουμε.
role: Ο ρόλος ελέγχει ποια δικαιώματα έχει ο χρήστης.
user_role:
color: Το χρώμα που θα χρησιμοποιηθεί για το ρόλο σε ολόκληρη τη διεπαφή, ως RGB σε δεκαεξαδική μορφή
@ -252,6 +261,7 @@ el:
name: Ετικέτα
filters:
actions:
blur: Απόκρυψη πολυμέσων με προειδοποίηση
hide: Πλήρης απόκρυψη
warn: Απόκρυψη με προειδοποίηση
form_admin_settings:
@ -265,6 +275,7 @@ el:
favicon: Favicon
mascot: Προσαρμοσμένη μασκότ (απαρχαιωμένο)
media_cache_retention_period: Περίοδος διατήρησης προσωρινής μνήμης πολυμέσων
min_age: Ελάχιστη απαιτούμενη ηλικία
peers_api_enabled: Δημοσίευση λίστας των εντοπισμένων διακομιστών στο API
profile_directory: Ενεργοποίηση καταλόγου προφίλ
registrations_mode: Ποιος μπορεί να εγγραφεί
@ -330,16 +341,22 @@ el:
usable: Να επιτρέπεται η τοπική χρήση αυτής της ετικέτας από αναρτήσεις
terms_of_service:
changelog: Τι άλλαξε;
effective_date: Ημερομηνία έναρξης ισχύος
text: Όροι Παροχής Υπηρεσιών
terms_of_service_generator:
admin_email: Διεύθυνση email για τις νομικές ανακοινώσεις
arbitration_address: Φυσική διεύθυνση για τις ανακοινώσεις διαιτησίας
arbitration_website: Ιστοσελίδα για την υποβολή ειδοποιήσεων διαιτησίας
choice_of_law: Επιλογή νόμου
dmca_address: Φυσική διεύθυνση για ειδοποιήσεις DMCA/πνευματικών δικαιωμάτων
dmca_email: Διεύθυνση email για ειδοποιήσεις DMCA/πνευματικών δικαιωμάτων
domain: Τομέας
jurisdiction: Νομική δικαιοδοσία
min_age: Ελάχιστη ηλικία
user:
date_of_birth_1i: Ημέρα
date_of_birth_2i: Μήνας
date_of_birth_3i: Έτος
role: Ρόλος
time_zone: Ζώνη ώρας
user_role:

View file

@ -89,6 +89,7 @@ nn:
favicon: WEBP, PNG, GIF eller JPG. Overstyrer det standarde Mastodon-favikonet med eit eigendefinert ikon.
mascot: Overstyrer illustrasjonen i det avanserte webgrensesnittet.
media_cache_retention_period: Mediafiler frå innlegg laga av eksterne brukarar blir bufra på serveren din. Når sett til ein positiv verdi, slettast media etter eit gitt antal dagar. Viss mediedata blir førespurt etter det er sletta, vil dei bli lasta ned på nytt viss kjelda sitt innhald framleis er tilgjengeleg. På grunn av restriksjonar på kor ofte lenkeførehandsvisningskort lastar tredjepart-nettstadar, rådast det til å setje denne verdien til minst 14 dagar, eller at førehandsvisningskort ikkje blir oppdatert på førespurnad før det tidspunktet.
min_age: Brukarane vil bli bedne om å stadfesta fødselsdatoen sin når dei registrerer seg
peers_api_enabled: Ei liste over domenenamn denne tenaren har møtt på i allheimen. Det står ingenting om tenaren din samhandlar med ein annan tenar, berre om tenaren din veit om den andre. Dette blir brukt av tenester som samlar statistikk om føderering i det heile.
profile_directory: Profilkatalogen viser alle brukarar som har valt å kunne bli oppdaga.
require_invite_text: Når registrering krev manuell godkjenning, lyt du gjera tekstfeltet "Kvifor vil du bli med?" obligatorisk i staden for valfritt
@ -133,11 +134,13 @@ nn:
name: Du kan berre endra bruken av store/små bokstavar, t. d. for å gjera det meir leseleg
terms_of_service:
changelog: Du kan bruka Markdown-syntaks for struktur.
effective_date: Ei rimeleg ventetid kan variera frå 10 til 30 dagar frå den dagen du varsla folka som bruker denne tenaren.
text: Du kan bruka Markdown-syntaks for struktur.
terms_of_service_generator:
admin_email: Juridiske merknader kan vera motsegner, rettsavgjerder, orskurdar eller førespurnader om sletting.
arbitration_address: Kan vere lik den fysiske adressa over, eller "N/A" viss du bruker epost.
arbitration_website: Kan vere eit nettskjema eller "N/A" viss du bruker e-post.
choice_of_law: Jurisdiksjon
dmca_address: For US operators, use the address registered in the DMCA Designated Agent Directory. A P.O. Box listing is available upon direct request, use the DMCA Designated Agent Post Office Box Waiver Request to email the Copyright Office and describe that you are a home-based content moderator who fears revenge or retribution for your actions and need to use a P.O. Box to remove your home address from public view.
dmca_email: Kan vere same e-post som brukast i "E-postadresse for juridiske meldingar" ovanfor.
domain: Noko som identifiserer den nettenesta du tilbyr.
@ -145,6 +148,7 @@ nn:
min_age: Skal ikkje vere under minstealder som krevst av lover i jurisdiksjonen din.
user:
chosen_languages: Når merka vil berre tuta på dei valde språka synast på offentlege tidsliner
date_of_birth: Me må syta for at du er minst %{age} for å bruka Masodon. Me lagrar ikkje dette.
role: Rolla kontrollerer kva løyve brukaren har.
user_role:
color: Fargen som skal nyttast for denne rolla i heile brukargrensesnittet, som RGB i hex-format
@ -165,7 +169,7 @@ nn:
value: Innhald
indexable: Ta med offentlege innlegg i søkjeresultat
show_collections: Vis dei du fylgjer og dei som fylgjer deg på profilen din
unlocked: Godta nye følgjare automatisk
unlocked: Godta nye fylgjarar automatisk
account_alias:
acct: Brukarnamnet på den gamle kontoen
account_migration:
@ -271,6 +275,7 @@ nn:
favicon: Favorittikon
mascot: Eigendefinert maskot (eldre funksjon)
media_cache_retention_period: Oppbevaringsperiode for mediebuffer
min_age: Minste aldersgrense
peers_api_enabled: Legg ut ei liste over oppdaga tenarar i APIet
profile_directory: Aktiver profilkatalog
registrations_mode: Kven kan registrera seg
@ -336,17 +341,22 @@ nn:
usable: Godta at innlegga kan bruka denne emneknaggen lokalt
terms_of_service:
changelog: Kva er endra?
effective_date: I kraft frå
text: Bruksvilkår
terms_of_service_generator:
admin_email: Epostadresse for juridiske merknader
arbitration_address: Fysisk adresse for skilsdomsvarsel
arbitration_website: Nettstad for å senda inn skilsdomsvarsel
choice_of_law: Jurisdiksjon
dmca_address: Fysisk adresse for opphavsrettsvarsel
dmca_email: Epostadresse for opphavsrettsvarsel
domain: Domene
jurisdiction: Rettskrins
min_age: Minstealder
user:
date_of_birth_1i: Dag
date_of_birth_2i: Månad
date_of_birth_3i: År
role: Rolle
time_zone: Tidssone
user_role:

View file

@ -0,0 +1,20 @@
# 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, null: 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
# Can be used in the future to e.g. bulk-reject quotes from blocked accounts
add_index :quotes, [:account_id, :quoted_account_id]
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddQuoteIdToStatusEdit < ActiveRecord::Migration[8.0]
def change
add_column :status_edits, :quote_id, :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.
ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
ActiveRecord::Schema[8.0].define(version: 2025_04_11_095859) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -871,6 +871,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
t.string "url"
end
create_table "quotes", force: :cascade do |t|
t.bigint "account_id", null: false
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
@ -1008,6 +1026,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) do
t.text "media_descriptions", array: true
t.string "poll_options", array: true
t.boolean "sensitive"
t.bigint "quote_id"
t.index ["account_id"], name: "index_status_edits_on_account_id"
t.index ["status_id"], name: "index_status_edits_on_status_id"
end
@ -1353,6 +1372,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_04_10_144908) 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

@ -7,7 +7,15 @@ RSpec.describe ActivityPub::Activity::Create do
let(:json) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
'@context': [
'https://www.w3.org/ns/activitystreams',
{
quote: {
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
},
],
id: [ActivityPub::TagManager.instance.uri_for(sender), '#foo'].join,
type: 'Create',
actor: ActivityPub::TagManager.instance.uri_for(sender),
@ -929,6 +937,115 @@ RSpec.describe ActivityPub::Activity::Create do
end
end
context 'with an unverifiable quote of a known post', feature: :inbound_quotes do
let(:quoted_status) { Fabricate(:status) }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status)
)
end
it 'creates a status with an unverified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'pending',
approval_uri: nil
)
end
end
context 'with an unverifiable unknown post', feature: :inbound_quotes do
let(:unknown_post_uri) { 'https://unavailable.example.com/unavailable-post' }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: unknown_post_uri
)
end
before do
stub_request(:get, unknown_post_uri).to_return(status: 404)
end
it 'creates a status with an unverified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'pending',
approval_uri: nil
)
end
end
context 'with a verifiable quote of a known post', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/quote-approval' }
let(:object_json) do
build_object(
type: 'Note',
content: 'woah what she said is amazing',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri
)
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: object_json[:id],
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'creates a status with a verified quote' do
expect { subject.perform }.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status).to_not be_nil
expect(status.quote).to_not be_nil
expect(status.quote).to have_attributes(
state: 'accepted',
approval_uri: approval_uri
)
end
end
context 'when a vote to a local poll' do
let(:poll) { Fabricate(:poll, options: %w(Yellow Blue)) }
let!(:local_status) { Fabricate(:status, poll: poll) }

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

@ -40,10 +40,119 @@ RSpec.describe StatusCacheHydrator do
end
end
context 'when handling an unapproved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: status, quoted_status: quoted_status, state: :pending)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when handling an approved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: status, quoted_status: quoted_status, state: :accepted)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
context 'when the quoted post has been favourited' do
before do
FavouriteService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when the quoted post has been reblogged' do
before do
ReblogService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
context 'when the quoted post matches account filters' do
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
before do
account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'renders the same attributes as a full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:quote]).to_not be_nil
end
end
end
context 'when handling a reblog' do
let(:reblog) { Fabricate(:status) }
let(:status) { Fabricate(:status, reblog: reblog) }
context 'when the reblog has an approved quote' do
let(:quoted_status) { Fabricate(:status) }
before do
Fabricate(:quote, status: reblog, quoted_status: quoted_status, state: :accepted)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
context 'when the quoted post has been favourited' do
before do
FavouriteService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
context 'when the quoted post has been reblogged' do
before do
ReblogService.new.call(account, quoted_status)
end
it 'renders the same attributes as full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
context 'when the quoted post matches account filters' do
let(:quoted_status) { Fabricate(:status, text: 'this toot is about that banned word') }
before do
account.custom_filters.create!(phrase: 'filter1', context: %w(home), action: :hide, keywords_attributes: [{ keyword: 'banned' }, { keyword: 'irrelevant' }])
end
it 'renders the same attributes as a full render' do
expect(subject).to eql(compare_to_hash)
expect(subject[:reblog][:quote]).to_not be_nil
end
end
end
context 'when it has been favourited' do
before do
FavouriteService.new.call(account, reblog)

View file

@ -0,0 +1,87 @@
# 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) }
context 'with a pending quote' do
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'pending'
)
end
end
context 'with an accepted quote' do
let(:quote) { Fabricate(:quote, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: be_a(Hash),
state: 'accepted'
)
end
end
context 'with an accepted quote of a deleted post' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_status.destroy!
quote.reload
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'deleted'
)
end
end
context 'with an accepted quote of a blocked user' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_account.block!(current_user.account)
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: nil,
state: 'unauthorized'
)
end
end
context 'with a recursive accepted quote' do
let(:status) { Fabricate(:status) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status: be_a(Hash),
state: 'accepted'
)
end
end
end

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe REST::ShallowQuoteSerializer 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) }
context 'with a pending quote' do
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'pending'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
context 'with an accepted quote' do
let(:quote) { Fabricate(:quote, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: be_a(String),
state: 'accepted'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
context 'with an accepted quote of a deleted post' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_status.destroy!
quote.reload
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'deleted'
)
end
end
context 'with an accepted quote of a blocked user' do
let(:quote) { Fabricate(:quote, state: :accepted) }
before do
quote.quoted_account.block!(current_user.account)
end
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: nil,
state: 'unauthorized'
)
end
end
context 'with a recursive accepted quote' do
let(:status) { Fabricate(:status) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
it 'returns expected values' do
expect(subject.deep_symbolize_keys)
.to include(
quoted_status_id: be_a(String),
state: 'accepted'
)
expect(subject.deep_symbolize_keys)
.to_not have_key(:quoted_status)
end
end
end

View file

@ -5,7 +5,7 @@ require 'rails_helper'
RSpec.describe ActivityPub::ProcessStatusUpdateService do
subject { described_class.new }
let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) }
let!(:status) { Fabricate(:status, text: 'Hello world', uri: 'https://example.com/statuses/1234', account: Fabricate(:account, domain: 'example.com')) }
let(:bogus_mention) { 'https://example.com/users/erroringuser' }
let(:payload) do
{
@ -435,6 +435,398 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end
end
context 'when the status has an existing unverified quote and adds an approval link', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri,
}
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the approval URI and verifies the quote' do
expect { subject.call(status, json, json) }
.to change(quote, :approval_uri).to(approval_uri)
.and change(quote, :state).to('accepted')
end
end
context 'when the status has an existing verified quote and removes an approval link', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
}
end
it 'removes the approval URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change(quote, :approval_uri).to(nil)
.and change(quote, :state).to('pending')
end
end
context 'when the status adds a verifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
quoteAuthorization: approval_uri,
}
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the approval URI and verifies the quote' do
expect { subject.call(status, json, json) }
.to change(status, :quote).from(nil)
expect(status.quote.approval_uri).to eq approval_uri
expect(status.quote.state).to eq 'accepted'
end
end
context 'when the status adds a unverifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(quoted_status),
}
end
it 'updates the approval URI but does not verify the quote' do
expect { subject.call(status, json, json) }
.to change(status, :quote).from(nil)
expect(status.quote.approval_uri).to be_nil
expect(status.quote.state).to eq 'pending'
end
end
context 'when the status removes a verified quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
}
end
it 'removes the quote' do
expect { subject.call(status, json, json) }
.to change { status.reload.quote }.to(nil)
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status removes an unverified quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: nil, state: :pending) }
let(:payload) do
{
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
}
end
it 'removes the quote' do
expect { subject.call(status, json, json) }
.to change { status.reload.quote }.to(nil)
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status swaps a verified quote with an unverifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:second_quoted_status) { Fabricate(:status, account: quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
quoteAuthorization: approval_uri,
}
end
before do
stub_request(:get, approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(quoted_status),
}))
end
it 'updates the URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
.and change { status.quote.state }.from('accepted')
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status swaps a verified quote with another verifiable quote', feature: :inbound_quotes do
let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') }
let(:second_quoted_account) { Fabricate(:account, domain: 'second-quoted.example.com') }
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
let(:second_quoted_status) { Fabricate(:status, account: second_quoted_account) }
let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
let(:approval_uri) { 'https://quoted.example.com/approvals/1' }
let(:second_approval_uri) { 'https://second-quoted.example.com/approvals/2' }
let(:payload) do
{
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'@id': 'https://w3id.org/fep/044f#quote',
'@type': '@id',
},
{
'@id': 'https://w3id.org/fep/044f#quoteAuthorization',
'@type': '@id',
},
],
id: 'foo',
type: 'Note',
summary: 'Show more',
content: 'Hello universe',
updated: '2021-09-08T22:39:25Z',
quote: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
quoteAuthorization: second_approval_uri,
}
end
before do
stub_request(:get, second_approval_uri).to_return(headers: { 'Content-Type': 'application/activity+json' }, body: Oj.dump({
'@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: 'QuoteAuthorization',
id: second_approval_uri,
attributedTo: ActivityPub::TagManager.instance.uri_for(second_quoted_status.account),
interactingObject: ActivityPub::TagManager.instance.uri_for(status),
interactionTarget: ActivityPub::TagManager.instance.uri_for(second_quoted_status),
}))
end
it 'updates the URI and unverifies the quote' do
expect { subject.call(status, json, json) }
.to change { status.quote.quoted_status }.from(quoted_status).to(second_quoted_status)
.and change { status.quote.approval_uri }.from(approval_uri).to(second_approval_uri)
.and(not_change { status.quote.state })
expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
def poll_option_json(name, votes)
{ type: 'Note', name: name, replies: { type: 'Collection', totalItems: votes } }
end

View file

@ -0,0 +1,246 @@
# frozen_string_literal: true
require 'rails_helper'
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 unfetchable approval URI' do
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
before do
stub_request(:get, approval_uri)
.to_return(status: 404)
end
context 'with an already-fetched post' do
it 'does not update the status' do
expect { subject.call(quote) }
.to change(quote, :state).to('rejected')
end
end
context 'with an already-verified quote' do
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
it 'rejects the quote' do
expect { subject.call(quote) }
.to change(quote, :state).to('revoked')
end
end
end
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 a post that cannot be fetched but is inlined' do
let(:quoted_status) { nil }
let(:approval_interaction_target) do
{
type: 'Note',
id: 'https://b.example.com/unknown-quoted',
to: 'https://www.w3.org/ns/activitystreams#Public',
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
content: 'previously unknown post',
}
end
before do
stub_request(:get, 'https://b.example.com/unknown-quoted')
.to_return(status: 404)
end
it 'updates the status' do
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
.to change(quote, :state).to('accepted')
expect(a_request(:get, approval_uri))
.to have_been_made.once
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
end
end
context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
let(:quoted_status) { nil }
let(:approval_interaction_target) do
{
type: 'Note',
id: 'https://example.com/unknown-quoted',
to: 'https://www.w3.org/ns/activitystreams#Public',
attributedTo: ActivityPub::TagManager.instance.uri_for(account),
content: 'previously unknown post',
}
end
before do
stub_request(:get, 'https://example.com/unknown-quoted')
.to_return(status: 404)
end
it 'does not update the status' do
expect { subject.call(quote, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
.to not_change(quote, :state)
.and not_change(quote, :quoted_status)
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 an invalid approval document because of a mismatched ID' do
let(:approval_id) { 'https://evil.com/approvals/1234' }
it 'does not accept the quote' do
# NOTE: maybe we want to skip that instead of rejecting it?
expect { subject.call(quote) }
.to change(quote, :state).to('rejected')
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

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::QuoteRefreshWorker do
let(:worker) { described_class.new }
let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
describe '#perform' do
before { stub_service }
let(:account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, account: account) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil, updated_at: updated_at) }
context 'when dealing with an old quote' do
let(:updated_at) { (Quote::BACKGROUND_REFRESH_INTERVAL * 2).ago }
it 'sends the status to the service and bumps the updated date' do
expect { worker.perform(quote.id) }
.to(change { quote.reload.updated_at })
expect(service).to have_received(:call).with(quote)
end
end
context 'when dealing with a recent quote' do
let(:updated_at) { Time.now.utc }
it 'does not call the service and does not touch the quote' do
expect { worker.perform(quote.id) }
.to_not(change { quote.reload.updated_at })
expect(service).to_not have_received(:call).with(quote)
end
end
end
def stub_service
allow(ActivityPub::VerifyQuoteService)
.to receive(:new)
.and_return(service)
end
end

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ActivityPub::RefetchAndVerifyQuoteWorker do
let(:worker) { described_class.new }
let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
describe '#perform' do
before { stub_service }
let(:account) { Fabricate(:account, domain: 'example.com') }
let(:status) { Fabricate(:status, account: account) }
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil) }
let(:url) { 'https://example.com/quoted-status' }
it 'sends the status to the service' do
worker.perform(quote.id, url)
expect(service).to have_received(:call).with(quote, fetchable_quoted_uri: url, request_id: anything)
end
it 'returns nil for non-existent record' do
result = worker.perform(123_123_123, url)
expect(result).to be(true)
end
end
def stub_service
allow(ActivityPub::VerifyQuoteService)
.to receive(:new)
.and_return(service)
end
end