Add ability to manage which websites can credit you in link previews (#31819)

This commit is contained in:
Eugen Rochko 2024-09-10 14:00:40 +02:00 committed by GitHub
parent 3929e3c6d2
commit e0c27a5047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 381 additions and 160 deletions

View file

@ -2,14 +2,30 @@
class Settings::VerificationsController < Settings::BaseController
before_action :set_account
before_action :set_verified_links
def show
@verified_links = @account.fields.select(&:verified?)
def show; end
def update
if UpdateAccountService.new.call(@account, account_params)
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
redirect_to settings_verification_path, notice: I18n.t('generic.changes_saved_msg')
else
render :show
end
end
private
def account_params
params.require(:account).permit(:attribution_domains_as_text)
end
def set_account
@account = current_account
end
def set_verified_links
@verified_links = @account.fields.select(&:verified?)
end
end

View file

@ -41,6 +41,7 @@ module ContextHelper
'cipherText' => 'toot:cipherText',
},
suspended: { 'toot' => 'http://joinmastodon.org/ns#', 'suspended' => 'toot:suspended' },
attribution_domains: { 'toot' => 'http://joinmastodon.org/ns#', 'attributionDomains' => { '@id' => 'toot:attributionDomains', '@type' => '@id' } },
}.freeze
def full_context

View file

@ -12,6 +12,41 @@ code {
margin: 50px auto;
}
.form-section {
border-radius: 8px;
background: var(--surface-background-color);
padding: 24px;
margin-bottom: 24px;
}
.fade-out-top {
position: relative;
overflow: hidden;
height: 160px;
&::after {
content: '';
display: block;
background: linear-gradient(
to bottom,
var(--surface-background-color),
transparent
);
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
height: 100px;
pointer-events: none;
}
& > div {
position: absolute;
inset-inline-start: 0;
bottom: 0;
}
}
.indicator-icon {
display: flex;
align-items: center;

View file

@ -51,6 +51,7 @@
# reviewed_at :datetime
# requested_review_at :datetime
# indexable :boolean default(FALSE), not null
# attribution_domains :string default([]), is an Array
#
class Account < ApplicationRecord
@ -88,6 +89,7 @@ class Account < ApplicationRecord
include Account::Merging
include Account::Search
include Account::StatusesSearch
include Account::AttributionDomains
include DomainMaterializable
include DomainNormalizable
include Paginable

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Account::AttributionDomains
extend ActiveSupport::Concern
included do
validates :attribution_domains_as_text, domain: { multiline: true }, lines: { maximum: 100 }, if: -> { local? && will_save_change_to_attribution_domains? }
end
def attribution_domains_as_text
self[:attribution_domains].join("\n")
end
def attribution_domains_as_text=(str)
self[:attribution_domains] = str.split.filter_map do |line|
line.strip.delete_prefix('*.')
end
end
def can_be_attributed_from?(domain)
segments = domain.split('.')
variants = segments.map.with_index { |_, i| segments[i..].join('.') }.to_set
self[:attribution_domains].to_set.intersect?(variants)
end
end

View file

@ -8,7 +8,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
context_extensions :manually_approves_followers, :featured, :also_known_as,
:moved_to, :property_value, :discoverable, :olm, :suspended,
:memorial, :indexable
:memorial, :indexable, :attribution_domains
attributes :id, :type, :following, :followers,
:inbox, :outbox, :featured, :featured_tags,
@ -25,6 +25,7 @@ class ActivityPub::ActorSerializer < ActivityPub::Serializer
attribute :moved_to, if: :moved?
attribute :also_known_as, if: :also_known_as?
attribute :suspended, if: :suspended?
attribute :attribution_domains, if: -> { object.attribution_domains.any? }
class EndpointsSerializer < ActivityPub::Serializer
include RoutingHelper

View file

@ -117,6 +117,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.discoverable = @json['discoverable'] || false
@account.indexable = @json['indexable'] || false
@account.memorial = @json['memorial'] || false
@account.attribution_domains = as_array(@json['attributionDomains'] || []).map { |item| value_or_id(item) }
end
def set_fetchable_key!

View file

@ -153,12 +153,13 @@ class FetchLinkCardService < BaseService
return if html.nil?
link_details_extractor = LinkDetailsExtractor.new(@url, @html, @html_charset)
provider = PreviewCardProvider.matching_domain(Addressable::URI.parse(link_details_extractor.canonical_url).normalized_host)
linked_account = ResolveAccountService.new.call(link_details_extractor.author_account, suppress_errors: true) if link_details_extractor.author_account.present? && provider&.trendable?
domain = Addressable::URI.parse(link_details_extractor.canonical_url).normalized_host
provider = PreviewCardProvider.matching_domain(domain)
linked_account = ResolveAccountService.new.call(link_details_extractor.author_account, suppress_errors: true) if link_details_extractor.author_account.present?
@card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url
@card.assign_attributes(link_details_extractor.to_preview_card_attributes)
@card.author_account = linked_account
@card.author_account = linked_account if linked_account&.can_be_attributed_from?(domain) || provider&.trendable?
@card.save_with_optional_image! unless @card.title.blank? && @card.html.blank?
end
end

View file

@ -1,22 +1,29 @@
# frozen_string_literal: true
class DomainValidator < ActiveModel::EachValidator
MAX_DOMAIN_LENGTH = 256
MIN_LABEL_LENGTH = 1
MAX_LABEL_LENGTH = 63
ALLOWED_CHARACTERS_RE = /^[a-z0-9\-]+$/i
def validate_each(record, attribute, value)
return if value.blank?
domain = if options[:acct]
value.split('@').last
else
value
end
(options[:multiline] ? value.split : [value]).each do |domain|
_, domain = domain.split('@') if options[:acct]
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(domain)
next if domain.blank?
record.errors.add(attribute, options[:multiline] ? :invalid_domain_on_line : :invalid, value: domain) unless compliant?(domain)
end
end
private
def compliant?(value)
Addressable::URI.new.tap { |uri| uri.host = value }
uri = Addressable::URI.new
uri.host = value
uri.normalized_host.size < MAX_DOMAIN_LENGTH && uri.normalized_host.split('.').all? { |label| label.size.between?(MIN_LABEL_LENGTH, MAX_LABEL_LENGTH) && label =~ ALLOWED_CHARACTERS_RE }
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
false
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class LinesValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
record.errors.add(attribute, :too_many_lines, limit: options[:maximum]) if options[:maximum].present? && value.split.size > options[:maximum]
end
end

View file

@ -5,7 +5,9 @@
%h2= t('settings.profile')
= render partial: 'settings/shared/profile_navigation'
.simple_form
.simple_form.form-section
%h3= t('verification.website_verification')
%p.lead= t('verification.hint_html')
%h4= t('verification.here_is_how')
@ -28,3 +30,33 @@
%span.verified-badge
= material_symbol 'check', class: 'verified-badge__mark'
%span= field.value
= simple_form_for @account, url: settings_verification_path, html: { method: :put, class: 'form-section' } do |f|
= render 'shared/error_messages', object: @account
%h3= t('author_attribution.title')
%p.lead= t('author_attribution.hint_html')
.fields-row
.fields-row__column.fields-row__column-6
.fields-group
= f.input :attribution_domains_as_text, as: :text, wrapper: :with_block_label, input_html: { placeholder: "example1.com\nexample2.com\nexample3.com", rows: 4 }
.fields-row__column.fields-row__column-6
.fields-group.fade-out-top
%div
.status-card.expanded.bottomless
.status-card__image
= image_tag frontend_asset_url('images/preview.png'), alt: '', class: 'status-card__image-image'
.status-card__content
%span.status-card__host
%span= t('author_attribution.s_blog', name: @account.username)
·
%time.time-ago{ datetime: 1.year.ago.to_date.iso8601 }
%strong.status-card__title= t('author_attribution.example_title')
.more-from-author
= logo_as_symbol(:icon)
= t('author_attribution.more_from_html', name: link_to(root_url, class: 'story__details__shared__author-link') { image_tag(@account.avatar.url, class: 'account__avatar', width: 16, height: 16, alt: '') + content_tag(:bdi, display_name(@account)) })
.actions
= f.button :button, t('generic.save_changes'), type: :submit