diff --git a/Dockerfile b/Dockerfile
index 6620f4c096..7e9393efea 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"
diff --git a/FEDERATION.md b/FEDERATION.md
index 2819fa935a..03ea5449de 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -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
diff --git a/Gemfile.lock b/Gemfile.lock
index f13df0c43f..0b21e544e6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/javascript/mastodon/locales/cy.json b/app/javascript/mastodon/locales/cy.json
index 3bf10be7fb..23708ab912 100644
--- a/app/javascript/mastodon/locales/cy.json
+++ b/app/javascript/mastodon/locales/cy.json
@@ -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.",
diff --git a/app/javascript/mastodon/locales/el.json b/app/javascript/mastodon/locales/el.json
index 0b9e42cbe9..e9f8ead17f 100644
--- a/app/javascript/mastodon/locales/el.json
+++ b/app/javascript/mastodon/locales/el.json
@@ -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": "Αύξηση έντασης"
}
diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json
index 69e20fc016..f4d9266276 100644
--- a/app/javascript/mastodon/locales/es-MX.json
+++ b/app/javascript/mastodon/locales/es-MX.json
@@ -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}",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 4f9d91ce11..49e5631a81 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -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}",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 36770c675f..55da1c8c11 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -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.",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 7110e809c1..774975122a 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -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"
}
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 30f4777f61..a2deb6fa75 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -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",
diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json
index ab867017a9..492ce929fc 100644
--- a/app/javascript/mastodon/locales/nn.json
+++ b/app/javascript/mastodon/locales/nn.json
@@ -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 .",
"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 .",
"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"
}
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 0b4eadf231..7f96c44b45 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -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?
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index 61f6ca6997..69b7bd0354 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -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
diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb
index 994be69856..36ee5eb3f2 100644
--- a/app/lib/activitypub/parser/status_parser.rb
+++ b/app/lib/activitypub/parser/status_parser.rb
@@ -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
diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb
index 676e9e62a0..8821c23d13 100644
--- a/app/lib/status_cache_hydrator.rb
+++ b/app/lib/status_cache_hydrator.rb
@@ -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)
diff --git a/app/models/concerns/status/snapshot_concern.rb b/app/models/concerns/status/snapshot_concern.rb
index 7199d2242e..cb481daf9c 100644
--- a/app/models/concerns/status/snapshot_concern.rb
+++ b/app/models/concerns/status/snapshot_concern.rb
@@ -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
diff --git a/app/models/quote.rb b/app/models/quote.rb
new file mode 100644
index 0000000000..8e21d9b481
--- /dev/null
+++ b/app/models/quote.rb
@@ -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
diff --git a/app/models/status.rb b/app/models/status.rb
index 6873e975b9..b33d986d0b 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -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,
diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb
index 165b5403ec..3b0d7c1dca 100644
--- a/app/models/status_edit.rb
+++ b/app/models/status_edit.rb
@@ -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
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 5d53040fb2..2d95db82da 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -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] || {})
diff --git a/app/serializers/rest/base_quote_serializer.rb b/app/serializers/rest/base_quote_serializer.rb
new file mode 100644
index 0000000000..0434f342c9
--- /dev/null
+++ b/app/serializers/rest/base_quote_serializer.rb
@@ -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
diff --git a/app/serializers/rest/quote_serializer.rb b/app/serializers/rest/quote_serializer.rb
new file mode 100644
index 0000000000..6f2eede0ea
--- /dev/null
+++ b/app/serializers/rest/quote_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::QuoteSerializer < REST::BaseQuoteSerializer
+ has_one :quoted_status, serializer: REST::ShallowStatusSerializer
+end
diff --git a/app/serializers/rest/shallow_quote_serializer.rb b/app/serializers/rest/shallow_quote_serializer.rb
new file mode 100644
index 0000000000..1f5f229d43
--- /dev/null
+++ b/app/serializers/rest/shallow_quote_serializer.rb
@@ -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
diff --git a/app/serializers/rest/shallow_status_serializer.rb b/app/serializers/rest/shallow_status_serializer.rb
new file mode 100644
index 0000000000..ca0ac8f4f4
--- /dev/null
+++ b/app/serializers/rest/shallow_status_serializer.rb
@@ -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
diff --git a/app/serializers/rest/status_edit_serializer.rb b/app/serializers/rest/status_edit_serializer.rb
index f7a48797d1..30e318a6aa 100644
--- a/app/serializers/rest/status_edit_serializer.rb
+++ b/app/serializers/rest/status_edit_serializer.rb
@@ -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
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 003ed2023d..eea8dbaa3e 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -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
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index fd5a8c2d46..6a1066a05d 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -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
diff --git a/app/services/activitypub/verify_quote_service.rb b/app/services/activitypub/verify_quote_service.rb
new file mode 100644
index 0000000000..0803d62d3a
--- /dev/null
+++ b/app/services/activitypub/verify_quote_service.rb
@@ -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
diff --git a/app/workers/activitypub/quote_refresh_worker.rb b/app/workers/activitypub/quote_refresh_worker.rb
new file mode 100644
index 0000000000..7dabfddc80
--- /dev/null
+++ b/app/workers/activitypub/quote_refresh_worker.rb
@@ -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
diff --git a/app/workers/activitypub/refetch_and_verify_quote_worker.rb b/app/workers/activitypub/refetch_and_verify_quote_worker.rb
new file mode 100644
index 0000000000..0c7ecd9b2a
--- /dev/null
+++ b/app/workers/activitypub/refetch_and_verify_quote_worker.rb
@@ -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
diff --git a/config/locales/activerecord.el.yml b/config/locales/activerecord.el.yml
index 58e7a7df3f..4bb344bc6d 100644
--- a/config/locales/activerecord.el.yml
+++ b/config/locales/activerecord.el.yml
@@ -55,6 +55,8 @@ el:
too_soon: είναι πολύ σύντομα, πρέπει να είναι μετά από %{date}
user:
attributes:
+ date_of_birth:
+ below_limit: είναι κάτω από το όριο ηλικίας
email:
blocked: χρησιμοποιεί μη επιτρεπόμενο πάροχο e-mail
unreachable: δεν φαίνεται να υπάρχει
diff --git a/config/locales/activerecord.nn.yml b/config/locales/activerecord.nn.yml
index f47bafe0b7..ae73057dcc 100644
--- a/config/locales/activerecord.nn.yml
+++ b/config/locales/activerecord.nn.yml
@@ -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
diff --git a/config/locales/cy.yml b/config/locales/cy.yml
index edd89fedc6..2679c00524 100644
--- a/config/locales/cy.yml
+++ b/config/locales/cy.yml
@@ -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 %{display_count} defnyddiwr . Bydd y testun canlynol yn cael ei gynnwys yn yr e-bost:'
title: Hysbysiad rhagolwg cyhoeddiad
publish: Cyhoeddi
diff --git a/config/locales/el.yml b/config/locales/el.yml
index d2f4fbba01..9e82063f98 100644
--- a/config/locales/el.yml
+++ b/config/locales/el.yml
@@ -319,6 +319,7 @@ el:
create: Δημιουργία ανακοίνωσης
title: Νέα ανακοίνωση
preview:
+ disclaimer: Δεδομένου ότι οι χρήστες δεν μπορούν να εξαιρεθούν από αυτά, οι ειδοποιήσεις μέσω ηλεκτρονικού ταχυδρομείου θα πρέπει να περιορίζονται σε σημαντικές ανακοινώσεις, όπως η παραβίαση προσωπικών δεδομένων ή οι ειδοποιήσεις κλεισίματος διακομιστή.
explanation_html: 'Το email θα αποσταλεί σε %{display_count} χρήστες. Το ακόλουθο κείμενο θα συμπεριληφθεί στο e-mail:'
title: Προεπισκόπηση ειδοποίησης ανακοίνωσης
publish: Δημοσίευση
@@ -507,6 +508,8 @@ el:
select_capabilities: Επέλεξε Δυνατότητες
sign_in: Σύνδεση
status: Κατάσταση
+ title: Πάροχοι Δευτερεύουσας Υπηρεσίας Fediverse
+ title: FASP
follow_recommendations:
description_html: "Ακολουθώντας συστάσεις βοηθάει τους νέους χρήστες να βρουν γρήγορα ενδιαφέρον περιεχόμενο. Όταν ένας χρήστης δεν έχει αλληλεπιδράσει με άλλους αρκετά για να διαμορφώσει εξατομικευμένες συστάσεις, συνιστώνται αυτοί οι λογαριασμοί. Υπολογίζονται εκ νέου σε καθημερινή βάση από ένα σύνολο λογαριασμών με τις υψηλότερες πρόσφατες αλληλεπιδράσεις και μεγαλύτερο αριθμό τοπικών ακόλουθων για μια δεδομένη γλώσσα."
language: Για τη γλώσσα
@@ -971,6 +974,7 @@ el:
chance_to_review_html: "Οι παραγόμενοι όροι υπηρεσίας δε θα δημοσιεύονται αυτόματα. Θα έχεις την ευκαιρία να εξετάσεις το αποτέλεσμα. Παρακαλούμε συμπλήρωσε τις απαιτούμενες πληροφορίες για να συνεχίσεις."
explanation_html: Το πρότυπο όρων υπηρεσίας που παρέχονται είναι μόνο για ενημερωτικούς σκοπούς και δε θα πρέπει να ερμηνεύονται ως νομικές συμβουλές για οποιοδήποτε θέμα. Παρακαλούμε συμβουλέψου τον νομικό σου σύμβουλο σχετικά με την περίπτωσή σου και τις συγκεκριμένες νομικές ερωτήσεις που έχεις.
title: Ρύθμιση Όρων Παροχής Υπηρεσιών
+ going_live_on_html: Ενεργό, σε ισχύ από %{date}
history: Ιστορικό
live: Ενεργό
no_history: Δεν υπάρχουν ακόμα καταγεγραμμένες αλλαγές στους όρους παροχής υπηρεσιών.
@@ -1936,6 +1940,10 @@ el:
recovery_instructions_html: Αν ποτέ δεν έχεις πρόσβαση στο κινητό σου, μπορείς να χρησιμοποιήσεις έναν από τους παρακάτω κωδικούς ανάκτησης για να αποκτήσεις πρόσβαση στο λογαριασμό σου. Διαφύλαξε τους κωδικούς ανάκτησης. Για παράδειγμα, μπορείς να τους εκτυπώσεις και να τους φυλάξεις μαζί με άλλα σημαντικά σου έγγραφα.
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}. Αυτές οι ενημερώσεις θα τεθούν σε ισχύ στις %{date}. Σε ενθαρρύνουμε να εξετάσεις πλήρως τους ενημερωμένους όρους εδώ.
sign_off: Η ομάδα του %{domain}
subject: Ενημερώσεις στους όρους παροχής υπηρεσιών μας
subtitle: Οι όροι παροχής υπηρεσιών του %{domain} αλλάζουν
diff --git a/config/locales/it.yml b/config/locales/it.yml
index 95096b07c8..5c0a45f39a 100644
--- a/config/locales/it.yml
+++ b/config/locales/it.yml
@@ -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 %{display_count} utenti. Il seguente testo sarà incluso nell''e-mail:'
title: Anteprima della notifica dell'annuncio
publish: Pubblica
diff --git a/config/locales/lv.yml b/config/locales/lv.yml
index 2bb5abf2de..6c81fe6e25 100644
--- a/config/locales/lv.yml
+++ b/config/locales/lv.yml
@@ -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
diff --git a/config/locales/nn.yml b/config/locales/nn.yml
index bcdcf01d9a..30ddcaeb6a 100644
--- a/config/locales/nn.yml
+++ b/config/locales/nn.yml
@@ -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 %{display_count} folk. 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: "Fylgjeforslag hjelper nye brukarar å finna interessant innhald raskt. 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: "Dei genererte bruksvilkåra blir ikkje lagde ut automatisk 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. Oppbevar gjenopprettingskodene sikkert, 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 %{date}. Me oppmodar deg til å sjå på dei oppdaterte vilkåra her.
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.
diff --git a/config/locales/simple_form.el.yml b/config/locales/simple_form.el.yml
index 62de683213..3b82cd3c75 100644
--- a/config/locales/simple_form.el.yml
+++ b/config/locales/simple_form.el.yml
@@ -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:
diff --git a/config/locales/simple_form.nn.yml b/config/locales/simple_form.nn.yml
index 1a33a4b91d..b742375f12 100644
--- a/config/locales/simple_form.nn.yml
+++ b/config/locales/simple_form.nn.yml
@@ -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:
diff --git a/db/migrate/20250411094808_create_quotes.rb b/db/migrate/20250411094808_create_quotes.rb
new file mode 100644
index 0000000000..8c830665ac
--- /dev/null
+++ b/db/migrate/20250411094808_create_quotes.rb
@@ -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
diff --git a/db/migrate/20250411095859_add_quote_id_to_status_edit.rb b/db/migrate/20250411095859_add_quote_id_to_status_edit.rb
new file mode 100644
index 0000000000..f5bb2f812a
--- /dev/null
+++ b/db/migrate/20250411095859_add_quote_id_to_status_edit.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index cf79ec35ab..d3f04cc8d8 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/spec/fabricators/quote_fabricator.rb b/spec/fabricators/quote_fabricator.rb
new file mode 100644
index 0000000000..c420d2720c
--- /dev/null
+++ b/spec/fabricators/quote_fabricator.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+Fabricator(:quote) do
+ status { Fabricate.build(:status) }
+ quoted_status { Fabricate.build(:status) }
+ state :pending
+end
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 477a58b576..2ec9742cc5 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -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) }
diff --git a/spec/lib/activitypub/activity/delete_spec.rb b/spec/lib/activitypub/activity/delete_spec.rb
index 71977a96a2..849c7ada90 100644
--- a/spec/lib/activitypub/activity/delete_spec.rb
+++ b/spec/lib/activitypub/activity/delete_spec.rb
@@ -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
diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb
index 958e2f62d7..a0a82e3923 100644
--- a/spec/lib/status_cache_hydrator_spec.rb
+++ b/spec/lib/status_cache_hydrator_spec.rb
@@ -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)
diff --git a/spec/serializers/rest/quote_serializer_spec.rb b/spec/serializers/rest/quote_serializer_spec.rb
new file mode 100644
index 0000000000..999bd6d6b1
--- /dev/null
+++ b/spec/serializers/rest/quote_serializer_spec.rb
@@ -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
diff --git a/spec/serializers/rest/shallow_quote_serializer_spec.rb b/spec/serializers/rest/shallow_quote_serializer_spec.rb
new file mode 100644
index 0000000000..32acd5f5d1
--- /dev/null
+++ b/spec/serializers/rest/shallow_quote_serializer_spec.rb
@@ -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
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
index 28b7653833..b88c084b35 100644
--- a/spec/services/activitypub/process_status_update_service_spec.rb
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -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
diff --git a/spec/services/activitypub/verify_quote_service_spec.rb b/spec/services/activitypub/verify_quote_service_spec.rb
new file mode 100644
index 0000000000..8fe114079b
--- /dev/null
+++ b/spec/services/activitypub/verify_quote_service_spec.rb
@@ -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
diff --git a/spec/workers/activitypub/quote_refresh_worker_spec.rb b/spec/workers/activitypub/quote_refresh_worker_spec.rb
new file mode 100644
index 0000000000..bcdcc0b746
--- /dev/null
+++ b/spec/workers/activitypub/quote_refresh_worker_spec.rb
@@ -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
diff --git a/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb b/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb
new file mode 100644
index 0000000000..a925709885
--- /dev/null
+++ b/spec/workers/activitypub/refetch_and_verify_quote_worker_spec.rb
@@ -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