fetch_link_card_service.rb 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. # frozen_string_literal: true
  2. class FetchLinkCardService < BaseService
  3. include Redisable
  4. include Lockable
  5. URL_PATTERN = %r{
  6. (#{Twitter::TwitterText::Regex[:valid_url_preceding_chars]}) # $1 preceding chars
  7. ( # $2 URL
  8. (https?://) # $3 Protocol (required)
  9. (#{Twitter::TwitterText::Regex[:valid_domain]}) # $4 Domain(s)
  10. (?::(#{Twitter::TwitterText::Regex[:valid_port_number]}))? # $5 Port number (optional)
  11. (/#{Twitter::TwitterText::Regex[:valid_url_path]}*)? # $6 URL Path and anchor
  12. (\?#{Twitter::TwitterText::Regex[:valid_url_query_chars]}*#{Twitter::TwitterText::Regex[:valid_url_query_ending_chars]})? # $7 Query String
  13. )
  14. }iox
  15. def call(status)
  16. @status = status
  17. @original_url = parse_urls
  18. return if @original_url.nil? || @status.with_preview_card?
  19. @url = @original_url.to_s
  20. return process_local_url if TagManager.instance.local_url?(@url)
  21. with_redis_lock("fetch:#{@original_url}") do
  22. @card = PreviewCard.find_by(url: @url)
  23. process_url if @card.nil? || @card.updated_at <= 2.weeks.ago || @card.missing_image?
  24. end
  25. attach_card if @card&.persisted?
  26. rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e
  27. Rails.logger.debug { "Error fetching link #{@original_url}: #{e}" }
  28. nil
  29. end
  30. private
  31. def process_url
  32. @card ||= PreviewCard.new(url: @url)
  33. attempt_oembed || attempt_opengraph
  34. end
  35. def process_local_url
  36. recognized_params = Rails.application.routes.recognize_path(@url)
  37. return unless recognized_params[:action] == 'show'
  38. @card = nil
  39. case recognized_params[:controller]
  40. when 'statuses'
  41. status = Status.where(visibility: [:public, :unlisted]).find_by(id: recognized_params[:id])
  42. @card = LocalPreviewCard.create(status: @status, target_status: status)
  43. when 'accounts'
  44. account = Account.find_local(recognized_params[:username])
  45. @card = LocalPreviewCard.create(status: @status, target_account: account)
  46. end
  47. Rails.cache.delete(@status) if @card.present?
  48. end
  49. def html
  50. return @html if defined?(@html)
  51. @html = Request.new(:get, @url).add_headers('Accept' => 'text/html', 'User-Agent' => "#{Mastodon::Version.user_agent} Bot").perform do |res|
  52. next unless res.code == 200 && res.mime_type == 'text/html'
  53. # We follow redirects, and ideally we want to save the preview card for
  54. # the destination URL and not any link shortener in-between, so here
  55. # we set the URL to the one of the last response in the redirect chain
  56. @url = res.request.uri.to_s
  57. @card = PreviewCard.find_or_initialize_by(url: @url) if @card.url != @url
  58. @html_charset = res.charset
  59. res.body_with_limit
  60. end
  61. end
  62. def attach_card
  63. with_redis_lock("attach_card:#{@status.id}") do
  64. return if @status.with_preview_card?
  65. PreviewCardsStatus.create(status: @status, preview_card: @card, url: @original_url)
  66. Rails.cache.delete(@status)
  67. Trends.links.register(@status)
  68. end
  69. end
  70. def parse_urls
  71. urls = if @status.local?
  72. @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
  73. else
  74. document = Nokogiri::HTML(@status.text)
  75. links = document.css('a')
  76. links.filter_map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.filter_map(&:normalize)
  77. end
  78. urls.reject { |uri| bad_url?(uri) }.first
  79. end
  80. def bad_url?(uri)
  81. # Avoid local instance URLs and invalid URLs
  82. uri.host.blank? || bad_local_url?(uri.to_s) || !%w(http https).include?(uri.scheme)
  83. end
  84. def bad_local_url?(uri)
  85. return false unless TagManager.instance.local_url?(uri.to_s)
  86. recognized_params = Rails.application.routes.recognize_path(uri)
  87. recognized_params[:action] != 'show' || %w(accounts statuses).exclude?(recognized_params[:controller])
  88. end
  89. def mention_link?(anchor)
  90. @status.mentions.any? do |mention|
  91. anchor['href'] == ActivityPub::TagManager.instance.url_for(mention.account)
  92. end
  93. end
  94. def skip_link?(anchor)
  95. # Avoid links for hashtags and mentions (microformats)
  96. anchor['rel']&.include?('tag') || anchor['class']&.match?(/u-url|h-card/) || mention_link?(anchor)
  97. end
  98. def attempt_oembed
  99. service = FetchOEmbedService.new
  100. url_domain = Addressable::URI.parse(@url).normalized_host
  101. cached_endpoint = Rails.cache.read("oembed_endpoint:#{url_domain}")
  102. embed = service.call(@url, cached_endpoint: cached_endpoint) unless cached_endpoint.nil?
  103. embed ||= service.call(@url, html: html) unless html.nil?
  104. return false if embed.nil?
  105. url = Addressable::URI.parse(service.endpoint_url)
  106. @card.type = embed[:type]
  107. @card.title = embed[:title] || ''
  108. @card.author_name = embed[:author_name] || ''
  109. @card.author_url = embed[:author_url].present? ? (url + embed[:author_url]).to_s : ''
  110. @card.provider_name = embed[:provider_name] || ''
  111. @card.provider_url = embed[:provider_url].present? ? (url + embed[:provider_url]).to_s : ''
  112. @card.width = 0
  113. @card.height = 0
  114. case @card.type
  115. when 'link'
  116. @card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
  117. when 'photo'
  118. return false if embed[:url].blank?
  119. @card.embed_url = (url + embed[:url]).to_s
  120. @card.image_remote_url = (url + embed[:url]).to_s
  121. @card.width = embed[:width].presence || 0
  122. @card.height = embed[:height].presence || 0
  123. when 'video'
  124. @card.width = embed[:width].presence || 0
  125. @card.height = embed[:height].presence || 0
  126. @card.html = Sanitize.fragment(embed[:html], Sanitize::Config::MASTODON_OEMBED)
  127. @card.image_remote_url = (url + embed[:thumbnail_url]).to_s if embed[:thumbnail_url].present?
  128. when 'rich'
  129. # Most providers rely on <script> tags, which is a no-no
  130. return false
  131. end
  132. @card.save_with_optional_image!
  133. end
  134. def attempt_opengraph
  135. return if html.nil?
  136. link_details_extractor = LinkDetailsExtractor.new(@url, @html, @html_charset)
  137. @card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url
  138. @card.assign_attributes(link_details_extractor.to_preview_card_attributes)
  139. @card.save_with_optional_image! unless @card.title.blank? && @card.html.blank?
  140. end
  141. end