Compare commits
6 Commits
main
...
features/q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d15ba8311 | ||
|
|
009b877023 | ||
|
|
ee49cbd492 | ||
|
|
223d7ced76 | ||
|
|
1a47c4f265 | ||
|
|
9ff21c72f5 |
20
Gemfile.lock
20
Gemfile.lock
@ -94,7 +94,7 @@ GEM
|
||||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1080.0)
|
||||
aws-partitions (1.1066.0)
|
||||
aws-sdk-core (3.215.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
@ -126,7 +126,7 @@ GEM
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.1)
|
||||
brakeman (7.0.0)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
brpoplpush-redis_script (0.1.3)
|
||||
@ -194,7 +194,7 @@ GEM
|
||||
devise_pam_authenticatable2 (9.2.0)
|
||||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.6.1)
|
||||
diff-lcs (1.6.0)
|
||||
discard (1.4.0)
|
||||
activerecord (>= 4.2, < 9.0)
|
||||
docile (1.4.1)
|
||||
@ -266,10 +266,10 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google-protobuf (4.30.2)
|
||||
google-protobuf (4.30.1)
|
||||
bigdecimal
|
||||
rake (>= 13)
|
||||
googleapis-common-protos-types (1.19.0)
|
||||
googleapis-common-protos-types (1.18.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
@ -426,7 +426,7 @@ GEM
|
||||
mime-types (3.6.2)
|
||||
logger
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2025.0402)
|
||||
mime-types-data (3.2025.0318)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.5)
|
||||
@ -688,7 +688,7 @@ GEM
|
||||
link_header (~> 0.0, >= 0.0.8)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (6.13.1)
|
||||
rdoc (6.12.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (4.8.1)
|
||||
@ -840,7 +840,7 @@ GEM
|
||||
stoplight (4.1.1)
|
||||
redlock (~> 1.0)
|
||||
stringio (3.1.6)
|
||||
strong_migrations (2.3.0)
|
||||
strong_migrations (2.2.1)
|
||||
activerecord (>= 7)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
@ -851,7 +851,7 @@ GEM
|
||||
temple (0.10.3)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
terrapin (1.1.0)
|
||||
terrapin (1.0.1)
|
||||
climate_control
|
||||
test-prof (1.4.4)
|
||||
thor (1.3.2)
|
||||
@ -1085,4 +1085,4 @@ RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.7
|
||||
2.6.6
|
||||
|
||||
@ -80,7 +80,7 @@
|
||||
"admin.dashboard.retention.cohort_size": "Noví uživatelé",
|
||||
"admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny",
|
||||
"admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli",
|
||||
"admin.impact_report.instance_follows": "Sledující, o které by jejich uživatelé přišli",
|
||||
"admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli",
|
||||
"admin.impact_report.title": "Shrnutí dopadu",
|
||||
"alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.",
|
||||
"alert.rate_limited.title": "Spojení omezena",
|
||||
@ -102,7 +102,7 @@
|
||||
"annual_report.summary.archetype.replier": "Sociální motýlek",
|
||||
"annual_report.summary.followers.followers": "sledujících",
|
||||
"annual_report.summary.followers.total": "{count} celkem",
|
||||
"annual_report.summary.here_it_is": "Zde je tvůj rok {year} v přehledu:",
|
||||
"annual_report.summary.here_it_is": "Zde je tvůj {year} v přehledu:",
|
||||
"annual_report.summary.highlighted_post.by_favourites": "nejvíce oblíbený příspěvek",
|
||||
"annual_report.summary.highlighted_post.by_reblogs": "nejvíce boostovaný příspěvek",
|
||||
"annual_report.summary.highlighted_post.by_replies": "příspěvek s nejvíce odpověďmi",
|
||||
@ -268,7 +268,7 @@
|
||||
"domain_pill.activitypub_like_language": "ActivityPub je jako jazyk, kterým Mastodon mluví s jinými sociálními sítěmi.",
|
||||
"domain_pill.server": "Server",
|
||||
"domain_pill.their_handle": "Handle:",
|
||||
"domain_pill.their_server": "Jejich digitální domov, kde žijí všechny jejich příspěvky.",
|
||||
"domain_pill.their_server": "Jejich digitální domov, kde žijí jejich všechny příspěvky.",
|
||||
"domain_pill.their_username": "Jejich jedinečný identifikátor na jejich serveru. Je možné, že na jiných serverech jsou uživatelé se stejným uživatelským jménem.",
|
||||
"domain_pill.username": "Uživatelské jméno",
|
||||
"domain_pill.whats_in_a_handle": "Co obsahuje handle?",
|
||||
@ -573,7 +573,7 @@
|
||||
"notification.label.private_reply": "Privátní odpověď",
|
||||
"notification.label.reply": "Odpověď",
|
||||
"notification.mention": "Zmínka",
|
||||
"notification.mentioned_you": "{name} vás zmínil*a",
|
||||
"notification.mentioned_you": "{name} vás zmínil",
|
||||
"notification.moderation-warning.learn_more": "Zjistit více",
|
||||
"notification.moderation_warning": "Obdrželi jste varování od moderátorů",
|
||||
"notification.moderation_warning.action_delete_statuses": "Některé z vašich příspěvků byly odstraněny.",
|
||||
|
||||
@ -248,7 +248,6 @@
|
||||
"filter_modal.select_filter.search": "Nadi neɣ snulfu-d",
|
||||
"filter_modal.select_filter.title": "Sizdeg tassufeɣt-a",
|
||||
"filter_modal.title.status": "Sizdeg tassufeɣt",
|
||||
"filtered_notifications_banner.title": "Ilɣa yettwasizdgen",
|
||||
"firehose.all": "Akk",
|
||||
"firehose.local": "Deg uqeddac-ayi",
|
||||
"firehose.remote": "Iqeddacen nniḍen",
|
||||
@ -427,7 +426,6 @@
|
||||
"notification_requests.edit_selection": "Ẓreg",
|
||||
"notification_requests.exit_selection": "Immed",
|
||||
"notification_requests.notifications_from": "Alɣuten sɣur {name}",
|
||||
"notification_requests.title": "Ilɣa yettwasizdgen",
|
||||
"notifications.clear": "Sfeḍ alɣuten",
|
||||
"notifications.clear_confirmation": "Tebɣiḍ s tidet ad tekkseḍ akk alɣuten-inek·em i lebda?",
|
||||
"notifications.column_settings.admin.report": "Ineqqisen imaynuten:",
|
||||
|
||||
@ -65,7 +65,6 @@
|
||||
"account.statuses_counter": "{count, plural, one {{counter} gönderi} other {{counter} gönderi}}",
|
||||
"account.unblock": "@{name} adlı kişinin engelini kaldır",
|
||||
"account.unblock_domain": "{domain} alan adının engelini kaldır",
|
||||
"account.unblock_domain_short": "Engeli kaldır",
|
||||
"account.unblock_short": "Engeli kaldır",
|
||||
"account.unendorse": "Profilimde öne çıkarma",
|
||||
"account.unfollow": "Takibi bırak",
|
||||
|
||||
@ -1,34 +1,21 @@
|
||||
{
|
||||
"about.blocks": "باشقۇرۇلىدىغان مۇلازىمېتىر",
|
||||
"about.contact": "ئالاقە:",
|
||||
"about.disclaimer": "Mastodon ھەقسىز، ئوچۇق كودلۇق يۇمشاق دېتال تاۋار ماركىسى Mastodon gGmbH غا تەۋە.",
|
||||
"about.domain_blocks.no_reason_available": "سەۋەبىنى ئىشلەتكىلى بولمايدۇ",
|
||||
"account.badges.bot": "ماشىنا ئادەم",
|
||||
"account.cancel_follow_request": "ئەگىشىش ئىلتىماسىدىن ۋاز كەچ",
|
||||
"account.posts": "يازما",
|
||||
"account.posts_with_replies": "يازما ۋە ئىنكاس",
|
||||
"account.report": "@{name} نى پاش قىل",
|
||||
"about.blocks": "ئوتتۇراھال مۇلازىمېتىر",
|
||||
"about.contact": "ئالاقىلاشقۇچى:",
|
||||
"account.badges.bot": "Bot",
|
||||
"account.cancel_follow_request": "Withdraw follow request",
|
||||
"account.posts": "Toots",
|
||||
"account.posts_with_replies": "Toots and replies",
|
||||
"account.requested": "Awaiting approval",
|
||||
"account_note.placeholder": "چېكىلسە ئىزاھات قوشىدۇ",
|
||||
"column.pins": "چوققىلانغان يازما",
|
||||
"community.column_settings.media_only": "ۋاسىتەلا",
|
||||
"account_note.placeholder": "Click to add a note",
|
||||
"column.pins": "Pinned toot",
|
||||
"community.column_settings.media_only": "Media only",
|
||||
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.",
|
||||
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",
|
||||
"compose_form.placeholder": "What is on your mind?",
|
||||
"compose_form.publish_form": "يېڭى يازما",
|
||||
"compose_form.reply": "جاۋاب",
|
||||
"compose_form.save_changes": "يېڭىلا",
|
||||
"compose_form.spoiler.marked": "مەزمۇن ئاگاھلاندۇرۇشىنى چىقىرىۋەت",
|
||||
"compose_form.publish_form": "Publish",
|
||||
"compose_form.spoiler.marked": "Text is hidden behind warning",
|
||||
"compose_form.spoiler.unmarked": "Text is not hidden",
|
||||
"compose_form.spoiler_placeholder": "مەزمۇن ئاگاھلاندۇرۇشى (تاللاشچان)",
|
||||
"confirmation_modal.cancel": "ۋاز كەچ",
|
||||
"confirmations.block.confirm": "توس",
|
||||
"confirmations.delete.message": "بۇ يازمىنى راستىنلا ئۆچۈرەمسىز؟",
|
||||
"confirmations.delete.title": "يازما ئۆچۈرەمدۇ؟",
|
||||
"confirmations.delete_list.confirm": "ئۆچۈر",
|
||||
"confirmations.delete_list.message": "بۇ تىزىمنى راستتىنلا مەڭگۈلۈك ئۆچۈرەمسىز؟",
|
||||
"confirmations.delete_list.title": "تىزىمنى ئۆچۈرەمدۇ؟",
|
||||
"confirmations.discard_edit_media.confirm": "تاشلىۋەت",
|
||||
"confirmations.delete.message": "Are you sure you want to delete this status?",
|
||||
"embed.instructions": "Embed this status on your website by copying the code below.",
|
||||
"empty_column.account_timeline": "No toots here!",
|
||||
"empty_column.bookmarked_statuses": "You don't have any bookmarked toots yet. When you bookmark one, it will show up here.",
|
||||
|
||||
@ -45,6 +45,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
@unresolved_mentions = []
|
||||
@silenced_account_ids = []
|
||||
@params = {}
|
||||
@quote = nil
|
||||
@quote_uri = nil
|
||||
|
||||
process_status_params
|
||||
process_tags
|
||||
@ -55,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
attach_tags(@status)
|
||||
attach_mentions(@status)
|
||||
attach_counts(@status)
|
||||
attach_quote(@status)
|
||||
end
|
||||
|
||||
resolve_thread(@status)
|
||||
@ -189,6 +192,14 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def attach_quote(status)
|
||||
return if @quote.nil?
|
||||
|
||||
@quote.status = status
|
||||
ActivityPub::VerifyQuoteService.new.call(@quote, expected_quoted_uri: @quote_uri)
|
||||
@quote.save
|
||||
end
|
||||
|
||||
def process_tags
|
||||
return if @object['tag'].nil?
|
||||
|
||||
@ -199,6 +210,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
process_mention tag
|
||||
elsif equals_or_includes?(tag['type'], 'Emoji')
|
||||
process_emoji tag
|
||||
elsif equals_or_includes?(tag['type'], 'Link')
|
||||
process_link tag
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -246,6 +259,19 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
end
|
||||
end
|
||||
|
||||
def process_link(tag)
|
||||
return if tag['href'].blank? || @quote.present?
|
||||
|
||||
if tag['mediaType'] == 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' && tag['rel'] == 'https://misskey-hub.net/ns#_misskey_quote'
|
||||
@quote = Quote.new(account: @account, approval_uri: tag['approvedBy'])
|
||||
@quote_uri = tag['href']
|
||||
|
||||
# TODO: error handling
|
||||
@quote.status = ActivityPub::TagManager.instance.uri_to_resource(tag['href'], Status)
|
||||
@quote.status ||= ActivityPub::FetchRemoteStatusService.new.call(tag['href'], on_behalf_of: @account.followers.local.first, request_id: @options[:request_id])
|
||||
end
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return [] if @object['attachment'].nil?
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -43,6 +43,9 @@ class Notification < ApplicationRecord
|
||||
reblog: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
quote: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
follow: {
|
||||
filterable: true,
|
||||
}.freeze,
|
||||
@ -80,6 +83,7 @@ class Notification < ApplicationRecord
|
||||
TARGET_STATUS_INCLUDES_BY_TYPE = {
|
||||
status: :status,
|
||||
reblog: [status: :reblog],
|
||||
quote: [status: :quoted_status],
|
||||
mention: [mention: :status],
|
||||
favourite: [favourite: :status],
|
||||
poll: [poll: :status],
|
||||
@ -118,6 +122,8 @@ class Notification < ApplicationRecord
|
||||
status
|
||||
when :reblog
|
||||
status&.reblog
|
||||
when :quote
|
||||
status&.quoted_status
|
||||
when :favourite
|
||||
favourite&.status
|
||||
when :mention
|
||||
|
||||
51
app/models/quote.rb
Normal file
51
app/models/quote.rb
Normal file
@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: quotes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# activity_uri :string
|
||||
# approval_uri :string
|
||||
# state :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint(8)
|
||||
# quoted_account_id :bigint(8)
|
||||
# quoted_status_id :bigint(8)
|
||||
# status_id :bigint(8) not null
|
||||
#
|
||||
class Quote < ApplicationRecord
|
||||
enum :state,
|
||||
{ pending: 0, accepted: 1, rejected: 2, revoked: 3 },
|
||||
validate: true
|
||||
|
||||
belongs_to :status
|
||||
belongs_to :quoted_status, class_name: 'Status', optional: true
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :quoted_account, class_name: 'Account', optional: true
|
||||
|
||||
before_validation :set_accounts!
|
||||
|
||||
validates :activity_uri, presence: true, if: -> { account.local? && quoted_account&.remote? }
|
||||
|
||||
def accept!
|
||||
update(state: :accepted)
|
||||
end
|
||||
|
||||
def reject!
|
||||
if accepted?
|
||||
update(state: :revoked)
|
||||
elsif !revoked?
|
||||
update(state: :rejected)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accounts!
|
||||
self.account = status.account
|
||||
self.quoted_account = quoted_status&.account
|
||||
end
|
||||
end
|
||||
@ -44,6 +44,13 @@ class Status < ApplicationRecord
|
||||
|
||||
MEDIA_ATTACHMENTS_LIMIT = 4
|
||||
|
||||
QUOTE_APPROVAL_POLICY_FLAGS = {
|
||||
public: (1 << 0),
|
||||
followers: (1 << 1),
|
||||
followed: (1 << 2),
|
||||
unknown: (1 << 3),
|
||||
}.freeze
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
self.discard_column = :deleted_at
|
||||
@ -93,6 +100,7 @@ class Status < ApplicationRecord
|
||||
has_one :status_stat, inverse_of: :status, dependent: nil
|
||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||
has_one :trend, class_name: 'StatusTrend', inverse_of: :status, dependent: nil
|
||||
has_one :quote, inverse_of: :status, dependent: :destroy
|
||||
|
||||
validates :uri, uniqueness: true, presence: true, unless: :local?
|
||||
validates :text, presence: true, unless: -> { with_media? || reblog? }
|
||||
@ -154,6 +162,7 @@ class Status < ApplicationRecord
|
||||
:status_stat,
|
||||
:tags,
|
||||
:preloadable_poll,
|
||||
quote: { status: { account: [:account_stat, user: :role] } },
|
||||
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
|
||||
account: [:account_stat, user: :role],
|
||||
active_mentions: :account,
|
||||
@ -164,6 +173,7 @@ class Status < ApplicationRecord
|
||||
:conversation,
|
||||
:status_stat,
|
||||
:preloadable_poll,
|
||||
:quote,
|
||||
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
|
||||
account: [:account_stat, user: :role],
|
||||
active_mentions: :account,
|
||||
|
||||
@ -21,7 +21,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
|
||||
end
|
||||
|
||||
def status_type?
|
||||
[:favourite, :reblog, :status, :mention, :poll, :update].include?(object.type)
|
||||
[:favourite, :reblog, :quote, :status, :mention, :poll, :update].include?(object.type)
|
||||
end
|
||||
|
||||
def report_type?
|
||||
|
||||
7
app/serializers/rest/quote_serializer.rb
Normal file
7
app/serializers/rest/quote_serializer.rb
Normal file
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class REST::QuoteSerializer < ActiveModel::Serializer
|
||||
attributes :state
|
||||
|
||||
has_one :quoted_status, serializer: REST::StatusSerializer
|
||||
end
|
||||
@ -29,6 +29,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||
has_many :tags
|
||||
has_many :emojis, serializer: REST::CustomEmojiSerializer
|
||||
|
||||
has_one :quote, key: :quote, serializer: REST::QuoteSerializer
|
||||
has_one :preview_card, key: :card, serializer: REST::PreviewCardSerializer
|
||||
has_one :preloadable_poll, key: :poll, serializer: REST::PollSerializer
|
||||
|
||||
|
||||
91
app/services/activitypub/verify_quote_service.rb
Normal file
91
app/services/activitypub/verify_quote_service.rb
Normal file
@ -0,0 +1,91 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::VerifyQuoteService < BaseService
|
||||
include JsonLdHelper
|
||||
|
||||
def call(quote, expected_quoted_uri: nil, prefetched_body: nil)
|
||||
@quote = quote
|
||||
return if fast_track_approval! || quote.approval_uri.blank?
|
||||
|
||||
@json = fetch_approval_object(quote.approval_uri, prefetched_body:)
|
||||
if @json.nil?
|
||||
# It is possible the proof isn't dereferenceable yet, so only reject if already accepted
|
||||
quote.reject! unless @quote.pending?
|
||||
return
|
||||
end
|
||||
|
||||
return if non_matching_uri_hosts?(quote.approval_uri, value_or_id(@json['attributedTo']))
|
||||
return unless matching_type? && matching_quote_uri?
|
||||
|
||||
# Opportunistically import embedded posts if needed
|
||||
return if import_quoted_post_if_needed!(expected_quoted_uri) && fast_track_approval!
|
||||
|
||||
return unless matching_quoted_post? && matching_quoted_author?
|
||||
|
||||
quote.accept!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# FEP-044f defines rules that don't require the approval flow
|
||||
def fast_track_approval!
|
||||
return false if @quote.quoted_status_id.blank?
|
||||
|
||||
# Always allow someone to quote themselves
|
||||
if @quote.account_id == @quote.quoted_account_id
|
||||
@quote.accept!
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Always allow someone to quote posts in which they are mentioned
|
||||
if @quote.quoted_status.active_mentions.exists?(mentions: { account_id: @quote.account_id })
|
||||
@quote.accept!
|
||||
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_approval_object(uri, prefetched_body: nil)
|
||||
if prefetched_body.nil?
|
||||
fetch_resource(uri, true, @quote.account, raise_on_error: :temporary)
|
||||
else
|
||||
body_to_json(prefetched_body, compare_id: uri)
|
||||
end
|
||||
end
|
||||
|
||||
def matching_type?
|
||||
supported_context?(@json) && equals_or_includes?(@json['type'], 'QuoteAuthorization')
|
||||
end
|
||||
|
||||
def matching_quote_uri?
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.status) == value_or_id(@json['interactingObject'])
|
||||
end
|
||||
|
||||
def import_quoted_post_if_needed!(uri)
|
||||
return if uri.nil? || @quote.quoted_status_id.present?
|
||||
return if value_or_id(@json['interactionTarget']) == uri
|
||||
|
||||
status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
|
||||
# TODO: import embedded post
|
||||
|
||||
if status.present?
|
||||
quote.update(status: status)
|
||||
true
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def matching_quoted_post?
|
||||
return false if @quote.quoted_status_id.blank?
|
||||
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.quoted_status) == value_or_id(@json['interactionTarget'])
|
||||
end
|
||||
|
||||
def matching_quoted_author?
|
||||
ActivityPub::TagManager.instance.uri_for(@quote.quoted_account) == value_or_id(@json['attributedTo'])
|
||||
end
|
||||
end
|
||||
@ -459,9 +459,7 @@ lv:
|
||||
create: Pievienot domēnu
|
||||
resolve: Atrisināt domēnu
|
||||
title: Liegt jaunu e-pasta domēnu
|
||||
no_email_domain_block_selected: Neviens e-pasta domēna bloks netika mainīts, jo neviens netika atlasīts
|
||||
not_permitted: Nav atļauta
|
||||
resolved_dns_records_hint_html: Domēna vārds saistās ar zemāk norādītajiem MX domēniem, kuri beigās ir atbildīgi par e-pasta pieņemšana. MX domēna liegšana liegs reģistrēšanos no jebkuras e-pasta adreses, kas izmanto to pašu MX domēnu, pat ja redzamais domēna vārds ir atšķirīgs. <strong>Jāuzmanās, lai neliegtu galvenos e-pasta pakalpojuma sniedzējus.</strong>
|
||||
resolved_through_html: Atrisināts, izmantojot %{domain}
|
||||
title: Bloķētie e-pasta domēni
|
||||
export_domain_allows:
|
||||
@ -479,13 +477,6 @@ lv:
|
||||
new:
|
||||
title: Importēt bloķētos domēnus
|
||||
no_file: Nav atlasīts neviens fails
|
||||
fasp:
|
||||
debug:
|
||||
callbacks:
|
||||
created_at: Izveidots
|
||||
delete: Izdzēst
|
||||
ip: IP adrese
|
||||
request_body: Pieprasījuma saturs
|
||||
follow_recommendations:
|
||||
description_html: "<strong>Sekošanas ieteikumi palīdz jauniem lietotājiem ātri arast saistošu saturu</strong>. Kad lietotājs nav pietiekami mijiedarbojies ar citiem, lai veidotos pielāgoti sekošanas iteikumi, tiek ieteikti šie konti. Tie tiek pārskaitļoti ik dienas, izmantojot kontu, kuriem ir augstākās nesenās iesaistīšanās un lielākais vietējo sekotāju skaits norādītajā valodā."
|
||||
language: Valodai
|
||||
|
||||
@ -84,7 +84,7 @@ de:
|
||||
backups_retention_period: Nutzer*innen haben die Möglichkeit, Archive ihrer Beiträge zu erstellen, die sie später herunterladen können. Wenn ein positiver Wert gesetzt ist, werden diese Archive nach der festgelegten Anzahl von Tagen automatisch aus deinem Speicher gelöscht.
|
||||
bootstrap_timeline_accounts: Diese Konten werden bei den Follower-Empfehlungen für neu registrierte Nutzer*innen oben angeheftet.
|
||||
closed_registrations_message: Wird angezeigt, wenn Registrierungen deaktiviert sind
|
||||
content_cache_retention_period: Sämtliche Beiträge von anderen Servern (einschließlich geteilte Beiträge und Antworten) werden, unabhängig von der Interaktion der lokalen Nutzer*innen mit diesen Beiträgen, nach der festgelegten Anzahl von Tagen gelöscht. Das betrifft auch Beiträge, die von lokalen Nutzer*innen favorisiert oder als Lesezeichen gespeichert wurden. Private Erwähnungen zwischen Nutzer*innen von verschiedenen Servern werden ebenfalls verloren gehen und können nicht wiederhergestellt werden. Diese Option richtet sich ausschließlich an Server mit speziellen Zwecken und wird die allgemeine Nutzungserfahrung beeinträchtigen, wenn sie für den allgemeinen Gebrauch aktiviert ist.
|
||||
content_cache_retention_period: Sämtliche Beiträge von anderen Servern (einschließlich geteilte Beiträge und Antworten) werden, unabhängig von der Interaktion der lokalen Nutzer*innen mit diesen Beiträgen, nach der festgelegten Anzahl von Tagen gelöscht. Das betrifft auch Beiträge, die von lokalen Nutzer*innen favorisiert oder als Lesezeichen gespeichert wurden. Private Erwähnungen zwischen Nutzer*innen von verschiedenen Servern werden ebenfalls verloren gehen und können nicht wiederhergestellt werden. Das Verwenden dieser Option richtet sich ausschließlich an Server für spezielle Zwecke und wird die allgemeine Nutzungserfahrung beeinträchtigen, wenn sie für den allgemeinen Gebrauch aktiviert ist.
|
||||
custom_css: Du kannst benutzerdefinierte Stile auf die Web-Version von Mastodon anwenden.
|
||||
favicon: WEBP, PNG, GIF oder JPG. Überschreibt das Standard-Mastodon-Favicon mit einem eigenen Symbol.
|
||||
mascot: Überschreibt die Abbildung in der erweiterten Weboberfläche.
|
||||
|
||||
19
db/migrate/20250404094808_create_quotes.rb
Normal file
19
db/migrate/20250404094808_create_quotes.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateQuotes < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
create_table :quotes do |t|
|
||||
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false
|
||||
t.belongs_to :status, foreign_key: { on_delete: :cascade }, index: { unique: true }, null: false
|
||||
t.belongs_to :quoted_status, foreign_key: { to_table: :statuses, on_delete: :nullify }, null: true
|
||||
t.belongs_to :quoted_account, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true
|
||||
t.integer :state, null: false, default: 0
|
||||
t.string :approval_uri, index: { where: 'approval_uri IS NOT NULL' }
|
||||
t.string :activity_uri, index: { unique: true, where: 'activity_uri IS NOT NULL' }
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :quotes, [:account_id, :quoted_account_id]
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddQuoteApprovalPolicyToStatuses < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :statuses, :quote_approval_policy, :integer
|
||||
end
|
||||
end
|
||||
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_03_13_123400) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_04_04_095029) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
@ -884,6 +884,24 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
t.string "url"
|
||||
end
|
||||
|
||||
create_table "quotes", force: :cascade do |t|
|
||||
t.bigint "account_id"
|
||||
t.bigint "status_id", null: false
|
||||
t.bigint "quoted_status_id"
|
||||
t.bigint "quoted_account_id"
|
||||
t.integer "state", default: 0, null: false
|
||||
t.string "approval_uri"
|
||||
t.string "activity_uri"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "quoted_account_id"], name: "index_quotes_on_account_id_and_quoted_account_id"
|
||||
t.index ["activity_uri"], name: "index_quotes_on_activity_uri", unique: true, where: "(activity_uri IS NOT NULL)"
|
||||
t.index ["approval_uri"], name: "index_quotes_on_approval_uri", where: "(approval_uri IS NOT NULL)"
|
||||
t.index ["quoted_account_id"], name: "index_quotes_on_quoted_account_id"
|
||||
t.index ["quoted_status_id"], name: "index_quotes_on_quoted_status_id"
|
||||
t.index ["status_id"], name: "index_quotes_on_status_id", unique: true
|
||||
end
|
||||
|
||||
create_table "relationship_severance_events", force: :cascade do |t|
|
||||
t.integer "type", null: false
|
||||
t.string "target_name", null: false
|
||||
@ -1080,6 +1098,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
t.boolean "trendable"
|
||||
t.bigint "ordered_media_attachment_ids", array: true
|
||||
t.datetime "fetched_replies_at"
|
||||
t.integer "quote_approval_policy"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
t.index ["account_id"], name: "index_statuses_on_account_id"
|
||||
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
|
||||
@ -1364,6 +1383,10 @@ ActiveRecord::Schema[8.0].define(version: 2025_03_13_123400) do
|
||||
add_foreign_key "polls", "statuses", on_delete: :cascade
|
||||
add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
|
||||
add_foreign_key "preview_cards", "accounts", column: "author_account_id", on_delete: :nullify
|
||||
add_foreign_key "quotes", "accounts", column: "quoted_account_id", on_delete: :nullify
|
||||
add_foreign_key "quotes", "accounts", on_delete: :cascade
|
||||
add_foreign_key "quotes", "statuses", column: "quoted_status_id", on_delete: :nullify
|
||||
add_foreign_key "quotes", "statuses", on_delete: :cascade
|
||||
add_foreign_key "report_notes", "accounts", on_delete: :cascade
|
||||
add_foreign_key "report_notes", "reports", on_delete: :cascade
|
||||
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
|
||||
|
||||
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
|
||||
@ -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
|
||||
|
||||
27
spec/serializers/rest/quote_serializer_spec.rb
Normal file
27
spec/serializers/rest/quote_serializer_spec.rb
Normal file
@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe REST::QuoteSerializer do
|
||||
subject do
|
||||
serialized_record_json(
|
||||
quote,
|
||||
described_class,
|
||||
options: {
|
||||
scope: current_user,
|
||||
scope_name: :current_user,
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:current_user) { Fabricate(:user) }
|
||||
let(:quote) { Fabricate(:quote) }
|
||||
|
||||
it 'returns expected values' do
|
||||
expect(subject.deep_symbolize_keys)
|
||||
.to include(
|
||||
quoted_status: be_a(Hash),
|
||||
state: 'pending'
|
||||
)
|
||||
end
|
||||
end
|
||||
169
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
169
spec/services/activitypub/verify_quote_service_spec.rb
Normal file
@ -0,0 +1,169 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
# TODO: high-level tests
|
||||
# - fetch unapproved post with a posteriori approval
|
||||
# - fetch implicitly approved post
|
||||
# - handle explicit revocation (`Delete`)
|
||||
# - handle post getting unapproved (`Update`)
|
||||
|
||||
RSpec.describe ActivityPub::VerifyQuoteService do
|
||||
subject { described_class.new }
|
||||
|
||||
let(:account) { Fabricate(:account, domain: 'a.example.com') }
|
||||
let(:quoted_account) { Fabricate(:account, domain: 'b.example.com') }
|
||||
let(:quoted_status) { Fabricate(:status, account: quoted_account) }
|
||||
let(:status) { Fabricate(:status, account: account) }
|
||||
let(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri) }
|
||||
|
||||
context 'with an approval URI' do
|
||||
let(:approval_uri) { 'https://b.example.com/approvals/1234' }
|
||||
|
||||
let(:approval_type) { 'QuoteAuthorization' }
|
||||
let(:approval_id) { approval_uri }
|
||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(quoted_account) }
|
||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(status) }
|
||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(quoted_status) }
|
||||
|
||||
let(:json) do
|
||||
{
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
{
|
||||
toot: 'http://joinmastodon.org/ns#',
|
||||
QuoteAuthorization: 'toot:QuoteAuthorization',
|
||||
gts: 'https://gotosocial.org/ns#',
|
||||
interactionPolicy: {
|
||||
'@id': 'gts:interactionPolicy',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactingObject: {
|
||||
'@id': 'gts:interactingObject',
|
||||
'@type': '@id',
|
||||
},
|
||||
interactionTarget: {
|
||||
'@id': 'gts:interactionTarget',
|
||||
'@type': '@id',
|
||||
},
|
||||
},
|
||||
],
|
||||
type: approval_type,
|
||||
id: approval_id,
|
||||
attributedTo: approval_attributed_to,
|
||||
interactingObject: approval_interacting_object,
|
||||
interactionTarget: approval_interaction_target,
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
before do
|
||||
stub_request(:get, approval_uri)
|
||||
.to_return(status: 200, body: Oj.dump(json), headers: { 'Content-Type': 'application/activity+json' })
|
||||
end
|
||||
|
||||
context 'with a valid activity for already-fetched posts' do
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to have_been_made.once
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a valid activity for already-fetched posts, with a pre-fetched approval' do
|
||||
it 'updates the status without fetching the activity' do
|
||||
expect { subject.call(quote, prefetched_body: Oj.dump(json)) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
|
||||
expect(a_request(:get, approval_uri))
|
||||
.to_not have_been_made
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an unverifiable approval' do
|
||||
let(:approval_uri) { 'https://evil.com/approvals/1234' }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a mismatched ID' do
|
||||
let(:approval_id) { 'https://evil.com/approvals/1234' }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval from the wrong account' do
|
||||
let(:approval_attributed_to) { ActivityPub::TagManager.instance.uri_for(Fabricate(:account, domain: 'b.example.com')) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval for the wrong quoted post' do
|
||||
let(:approval_interaction_target) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: quoted_account)) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval for the wrong quote post' do
|
||||
let(:approval_interacting_object) { ActivityPub::TagManager.instance.uri_for(Fabricate(:status, account: account)) }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an approval of the wrong type' do
|
||||
let(:approval_type) { 'ReplyAuthorization' }
|
||||
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with fast-track authorizations' do
|
||||
let(:approval_uri) { nil }
|
||||
|
||||
context 'without any fast-track condition' do
|
||||
it 'does not update the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to_not change(quote, :state)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account and the quoted account are the same' do
|
||||
let(:quoted_account) { account }
|
||||
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the account is mentioned by the quoted post' do
|
||||
before do
|
||||
quoted_status.mentions << Mention.new(account: account)
|
||||
end
|
||||
|
||||
it 'updates the status' do
|
||||
expect { subject.call(quote) }
|
||||
.to change(quote, :state).to('accepted')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -15738,8 +15738,8 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"sass@npm:^1.62.1":
|
||||
version: 1.86.3
|
||||
resolution: "sass@npm:1.86.3"
|
||||
version: 1.86.2
|
||||
resolution: "sass@npm:1.86.2"
|
||||
dependencies:
|
||||
"@parcel/watcher": "npm:^2.4.1"
|
||||
chokidar: "npm:^4.0.0"
|
||||
@ -15750,7 +15750,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
sass: sass.js
|
||||
checksum: 10c0/ba819a0828f732adf7a94cd8ca017bce92bc299ffb878836ed1da80a30612bfbbf56a5e42d6dff3ad80d919c2025afb42948fc7b54a7bc61a9a2d58e1e0c558a
|
||||
checksum: 10c0/fe40b63a19e867f460369d495bcdc979598c0b105c9d52164a7e9cc4e12ab91c27337702a1b58575f7da8fd08256b85fd2f34cc8275eb7c57699bfc5029f54a3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user