mirror of
https://github.com/glitch-soc/mastodon
synced 2025-04-24 14:14:51 +00:00
Merge pull request #3048 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to b33f9ea603
This commit is contained in:
commit
d7566c8fa4
52 changed files with 1796 additions and 44 deletions
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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": "Αύξηση έντασης"
|
||||
}
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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}",
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -36,9 +36,9 @@
|
|||
"account.follow_back": "Fylg tilbake",
|
||||
"account.followers": "Fylgjarar",
|
||||
"account.followers.empty": "Ingen fylgjer denne brukaren enno.",
|
||||
"account.followers_counter": "{count, plural, one {{counter} følgjar} other {{counter} følgjarar}}",
|
||||
"account.followers_counter": "{count, plural, one {{counter} fylgjar} other {{counter} fylgjarar}}",
|
||||
"account.following": "Fylgjer",
|
||||
"account.following_counter": "{count, plural, one {{counter} følgjer} other {{counter} følgjer}}",
|
||||
"account.following_counter": "{count, plural, one {{counter} fylgjar} other {{counter} fylgjarar}}",
|
||||
"account.follows.empty": "Denne brukaren fylgjer ikkje nokon enno.",
|
||||
"account.go_to_profile": "Gå til profil",
|
||||
"account.hide_reblogs": "Gøym framhevingar frå @{name}",
|
||||
|
@ -67,6 +67,7 @@
|
|||
"account.statuses_counter": "{count, plural, one {{counter} innlegg} other {{counter} innlegg}}",
|
||||
"account.unblock": "Stopp blokkering av @{name}",
|
||||
"account.unblock_domain": "Stopp blokkering av domenet {domain}",
|
||||
"account.unblock_domain_short": "Fjern blokkering",
|
||||
"account.unblock_short": "Stopp blokkering",
|
||||
"account.unendorse": "Ikkje vis på profil",
|
||||
"account.unfollow": "Slutt å fylgja",
|
||||
|
@ -117,7 +118,7 @@
|
|||
"annual_report.summary.thanks": "Takk for at du er med i Mastodon!",
|
||||
"attachments_list.unprocessed": "(ubehandla)",
|
||||
"audio.hide": "Gøym lyd",
|
||||
"block_modal.remote_users_caveat": "Me vil be tenaren {domain} om å respektere di avgjerd. Me kan ikkje garantera at det vert gjort, sidan nokre tenarar kan handtera blokkering ulikt. Offentlege innlegg kan framleis vera synlege for ikkje-innlogga brukarar.",
|
||||
"block_modal.remote_users_caveat": "Me vil be tenaren {domain} om å respektera di avgjerd. Me kan ikkje garantera at det vert gjort, sidan nokre tenarar kan handtera blokkering ulikt. Offentlege innlegg kan framleis vera synlege for ikkje-innlogga brukarar.",
|
||||
"block_modal.show_less": "Vis mindre",
|
||||
"block_modal.show_more": "Vis meir",
|
||||
"block_modal.they_cant_mention": "Dei kan ikkje nemna eller fylgja deg.",
|
||||
|
@ -276,7 +277,7 @@
|
|||
"domain_pill.who_they_are": "Sidan handtak seier kven nokon er og kvar dei er, kan du interagere med folk på tvers av det sosiale nettverket av <button>plattformar som støttar ActivityPub</button>.",
|
||||
"domain_pill.who_you_are": "Sidan handtaket ditt seier kven du er og kvar du er, kan folk interagere med deg på tvers av det sosiale nettverket av <button>plattformar som støttar ActivityPub</button>.",
|
||||
"domain_pill.your_handle": "Handtaket ditt:",
|
||||
"domain_pill.your_server": "Din digitale heim, der alle innlegga dine bur i. Liker du ikkje dette? Byt til ein ny tenar når som helst og ta med fylgjarane dine òg.",
|
||||
"domain_pill.your_server": "Din digitale heim, der alle innlegga dine bur. Liker du ikkje dette? Byt til ein ny tenar når som helst og ta med fylgjarane dine òg.",
|
||||
"domain_pill.your_username": "Din unike identifikator på denne tenaren. Det er mogleg å finne brukarar med same brukarnamn på forskjellige tenarar.",
|
||||
"embed.instructions": "Bygg inn denne statusen på nettsida di ved å kopiera koden nedanfor.",
|
||||
"embed.preview": "Slik kjem det til å sjå ut:",
|
||||
|
@ -380,6 +381,8 @@
|
|||
"generic.saved": "Lagra",
|
||||
"getting_started.heading": "Kom i gang",
|
||||
"hashtag.admin_moderation": "Opne moderasjonsgrensesnitt for #{name}",
|
||||
"hashtag.browse": "Bla gjennom innlegg i #{hashtag}",
|
||||
"hashtag.browse_from_account": "Bla gjennom innlegg frå @{name} i #{hashtag}",
|
||||
"hashtag.column_header.tag_mode.all": "og {additional}",
|
||||
"hashtag.column_header.tag_mode.any": "eller {additional}",
|
||||
"hashtag.column_header.tag_mode.none": "utan {additional}",
|
||||
|
@ -393,6 +396,7 @@
|
|||
"hashtag.counter_by_uses": "{count, plural,one {{counter} innlegg} other {{counter} innlegg}}",
|
||||
"hashtag.counter_by_uses_today": "{count, plural,one {{counter} innlegg} other {{counter} innlegg}} i dag",
|
||||
"hashtag.follow": "Fylg emneknagg",
|
||||
"hashtag.mute": "Demp @#{hashtag}",
|
||||
"hashtag.unfollow": "Slutt å fylgje emneknaggen",
|
||||
"hashtags.and_other": "…og {count, plural, one {}other {# fleire}}",
|
||||
"hints.profiles.followers_may_be_missing": "Kven som fylgjer denne profilen manglar kanskje.",
|
||||
|
@ -805,11 +809,11 @@
|
|||
"server_banner.about_active_users": "Personar som har brukt denne tenaren dei siste 30 dagane (Månadlege Aktive Brukarar)",
|
||||
"server_banner.active_users": "aktive brukarar",
|
||||
"server_banner.administered_by": "Administrert av:",
|
||||
"server_banner.is_one_of_many": "{domain} er ein av dei mange uavhengige Mastodon-serverane du kan bruke til å delta i Fødiverset.",
|
||||
"server_banner.is_one_of_many": "{domain} er ein av dei mange uavhengige Mastodon-tenarane du kan bruka til å delta i Allheimen.",
|
||||
"server_banner.server_stats": "Tenarstatistikk:",
|
||||
"sign_in_banner.create_account": "Opprett konto",
|
||||
"sign_in_banner.follow_anyone": "Følg kven som helst på tvers av Fødiverset og sjå alt i kronologisk rekkjefølgje. Ingen algoritmar, reklamar eller clickbait i sikte.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon er den beste måten å følgje med på det som skjer.",
|
||||
"sign_in_banner.follow_anyone": "Fylg kven som helst på tvers av Allheimen og sjå alt i kronologisk rekkjefylgje. Ingen algoritmar, reklame eller klikkfeller.",
|
||||
"sign_in_banner.mastodon_is": "Mastodon er den beste måten å fylgja med på det som skjer.",
|
||||
"sign_in_banner.sign_in": "Logg inn",
|
||||
"sign_in_banner.sso_redirect": "Logg inn eller registrer deg",
|
||||
"status.admin_account": "Opne moderasjonsgrensesnitt for @{name}",
|
||||
|
@ -875,7 +879,9 @@
|
|||
"subscribed_languages.target": "Endre abonnerte språk for {target}",
|
||||
"tabs_bar.home": "Heim",
|
||||
"tabs_bar.notifications": "Varsel",
|
||||
"terms_of_service.effective_as_of": "I kraft frå {date}",
|
||||
"terms_of_service.title": "Bruksvilkår",
|
||||
"terms_of_service.upcoming_changes_on": "Komande endringar {date}",
|
||||
"time_remaining.days": "{number, plural, one {# dag} other {# dagar}} igjen",
|
||||
"time_remaining.hours": "{number, plural, one {# time} other {# timar}} igjen",
|
||||
"time_remaining.minutes": "{number, plural, one {# minutt} other {# minutt}} igjen",
|
||||
|
@ -906,6 +912,12 @@
|
|||
"video.expand": "Utvid video",
|
||||
"video.fullscreen": "Fullskjerm",
|
||||
"video.hide": "Gøym video",
|
||||
"video.mute": "Demp",
|
||||
"video.pause": "Pause",
|
||||
"video.play": "Spel av"
|
||||
"video.play": "Spel av",
|
||||
"video.skip_backward": "Hopp bakover",
|
||||
"video.skip_forward": "Hopp framover",
|
||||
"video.unmute": "Opphev demping",
|
||||
"video.volume_down": "Volum ned",
|
||||
"video.volume_up": "Volum opp"
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -26,6 +26,7 @@ module Status::SnapshotConcern
|
|||
account_id: account_id || self.account_id,
|
||||
content_type: content_type,
|
||||
created_at: at_time || edited_at,
|
||||
quote_id: quote&.id,
|
||||
rate_limit: rate_limit
|
||||
)
|
||||
end
|
||||
|
|
67
app/models/quote.rb
Normal file
67
app/models/quote.rb
Normal file
|
@ -0,0 +1,67 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: quotes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# activity_uri :string
|
||||
# approval_uri :string
|
||||
# state :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8) not null
|
||||
# quoted_account_id :bigint(8)
|
||||
# quoted_status_id :bigint(8)
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
class Quote < ApplicationRecord
|
||||
BACKGROUND_REFRESH_INTERVAL = 1.week.freeze
|
||||
REFRESH_DEADLINE = 6.hours
|
||||
|
||||
enum :state,
|
||||
{ pending: 0, accepted: 1, rejected: 2, revoked: 3 },
|
||||
validate: true
|
||||
|
||||
belongs_to :status
|
||||
belongs_to :quoted_status, class_name: 'Status', optional: true
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :quoted_account, class_name: 'Account', optional: true
|
||||
|
||||
before_validation :set_accounts
|
||||
|
||||
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
||||
validate :validate_visibility
|
||||
|
||||
def accept!
|
||||
update!(state: :accepted)
|
||||
end
|
||||
|
||||
def reject!
|
||||
if accepted?
|
||||
update!(state: :revoked)
|
||||
elsif !revoked?
|
||||
update!(state: :rejected)
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_refresh_if_stale!
|
||||
return unless quoted_status_id.present? && approval_uri.present? && updated_at <= BACKGROUND_REFRESH_INTERVAL.ago
|
||||
|
||||
ActivityPub::QuoteRefreshWorker.perform_in(rand(REFRESH_DEADLINE), id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accounts
|
||||
self.account = status.account
|
||||
self.quoted_account = quoted_status&.account
|
||||
end
|
||||
|
||||
def validate_visibility
|
||||
return if account_id == quoted_account_id || quoted_status.nil? || quoted_status.distributable?
|
||||
|
||||
errors.add(:quoted_status_id, :visibility_mismatch)
|
||||
end
|
||||
end
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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] || {})
|
||||
|
|
25
app/serializers/rest/base_quote_serializer.rb
Normal file
25
app/serializers/rest/base_quote_serializer.rb
Normal file
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::BaseQuoteSerializer < ActiveModel::Serializer
|
||||
attributes :state
|
||||
|
||||
def state
|
||||
return object.state unless object.accepted?
|
||||
|
||||
# Extra states when a status is unavailable
|
||||
return 'deleted' if object.quoted_status.nil?
|
||||
return 'unauthorized' if status_filter.filtered?
|
||||
|
||||
object.state
|
||||
end
|
||||
|
||||
def quoted_status
|
||||
object.quoted_status if object.accepted? && object.quoted_status.present? && !status_filter.filtered?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_filter
|
||||
@status_filter ||= StatusFilter.new(object.quoted_status, current_user&.account, instance_options[:relationships] || {})
|
||||
end
|
||||
end
|
5
app/serializers/rest/quote_serializer.rb
Normal file
5
app/serializers/rest/quote_serializer.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::QuoteSerializer < REST::BaseQuoteSerializer
|
||||
has_one :quoted_status, serializer: REST::ShallowStatusSerializer
|
||||
end
|
9
app/serializers/rest/shallow_quote_serializer.rb
Normal file
9
app/serializers/rest/shallow_quote_serializer.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::ShallowQuoteSerializer < REST::BaseQuoteSerializer
|
||||
attribute :quoted_status_id
|
||||
|
||||
def quoted_status_id
|
||||
quoted_status&.id&.to_s
|
||||
end
|
||||
end
|
9
app/serializers/rest/shallow_status_serializer.rb
Normal file
9
app/serializers/rest/shallow_status_serializer.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::ShallowStatusSerializer < REST::StatusSerializer
|
||||
has_one :quote, key: :quote, serializer: REST::ShallowQuoteSerializer
|
||||
|
||||
# It looks like redefining one `has_one` requires redefining all inherited ones
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
112
app/services/activitypub/verify_quote_service.rb
Normal file
112
app/services/activitypub/verify_quote_service.rb
Normal file
|
@ -0,0 +1,112 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::VerifyQuoteService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
# Optionally fetch quoted post, and verify the quote is authorized
|
||||
def call(quote, fetchable_quoted_uri: nil, prefetched_body: nil, request_id: nil)
|
||||
@request_id = request_id
|
||||
@quote = quote
|
||||
@fetching_error = nil
|
||||
|
||||
fetch_quoted_post_if_needed!(fetchable_quoted_uri)
|
||||
return if fast_track_approval! || quote.approval_uri.blank?
|
||||
|
||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
|
||||
return quote.reject! if @json.nil?
|
||||
|
||||
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
|
||||
return unless matching_type? && matching_quote_uri?
|
||||
|
||||
# Opportunistically import embedded posts if needed
|
||||
return if import_quoted_post_if_needed!(fetchable_quoted_uri) && fast_track_approval!
|
||||
|
||||
# Raise an error if we failed to fetch the status
|
||||
raise @fetching_error if @quote.status.nil? && @fetching_error
|
||||
|
||||
return unless matching_quoted_post? && matching_quoted_author?
|
||||
|
||||
quote.accept!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# FEP-044f defines rules that don't require the approval flow
|
||||
def fast_track_approval!
|
||||
return false if @quote.quoted_status_id.blank?
|
||||
|
||||
# Always allow someone to quote themselves
|
||||
if @quote.account_id == @quote.quoted_account_id
|
||||
@quote.accept!
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Always allow someone to quote posts in which they are mentioned
|
||||
if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
|
||||
@quote.accept!
|
||||
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_approval_object(uri, prefetched_body: nil)
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, true, @quote.account.followers.local.first, raise_on_error: :temporary)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
end
|
||||
|
||||
def matching_type?
|
||||
supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization')
|
||||
end
|
||||
|
||||
def matching_quote_uri?
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
|
||||
end
|
||||
|
||||
def fetch_quoted_post_if_needed!(uri)
|
||||
return if uri.nil? || @quote.quoted_status.present?
|
||||
|
||||
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
status ||= ActivityPub::FetchRemoteStatusService.new.call(uri, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
|
||||
|
||||
@quote.update(quoted_status: status) if status.present?
|
||||
rescue Mastodon::UnexpectedResponseError, *Mastodon::HTTP_CONNECTION_ERRORS => e
|
||||
@fetching_error = e
|
||||
end
|
||||
|
||||
def import_quoted_post_if_needed!(uri)
|
||||
# No need to fetch if we already have a post
|
||||
return if uri.nil? || @quote.quoted_status_id.present? || !@json['interactionTarget'].is_a?(Hash)
|
||||
|
||||
# NOTE: Replacing the object's context by that of the parent activity is
|
||||
# not sound, but it's consistent with the rest of the codebase
|
||||
object = @json['interactionTarget'].merge({ '@context' => @json['@context'] })
|
||||
|
||||
# It's not safe to fetch if the inlined object is cross-origin or doesn't match expectations
|
||||
return if object['id'] != uri || non_matching_uri_hosts?(@quote.approval_uri, object['id'])
|
||||
|
||||
status = ActivityPub::FetchRemoteStatusService.new.call(object['id'], prefetched_body: object, on_behalf_of: @quote.account.followers.local.first, request_id: @request_id)
|
||||
|
||||
if status.present?
|
||||
@quote.update(quoted_status: status)
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def matching_quoted_post?
|
||||
return false if @quote.quoted_status_id.blank?
|
||||
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget'])
|
||||
end
|
||||
|
||||
def matching_quoted_author?
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo'])
|
||||
end
|
||||
end
|
15
app/workers/activitypub/quote_refresh_worker.rb
Normal file
15
app/workers/activitypub/quote_refresh_worker.rb
Normal file
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::QuoteRefreshWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 3, dead: false, lock: :until_executed, lock_ttl: 1.day.to_i
|
||||
|
||||
def perform(quote_id)
|
||||
quote = Quote.find_by(id: quote_id)
|
||||
return if quote.nil? || quote.updated_at > Quote::BACKGROUND_REFRESH_INTERVAL.ago
|
||||
|
||||
quote.touch
|
||||
ActivityPub::VerifyQuoteService.new.call(quote)
|
||||
end
|
||||
end
|
19
app/workers/activitypub/refetch_and_verify_quote_worker.rb
Normal file
19
app/workers/activitypub/refetch_and_verify_quote_worker.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::RefetchAndVerifyQuoteWorker
|
||||
include Sidekiq::Worker
|
||||
include ExponentialBackoff
|
||||
include JsonLdHelper
|
||||
|
||||
sidekiq_options queue: 'pull', retry: 3
|
||||
|
||||
def perform(quote_id, quoted_uri, options = {})
|
||||
quote = Quote.find(quote_id)
|
||||
ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
# Do nothing
|
||||
true
|
||||
rescue Mastodon::UnexpectedResponseError => e
|
||||
raise e unless response_error_unsalvageable?(e.response)
|
||||
end
|
||||
end
|
|
@ -55,6 +55,8 @@ el:
|
|||
too_soon: είναι πολύ σύντομα, πρέπει να είναι μετά από %{date}
|
||||
user:
|
||||
attributes:
|
||||
date_of_birth:
|
||||
below_limit: είναι κάτω από το όριο ηλικίας
|
||||
email:
|
||||
blocked: χρησιμοποιεί μη επιτρεπόμενο πάροχο e-mail
|
||||
unreachable: δεν φαίνεται να υπάρχει
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -331,6 +331,7 @@ cy:
|
|||
create: Creu cyhoeddiad
|
||||
title: Cyhoeddiad newydd
|
||||
preview:
|
||||
disclaimer: Gan nad oes modd i ddefnyddwyr eu hosgoi, dylai hysbysiadau e-bost gael eu cyfyngu i gyhoeddiadau pwysig fel tor-data personol neu hysbysiadau cau gweinydd.
|
||||
explanation_html: 'Bydd yr e-bost yn cael ei anfon at <strong>%{display_count} defnyddiwr</strong> . Bydd y testun canlynol yn cael ei gynnwys yn yr e-bost:'
|
||||
title: Hysbysiad rhagolwg cyhoeddiad
|
||||
publish: Cyhoeddi
|
||||
|
|
|
@ -319,6 +319,7 @@ el:
|
|||
create: Δημιουργία ανακοίνωσης
|
||||
title: Νέα ανακοίνωση
|
||||
preview:
|
||||
disclaimer: Δεδομένου ότι οι χρήστες δεν μπορούν να εξαιρεθούν από αυτά, οι ειδοποιήσεις μέσω ηλεκτρονικού ταχυδρομείου θα πρέπει να περιορίζονται σε σημαντικές ανακοινώσεις, όπως η παραβίαση προσωπικών δεδομένων ή οι ειδοποιήσεις κλεισίματος διακομιστή.
|
||||
explanation_html: 'Το email θα αποσταλεί σε <strong>%{display_count} χρήστες</strong>. Το ακόλουθο κείμενο θα συμπεριληφθεί στο e-mail:'
|
||||
title: Προεπισκόπηση ειδοποίησης ανακοίνωσης
|
||||
publish: Δημοσίευση
|
||||
|
@ -507,6 +508,8 @@ el:
|
|||
select_capabilities: Επέλεξε Δυνατότητες
|
||||
sign_in: Σύνδεση
|
||||
status: Κατάσταση
|
||||
title: Πάροχοι Δευτερεύουσας Υπηρεσίας Fediverse
|
||||
title: FASP
|
||||
follow_recommendations:
|
||||
description_html: "<strong>Ακολουθώντας συστάσεις βοηθάει τους νέους χρήστες να βρουν γρήγορα ενδιαφέρον περιεχόμενο</strong>. Όταν ένας χρήστης δεν έχει αλληλεπιδράσει με άλλους αρκετά για να διαμορφώσει εξατομικευμένες συστάσεις, συνιστώνται αυτοί οι λογαριασμοί. Υπολογίζονται εκ νέου σε καθημερινή βάση από ένα σύνολο λογαριασμών με τις υψηλότερες πρόσφατες αλληλεπιδράσεις και μεγαλύτερο αριθμό τοπικών ακόλουθων για μια δεδομένη γλώσσα."
|
||||
language: Για τη γλώσσα
|
||||
|
@ -971,6 +974,7 @@ el:
|
|||
chance_to_review_html: "<strong>Οι παραγόμενοι όροι υπηρεσίας δε θα δημοσιεύονται αυτόματα.</strong> Θα έχεις την ευκαιρία να εξετάσεις το αποτέλεσμα. Παρακαλούμε συμπλήρωσε τις απαιτούμενες πληροφορίες για να συνεχίσεις."
|
||||
explanation_html: Το πρότυπο όρων υπηρεσίας που παρέχονται είναι μόνο για ενημερωτικούς σκοπούς και δε θα πρέπει να ερμηνεύονται ως νομικές συμβουλές για οποιοδήποτε θέμα. Παρακαλούμε συμβουλέψου τον νομικό σου σύμβουλο σχετικά με την περίπτωσή σου και τις συγκεκριμένες νομικές ερωτήσεις που έχεις.
|
||||
title: Ρύθμιση Όρων Παροχής Υπηρεσιών
|
||||
going_live_on_html: Ενεργό, σε ισχύ από %{date}
|
||||
history: Ιστορικό
|
||||
live: Ενεργό
|
||||
no_history: Δεν υπάρχουν ακόμα καταγεγραμμένες αλλαγές στους όρους παροχής υπηρεσιών.
|
||||
|
@ -1936,6 +1940,10 @@ el:
|
|||
recovery_instructions_html: Αν ποτέ δεν έχεις πρόσβαση στο κινητό σου, μπορείς να χρησιμοποιήσεις έναν από τους παρακάτω κωδικούς ανάκτησης για να αποκτήσεις πρόσβαση στο λογαριασμό σου. <strong>Διαφύλαξε τους κωδικούς ανάκτησης</strong>. Για παράδειγμα, μπορείς να τους εκτυπώσεις και να τους φυλάξεις μαζί με άλλα σημαντικά σου έγγραφα.
|
||||
webauthn: Κλειδιά ασφαλείας
|
||||
user_mailer:
|
||||
announcement_published:
|
||||
description: 'Οι διαχειριστές του %{domain} κάνουν μια ανακοίνωση:'
|
||||
subject: Ανακοίνωση διακομιστή
|
||||
title: Ανακοίνωση διακομιστή %{domain}
|
||||
appeal_approved:
|
||||
action: Ρυθμίσεις Λογαριασμού
|
||||
explanation: Η έφεση του παραπτώματος εναντίον του λογαριασμού σου στις %{strike_date}, που υπέβαλες στις %{appeal_date} έχει εγκριθεί. Ο λογαριασμός σου είναι και πάλι σε καλή κατάσταση.
|
||||
|
@ -1968,6 +1976,8 @@ el:
|
|||
terms_of_service_changed:
|
||||
agreement: Συνεχίζοντας να χρησιμοποιείς το %{domain}, συμφωνείς με αυτούς τους όρους. Αν διαφωνείς με τους ενημερωμένους όρους, μπορείς να τερματίσεις τη συμφωνία σου με το %{domain} ανά πάσα στιγμή διαγράφοντας τον λογαριασμό σου.
|
||||
changelog: 'Με μια ματιά, αυτό σημαίνει αυτή η ενημέρωση για σένα:'
|
||||
description: 'Λαμβάνεις αυτό το email επειδή κάνουμε κάποιες αλλαγές στους όρους παροχής υπηρεσιών μας στο %{domain}. Αυτές οι ενημερώσεις θα τεθούν σε ισχύ στις %{date}. Σε ενθαρρύνουμε να εξετάσεις πλήρως τους ενημερωμένους όρους εδώ:'
|
||||
description_html: Λαμβάνεις αυτό το email επειδή κάνουμε κάποιες αλλαγές στους όρους παροχής υπηρεσιών μας στο %{domain}. Αυτές οι ενημερώσεις θα τεθούν σε ισχύ στις <strong>%{date}</strong>. Σε ενθαρρύνουμε να εξετάσεις πλήρως τους <a href="%{path}" target="_blank">ενημερωμένους όρους εδώ</a>.
|
||||
sign_off: Η ομάδα του %{domain}
|
||||
subject: Ενημερώσεις στους όρους παροχής υπηρεσιών μας
|
||||
subtitle: Οι όροι παροχής υπηρεσιών του %{domain} αλλάζουν
|
||||
|
|
|
@ -319,6 +319,7 @@ it:
|
|||
create: Crea annuncio
|
||||
title: Nuovo annuncio
|
||||
preview:
|
||||
disclaimer: Poiché gli utenti non possono disattivarle, le notifiche e-mail dovrebbero essere limitate ad annunci importanti, come notifiche di violazione dei dati personali o di chiusura del server.
|
||||
explanation_html: 'L''e-mail verrà inviata a <strong>%{display_count} utenti</strong>. Il seguente testo sarà incluso nell''e-mail:'
|
||||
title: Anteprima della notifica dell'annuncio
|
||||
publish: Pubblica
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,7 +21,7 @@ nn:
|
|||
one: Tut
|
||||
other: Tut
|
||||
posts_tab_heading: Tut
|
||||
self_follow_error: Det er ikkje tillate å følgje din eigen konto
|
||||
self_follow_error: Du kan ikkje fylgja deg sjølv
|
||||
admin:
|
||||
account_actions:
|
||||
action: Utfør
|
||||
|
@ -309,6 +309,7 @@ nn:
|
|||
title: Revisionslogg
|
||||
unavailable_instance: "(domenenamn er utilgjengeleg)"
|
||||
announcements:
|
||||
back: Tilbake til kunngjeringane
|
||||
destroyed_msg: Kunngjøringen er slettet!
|
||||
edit:
|
||||
title: Rediger kunngjøring
|
||||
|
@ -317,6 +318,10 @@ nn:
|
|||
new:
|
||||
create: Lag kunngjøring
|
||||
title: Ny kunngjøring
|
||||
preview:
|
||||
disclaimer: Av di folk ikkje kan velja bort epostvarsel, bør du avgrensa dei til viktige kunngjeringar som datainnbrot eller varsel om at tenaren skal stengja.
|
||||
explanation_html: 'Denne eposten blir send til <strong>%{display_count} folk</strong>. Denne teksten vil stå i eposten:'
|
||||
title: Førehandsvis kunngjeringa
|
||||
publish: Publiser
|
||||
published_msg: Kunngjøring publisert!
|
||||
scheduled_for: Planlagt for %{time}
|
||||
|
@ -475,11 +480,41 @@ nn:
|
|||
new:
|
||||
title: Importer domeneblokkeringar
|
||||
no_file: Inga fil vald
|
||||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: Oppretta
|
||||
delete: Slett
|
||||
ip: IP-adresse
|
||||
request_body: Meldingskropp i førespurnaden
|
||||
title: Avlusingstilbakekall
|
||||
providers:
|
||||
active: Aktiv
|
||||
base_url: Basisadresse
|
||||
callback: Tilbakekall
|
||||
delete: Slett
|
||||
edit: Rediger leverandør
|
||||
finish_registration: Fullfør registrering
|
||||
name: Namn
|
||||
providers: Leverandørar
|
||||
public_key_fingerprint: Offentleg nøkkelavtrykk
|
||||
registration_requested: Nokon vil registrera seg
|
||||
registrations:
|
||||
confirm: Stadfest
|
||||
description: Du har fått ei registrering frå ein tenesteleverandør. Avslå registreringa viss du ikkje sette i gang dette. Viss du sette i gang registreringa, må du samanlikna namnet og nøkkelavtrykket nøye før du stadfestar registreringa.
|
||||
reject: Avslå
|
||||
title: Stadfest registrering via tenestetilbydar
|
||||
save: Lagre
|
||||
select_capabilities: Vel eigenskapar
|
||||
sign_in: Logg inn
|
||||
status: Status
|
||||
title: Tilleggstenesteleverandørar for allheimen
|
||||
title: Tenestleverandør
|
||||
follow_recommendations:
|
||||
description_html: "<strong>Fylgjeforslag hjelper nye brukarar å finna interessant innhald raskt</strong>. Om ein brukar ikkje har samhandla nok med andre til å få tilpassa fylgjeforslag, blir desse kontoane føreslått i staden. Dei blir rekna ut på nytt kvar dag ut frå ei blanding av kva kontoar som har mykje nyleg aktivitet og høgast tal på fylgjarar på eit bestemt språk."
|
||||
language: For språk
|
||||
status: Status
|
||||
suppress: Demp følgjeforslag
|
||||
suppress: Demp fylgjeforslag
|
||||
suppressed: Dempa
|
||||
title: Fylgjeforslag
|
||||
unsuppress: Nullstill fylgjeforslag
|
||||
|
@ -939,6 +974,7 @@ nn:
|
|||
chance_to_review_html: "<strong>Dei genererte bruksvilkåra blir ikkje lagde ut automatisk</strong> Du får høve til å sjå gjennom resultatet og fylla inn dei detaljane som trengst."
|
||||
explanation_html: Malen for bruksvilkår er berre til informasjon, og du bør ikkje gå ut frå han som juridiske råd. Viss du har spørsmål om lovverk, bør du spørja ein advokat.
|
||||
title: Oppsett for bruksvilkår
|
||||
going_live_on_html: I bruk frå %{date}
|
||||
history: Historikk
|
||||
live: Direkte
|
||||
no_history: Det er ikkje registrert nokon endringar i bruksvilkåra enno.
|
||||
|
@ -1681,9 +1717,9 @@ nn:
|
|||
confirm_remove_selected_follows: Er du sikker på at du ikkje vil fylgja desse?
|
||||
dormant: I dvale
|
||||
follow_failure: Greidde ikkje fylgja alle kontoane du valde.
|
||||
follow_selected_followers: Følg valgte tilhengere
|
||||
follow_selected_followers: Fylg desse som fylgjer deg
|
||||
followers: Fylgjarar
|
||||
following: Følginger
|
||||
following: Folk du fylgjer
|
||||
invited: Innboden
|
||||
last_active: Sist aktiv
|
||||
most_recent: Sist
|
||||
|
@ -1904,6 +1940,10 @@ nn:
|
|||
recovery_instructions_html: Hvis du skulle miste tilgang til telefonen din, kan du bruke en av gjenopprettingskodene nedenfor til å gjenopprette tilgang til din konto. <strong>Oppbevar gjenopprettingskodene sikkert</strong>, for eksempel ved å skrive dem ut og gjemme dem på et lurt sted bare du vet om.
|
||||
webauthn: Sikkerhetsnøkler
|
||||
user_mailer:
|
||||
announcement_published:
|
||||
description: 'Styrarane på %{domain} har ei kunngjering:'
|
||||
subject: Kunngjering om tenesta
|
||||
title: Kunngjering frå %{domain}
|
||||
appeal_approved:
|
||||
action: Kontoinnstillingar
|
||||
explanation: Apellen på prikken mot din kontor på %{strike_date} som du la inn på %{appeal_date} har blitt godkjend. Din konto er nok ein gong i god stand.
|
||||
|
@ -1936,6 +1976,8 @@ nn:
|
|||
terms_of_service_changed:
|
||||
agreement: Viss du held fram å bruka %{domain}, seier du deg einig i vilkåra. Viss du er usamd i dei oppdaterte vilkåra, kan du slutta å bruka %{domain} når du vil ved å sletta brukarkontoen din.
|
||||
changelog: 'Denne oppdateringa, kort fortalt:'
|
||||
description: 'Du får denne eposten fordi me har endra tenestvilkåra på %{domain}. Desse endringane kjem i kraft %{date}. Me oppmodar deg til å sjå på dei oppdaterte vilkåra her:'
|
||||
description_html: Du får denne eposten fordi me har endra tenestvilkåra på %{domain}. Desse endringane kjem i kraft <strong>%{date}</strong>. Me oppmodar deg til å sjå på <a href="%{path}" target="_blank">dei oppdaterte vilkåra her</a>.
|
||||
sign_off: Folka på %{domain}
|
||||
subject: Endra bruksvilkår
|
||||
subtitle: Bruksvilkåra på %{domain} er endra
|
||||
|
@ -1945,7 +1987,7 @@ nn:
|
|||
appeal_description: Om du meiner dette er ein feil, kan du sende inn ei klage til gjengen i %{instance}.
|
||||
categories:
|
||||
spam: Søppelpost
|
||||
violation: Innhald bryter følgjande retningslinjer
|
||||
violation: Innhaldet bryt med desse retningslinene
|
||||
explanation:
|
||||
delete_statuses: Nokre av innlegga dine er bryt éin eller fleire retningslinjer, og har så blitt fjerna av moderatorene på %{instance}.
|
||||
disable: Du kan ikkje lenger bruke kontoen, men profilen din og andre data er intakt. Du kan be om ein sikkerhetskopi av dine data, endre kontoinnstillingar eller slette din konto.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
20
db/migrate/20250411094808_create_quotes.rb
Normal file
20
db/migrate/20250411094808_create_quotes.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateQuotes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :quotes do |t|
|
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false, null: false
|
||||
t.belongs_to :status, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
|
||||
t.belongs_to :quoted_status, foreign_key: { to_table: :statuses, on_delete: :nullify }, null: true
|
||||
t.belongs_to :quoted_account, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true
|
||||
t.integer :state, null: false, default: 0
|
||||
t.string :approval_uri, index: { where: 'approval_uri IS NOT NULL' }
|
||||
t.string :activity_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Can be used in the future to e.g. bulk-reject quotes from blocked accounts
|
||||
add_index :quotes, [:account_id, :quoted_account_id]
|
||||
end
|
||||
end
|
7
db/migrate/20250411095859_add_quote_id_to_status_edit.rb
Normal file
7
db/migrate/20250411095859_add_quote_id_to_status_edit.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddQuoteIdToStatusEdit < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :status_edits, :quote_id, :bigint, null: true
|
||||
end
|
||||
end
|
25
db/schema.rb
25
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
|
||||
|
|
7
spec/fabricators/quote_fabricator.rb
Normal file
7
spec/fabricators/quote_fabricator.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:quote) do
|
||||
status { Fabricate.build(:status) }
|
||||
quoted_status { Fabricate.build(:status) }
|
||||
state :pending
|
||||
end
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
87
spec/serializers/rest/quote_serializer_spec.rb
Normal file
87
spec/serializers/rest/quote_serializer_spec.rb
Normal file
|
@ -0,0 +1,87 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe REST::QuoteSerializer do
|
||||
subject do
|
||||
serialized_record_json(
|
||||
quote,
|
||||
described_class,
|
||||
options: {
|
||||
scope: current_user,
|
||||
scope_name: :current_user,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
let(:quote) { Fabricate(:quote) }
|
||||
|
||||
context 'with a pending quote' do
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: nil,
|
||||
state: 'pending'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: be_a(Hash),
|
||||
state: 'accepted'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote of a deleted post' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
before do
|
||||
quote.quoted_status.destroy!
|
||||
quote.reload
|
||||
end
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: nil,
|
||||
state: 'deleted'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote of a blocked user' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
before do
|
||||
quote.quoted_account.block!(current_user.account)
|
||||
end
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: nil,
|
||||
state: 'unauthorized'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a recursive accepted quote' do
|
||||
let(:status) { Fabricate(:status) }
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: be_a(Hash),
|
||||
state: 'accepted'
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
93
spec/serializers/rest/shallow_quote_serializer_spec.rb
Normal file
93
spec/serializers/rest/shallow_quote_serializer_spec.rb
Normal file
|
@ -0,0 +1,93 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe REST::ShallowQuoteSerializer do
|
||||
subject do
|
||||
serialized_record_json(
|
||||
quote,
|
||||
described_class,
|
||||
options: {
|
||||
scope: current_user,
|
||||
scope_name: :current_user,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
let(:quote) { Fabricate(:quote) }
|
||||
|
||||
context 'with a pending quote' do
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status_id: nil,
|
||||
state: 'pending'
|
||||
)
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to_not have_key(:quoted_status)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status_id: be_a(String),
|
||||
state: 'accepted'
|
||||
)
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to_not have_key(:quoted_status)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote of a deleted post' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
before do
|
||||
quote.quoted_status.destroy!
|
||||
quote.reload
|
||||
end
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status_id: nil,
|
||||
state: 'deleted'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an accepted quote of a blocked user' do
|
||||
let(:quote) { Fabricate(:quote, state: :accepted) }
|
||||
|
||||
before do
|
||||
quote.quoted_account.block!(current_user.account)
|
||||
end
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status_id: nil,
|
||||
state: 'unauthorized'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a recursive accepted quote' do
|
||||
let(:status) { Fabricate(:status) }
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: status, state: :accepted) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status_id: be_a(String),
|
||||
state: 'accepted'
|
||||
)
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to_not have_key(:quoted_status)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
246
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
246
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
|
@ -0,0 +1,246 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ActivityPub::VerifyQuoteService do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:account) { Fabricate(:account, domain: 'a.example.com') }
|
||||
let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
|
||||
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||
let(:status) { Fabricate(:status, account: account) }
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) }
|
||||
|
||||
context 'with an unfetchable approval URI' do
|
||||
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
||||
|
||||
before do
|
||||
stub_request(:get, approval_uri)
|
||||
.to_return(status: 404)
|
||||
end
|
||||
|
||||
context 'with an already-fetched post' do
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('rejected')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an already-verified quote' do
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) }
|
||||
|
||||
it 'rejects the quote' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('revoked')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval URI' do
|
||||
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
||||
|
||||
let(:approval_type) { 'QuoteAuthorization' }
|
||||
let(:approval_id) { approval_uri }
|
||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
|
||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
|
||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
|
||||
|
||||
let(:json) do
|
||||
{
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{
|
||||
toot: 'http://joinmastodon.org/ns#',
|
||||
QuoteAuthorization: 'toot:QuoteAuthorization',
|
||||
gts: 'https://gotosocial.org/ns#',
|
||||
interactionPolicy: {
|
||||
'@id': 'gts:interactionPolicy',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactingObject: {
|
||||
'@id': 'gts:interactingObject',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactionTarget: {
|
||||
'@id': 'gts:interactionTarget',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: approval_type,
|
||||
id: approval_id,
|
||||
attributedTo: approval_attributed_to,
|
||||
interactingObject: approval_interacting_object,
|
||||
interactionTarget: approval_interaction_target,
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, approval_uri)
|
||||
.to_return(status: 200, body: Oj.dump(json), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
context 'with a valid activity for already-fetched posts' do
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid activity for a post that cannot be fetched but is inlined' do
|
||||
let(:quoted_status) { nil }
|
||||
|
||||
let(:approval_interaction_target) do
|
||||
{
|
||||
type: 'Note',
|
||||
id: 'https://b.example.com/unknown-quoted',
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
attributedTo: ActivityPub::TagManager.instance.uri_for(quoted_account),
|
||||
content: 'previously unknown post',
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://b.example.com/unknown-quoted')
|
||||
.to_return(status: 404)
|
||||
end
|
||||
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote, fetchable_quoted_uri: 'https://b.example.com/unknown-quoted') }
|
||||
.to change(quote, :state).to('accepted')
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to have_been_made.once
|
||||
|
||||
expect(quote.reload.quoted_status.content).to eq 'previously unknown post'
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid activity for a post that cannot be fetched and is inlined from an untrusted source' do
|
||||
let(:quoted_status) { nil }
|
||||
|
||||
let(:approval_interaction_target) do
|
||||
{
|
||||
type: 'Note',
|
||||
id: 'https://example.com/unknown-quoted',
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
attributedTo: ActivityPub::TagManager.instance.uri_for(account),
|
||||
content: 'previously unknown post',
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, 'https://example.com/unknown-quoted')
|
||||
.to_return(status: 404)
|
||||
end
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote, fetchable_quoted_uri: 'https://example.com/unknown-quoted') }
|
||||
.to not_change(quote, :state)
|
||||
.and not_change(quote, :quoted_status)
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
|
||||
it 'updates the status without fetching the activity' do
|
||||
expect { subject.call(quote, prefetched_body: Oj.dump(json)) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to_not have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an unverifiable approval' do
|
||||
let(:approval_uri) { 'https://evil.com/approvals/1234' }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an invalid approval document because of a mismatched ID' do
|
||||
let(:approval_id) { 'https://evil.com/approvals/1234' }
|
||||
|
||||
it 'does not accept the quote' do
|
||||
# NOTE: maybe we want to skip that instead of rejecting it?
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('rejected')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval from the wrong account' do
|
||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval for the wrong quoted post' do
|
||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval for the wrong quote post' do
|
||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval of the wrong type' do
|
||||
let(:approval_type) { 'ReplyAuthorization' }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fast-track authorizations' do
|
||||
let(:approval_uri) { nil }
|
||||
|
||||
context 'without any fast-track condition' do
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account and the quoted account are the same' do
|
||||
let(:quoted_account) { account }
|
||||
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account is mentioned by the quoted post' do
|
||||
before do
|
||||
quoted_status.mentions << Mention.new(account: account)
|
||||
end
|
||||
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
44
spec/workers/activitypub/quote_refresh_worker_spec.rb
Normal file
44
spec/workers/activitypub/quote_refresh_worker_spec.rb
Normal file
|
@ -0,0 +1,44 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ActivityPub::QuoteRefreshWorker do
|
||||
let(:worker) { described_class.new }
|
||||
let(:service) { instance_double(ActivityPub::VerifyQuoteService, call: true) }
|
||||
|
||||
describe '#perform' do
|
||||
before { stub_service }
|
||||
|
||||
let(:account) { Fabricate(:account, domain: 'example.com') }
|
||||
let(:status) { Fabricate(:status, account: account) }
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: nil, updated_at: updated_at) }
|
||||
|
||||
context 'when dealing with an old quote' do
|
||||
let(:updated_at) { (Quote::BACKGROUND_REFRESH_INTERVAL * 2).ago }
|
||||
|
||||
it 'sends the status to the service and bumps the updated date' do
|
||||
expect { worker.perform(quote.id) }
|
||||
.to(change { quote.reload.updated_at })
|
||||
|
||||
expect(service).to have_received(:call).with(quote)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when dealing with a recent quote' do
|
||||
let(:updated_at) { Time.now.utc }
|
||||
|
||||
it 'does not call the service and does not touch the quote' do
|
||||
expect { worker.perform(quote.id) }
|
||||
.to_not(change { quote.reload.updated_at })
|
||||
|
||||
expect(service).to_not have_received(:call).with(quote)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stub_service
|
||||
allow(ActivityPub::VerifyQuoteService)
|
||||
.to receive(:new)
|
||||
.and_return(service)
|
||||
end
|
||||
end
|
|
@ -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
|
Loading…
Add table
Reference in a new issue