OEmbed support for PreviewCard (#2337)
* OEmbed support for PreviewCard * Improve ProviderDiscovery code failure treatment * Do not crawl links if there is a content warning, since those don't display a link card anyway * Reset db schema * Fresh migrate * Fix rubocop style issues Fix #1681 - return existing access token when applicable instead of creating new * Fix test * Extract http client to helper * Improve oembed controller
This commit is contained in:
		
							
								
								
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Gemfile
									
									
									
									
									
								
							@@ -49,6 +49,7 @@ gem 'rails-settings-cached'
 | 
				
			|||||||
gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
 | 
					gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
 | 
				
			||||||
gem 'rqrcode'
 | 
					gem 'rqrcode'
 | 
				
			||||||
gem 'ruby-oembed', require: 'oembed'
 | 
					gem 'ruby-oembed', require: 'oembed'
 | 
				
			||||||
 | 
					gem 'sanitize'
 | 
				
			||||||
gem 'sidekiq'
 | 
					gem 'sidekiq'
 | 
				
			||||||
gem 'sidekiq-unique-jobs'
 | 
					gem 'sidekiq-unique-jobs'
 | 
				
			||||||
gem 'simple-navigation'
 | 
					gem 'simple-navigation'
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -123,6 +123,7 @@ GEM
 | 
				
			|||||||
    connection_pool (2.2.1)
 | 
					    connection_pool (2.2.1)
 | 
				
			||||||
    crack (0.4.3)
 | 
					    crack (0.4.3)
 | 
				
			||||||
      safe_yaml (~> 1.0.0)
 | 
					      safe_yaml (~> 1.0.0)
 | 
				
			||||||
 | 
					    crass (1.0.2)
 | 
				
			||||||
    debug_inspector (0.0.2)
 | 
					    debug_inspector (0.0.2)
 | 
				
			||||||
    devise (4.2.1)
 | 
					    devise (4.2.1)
 | 
				
			||||||
      bcrypt (~> 3.0)
 | 
					      bcrypt (~> 3.0)
 | 
				
			||||||
@@ -258,6 +259,8 @@ GEM
 | 
				
			|||||||
    nio4r (2.0.0)
 | 
					    nio4r (2.0.0)
 | 
				
			||||||
    nokogiri (1.7.1)
 | 
					    nokogiri (1.7.1)
 | 
				
			||||||
      mini_portile2 (~> 2.1.0)
 | 
					      mini_portile2 (~> 2.1.0)
 | 
				
			||||||
 | 
					    nokogumbo (1.4.10)
 | 
				
			||||||
 | 
					      nokogiri
 | 
				
			||||||
    oj (2.18.5)
 | 
					    oj (2.18.5)
 | 
				
			||||||
    openssl (2.0.3)
 | 
					    openssl (2.0.3)
 | 
				
			||||||
    orm_adapter (0.5.0)
 | 
					    orm_adapter (0.5.0)
 | 
				
			||||||
@@ -398,6 +401,10 @@ GEM
 | 
				
			|||||||
    ruby-oembed (0.12.0)
 | 
					    ruby-oembed (0.12.0)
 | 
				
			||||||
    ruby-progressbar (1.8.1)
 | 
					    ruby-progressbar (1.8.1)
 | 
				
			||||||
    safe_yaml (1.0.4)
 | 
					    safe_yaml (1.0.4)
 | 
				
			||||||
 | 
					    sanitize (4.4.0)
 | 
				
			||||||
 | 
					      crass (~> 1.0.2)
 | 
				
			||||||
 | 
					      nokogiri (>= 1.4.4)
 | 
				
			||||||
 | 
					      nokogumbo (~> 1.4.1)
 | 
				
			||||||
    sass (3.4.23)
 | 
					    sass (3.4.23)
 | 
				
			||||||
    sass-rails (5.0.6)
 | 
					    sass-rails (5.0.6)
 | 
				
			||||||
      railties (>= 4.0.0, < 6)
 | 
					      railties (>= 4.0.0, < 6)
 | 
				
			||||||
@@ -540,6 +547,7 @@ DEPENDENCIES
 | 
				
			|||||||
  rspec-sidekiq
 | 
					  rspec-sidekiq
 | 
				
			||||||
  rubocop
 | 
					  rubocop
 | 
				
			||||||
  ruby-oembed
 | 
					  ruby-oembed
 | 
				
			||||||
 | 
					  sanitize
 | 
				
			||||||
  sass-rails (~> 5.0)
 | 
					  sass-rails (~> 5.0)
 | 
				
			||||||
  sidekiq
 | 
					  sidekiq
 | 
				
			||||||
  sidekiq-unique-jobs
 | 
					  sidekiq-unique-jobs
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -13,7 +13,7 @@ export function fetchStatusCard(id) {
 | 
				
			|||||||
    dispatch(fetchStatusCardRequest(id));
 | 
					    dispatch(fetchStatusCardRequest(id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
 | 
					    api(getState).get(`/api/v1/statuses/${id}/card`).then(response => {
 | 
				
			||||||
      if (!response.data.url || !response.data.title || !response.data.description) {
 | 
					      if (!response.data.url) {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,14 +14,11 @@ const getHostname = url => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class Card extends React.PureComponent {
 | 
					class Card extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  renderLink () {
 | 
				
			||||||
    const { card } = this.props;
 | 
					    const { card } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (card === null) {
 | 
					    let image    = '';
 | 
				
			||||||
      return null;
 | 
					    let provider = card.get('provider_name');
 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let image = '';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (card.get('image')) {
 | 
					    if (card.get('image')) {
 | 
				
			||||||
      image = (
 | 
					      image = (
 | 
				
			||||||
@@ -31,18 +28,64 @@ class Card extends React.PureComponent {
 | 
				
			|||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (provider.length < 1) {
 | 
				
			||||||
 | 
					      provider = getHostname(card.get('url'))
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
 | 
					      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
 | 
				
			||||||
        {image}
 | 
					        {image}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className='status-card__content'>
 | 
					        <div className='status-card__content'>
 | 
				
			||||||
          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
 | 
					          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
 | 
				
			||||||
          <p className='status-card__description'>{card.get('description').substring(0, 50)}</p>
 | 
					          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
 | 
				
			||||||
          <span className='status-card__host' style={hostStyle}>{getHostname(card.get('url'))}</span>
 | 
					          <span className='status-card__host' style={hostStyle}>{provider}</span>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  renderPhoto () {
 | 
				
			||||||
 | 
					    const { card } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'>
 | 
				
			||||||
 | 
					        <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} />
 | 
				
			||||||
 | 
					      </a>
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  renderVideo () {
 | 
				
			||||||
 | 
					    const { card } = this.props;
 | 
				
			||||||
 | 
					    const content  = { __html: card.get('html') };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div
 | 
				
			||||||
 | 
					        className='status-card-video'
 | 
				
			||||||
 | 
					        dangerouslySetInnerHTML={content}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  render () {
 | 
				
			||||||
 | 
					    const { card } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (card === null) {
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch(card.get('type')) {
 | 
				
			||||||
 | 
					    case 'link':
 | 
				
			||||||
 | 
					      return this.renderLink();
 | 
				
			||||||
 | 
					    case 'photo':
 | 
				
			||||||
 | 
					      return this.renderPhoto();
 | 
				
			||||||
 | 
					    case 'video':
 | 
				
			||||||
 | 
					      return this.renderVideo();
 | 
				
			||||||
 | 
					    case 'rich':
 | 
				
			||||||
 | 
					    default:
 | 
				
			||||||
 | 
					      return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Card.propTypes = {
 | 
					Card.propTypes = {
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1734,6 +1734,28 @@ button.icon-button.active i.fa-retweet {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.status-card-video, .status-card-rich, .status-card-photo {
 | 
				
			||||||
 | 
					  margin-top: 14px;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  iframe {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.status-card-photo {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  img {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.status-card__title {
 | 
					.status-card__title {
 | 
				
			||||||
  display: block;
 | 
					  display: block;
 | 
				
			||||||
  font-weight: 500;
 | 
					  font-weight: 500;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -14,8 +14,20 @@ class Api::OEmbedController < ApiController
 | 
				
			|||||||
  def stream_entry_from_url(url)
 | 
					  def stream_entry_from_url(url)
 | 
				
			||||||
    params = Rails.application.routes.recognize_path(url)
 | 
					    params = Rails.application.routes.recognize_path(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    raise ActiveRecord::RecordNotFound unless params[:controller] == 'stream_entries' && params[:action] == 'show'
 | 
					    raise ActiveRecord::RecordNotFound unless recognized_stream_entry_url?(params)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    StreamEntry.find(params[:id])
 | 
					    stream_entry(params)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def recognized_stream_entry_url?(params)
 | 
				
			||||||
 | 
					    %w(stream_entries statuses).include?(params[:controller]) && params[:action] == 'show'
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def stream_entry(params)
 | 
				
			||||||
 | 
					    if params[:controller] == 'stream_entries'
 | 
				
			||||||
 | 
					      StreamEntry.find(params[:id])
 | 
				
			||||||
 | 
					    else
 | 
				
			||||||
 | 
					      Status.find(params[:id]).stream_entry
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										13
									
								
								app/helpers/http_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/helpers/http_helper.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module HttpHelper
 | 
				
			||||||
 | 
					  USER_AGENT = "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::VERSION}; +http://#{Rails.configuration.x.local_domain}/)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def http_client(options = {})
 | 
				
			||||||
 | 
					    timeout = { write: 10, connect: 10, read: 10 }.merge(options)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HTTP.headers(user_agent: USER_AGENT)
 | 
				
			||||||
 | 
					        .timeout(:per_operation, timeout)
 | 
				
			||||||
 | 
					        .follow
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -1,13 +1,13 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
require 'singleton'
 | 
					require 'singleton'
 | 
				
			||||||
 | 
					require_relative './sanitize_config'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Formatter
 | 
					class Formatter
 | 
				
			||||||
  include Singleton
 | 
					  include Singleton
 | 
				
			||||||
  include RoutingHelper
 | 
					  include RoutingHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  include ActionView::Helpers::TextHelper
 | 
					  include ActionView::Helpers::TextHelper
 | 
				
			||||||
  include ActionView::Helpers::SanitizeHelper
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def format(status)
 | 
					  def format(status)
 | 
				
			||||||
    return reformat(status.content) unless status.local?
 | 
					    return reformat(status.content) unless status.local?
 | 
				
			||||||
@@ -23,7 +23,7 @@ class Formatter
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def reformat(html)
 | 
					  def reformat(html)
 | 
				
			||||||
    sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
 | 
					    sanitize(html, Sanitize::Config::MASTODON_STRICT)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def plaintext(status)
 | 
					  def plaintext(status)
 | 
				
			||||||
@@ -43,6 +43,10 @@ class Formatter
 | 
				
			|||||||
    html.html_safe # rubocop:disable Rails/OutputSafety
 | 
					    html.html_safe # rubocop:disable Rails/OutputSafety
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def sanitize(html, config)
 | 
				
			||||||
 | 
					    Sanitize.fragment(html, config)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def encode(html)
 | 
					  def encode(html)
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										36
									
								
								app/lib/provider_discovery.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/lib/provider_discovery.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ProviderDiscovery < OEmbed::ProviderDiscovery
 | 
				
			||||||
 | 
					  include HttpHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  class << self
 | 
				
			||||||
 | 
					    def discover_provider(url, options = {})
 | 
				
			||||||
 | 
					      res    = http_client.get(url)
 | 
				
			||||||
 | 
					      format = options[:format]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      html = Nokogiri::HTML(res.to_s)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if format.nil? || format == :json
 | 
				
			||||||
 | 
					        provider_endpoint ||= html.at_xpath('//link[@type="application/json+oembed"]')&.attribute('href')&.value
 | 
				
			||||||
 | 
					        format ||= :json if provider_endpoint
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if format.nil? || format == :xml
 | 
				
			||||||
 | 
					        provider_endpoint ||= html.at_xpath('//link[@type="application/xml+oembed"]')&.attribute('href')&.value
 | 
				
			||||||
 | 
					        format ||= :xml if provider_endpoint
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      begin
 | 
				
			||||||
 | 
					        provider_endpoint = Addressable::URI.parse(provider_endpoint)
 | 
				
			||||||
 | 
					        provider_endpoint.query = nil
 | 
				
			||||||
 | 
					        provider_endpoint = provider_endpoint.to_s
 | 
				
			||||||
 | 
					      rescue Addressable::URI::InvalidURIError
 | 
				
			||||||
 | 
					        raise OEmbed::NotFound, url
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      OEmbed::Provider.new(provider_endpoint, format || OEmbed::Formatter.default)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										42
									
								
								app/lib/sanitize_config.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								app/lib/sanitize_config.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,42 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Sanitize
 | 
				
			||||||
 | 
					  module Config
 | 
				
			||||||
 | 
					    HTTP_PROTOCOLS ||= ['http', 'https', :relative].freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    MASTODON_STRICT ||= freeze_config(
 | 
				
			||||||
 | 
					      elements: %w(p br span a),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      attributes: {
 | 
				
			||||||
 | 
					        'a'    => %w(href),
 | 
				
			||||||
 | 
					        'span' => %w(class),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      protocols: {
 | 
				
			||||||
 | 
					        'a' => { 'href' => HTTP_PROTOCOLS },
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    MASTODON_OEMBED ||= freeze_config merge(
 | 
				
			||||||
 | 
					      RELAXED,
 | 
				
			||||||
 | 
					      elements: RELAXED[:elements] + %w(audio embed iframe source video),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      attributes: merge(
 | 
				
			||||||
 | 
					        RELAXED[:attributes],
 | 
				
			||||||
 | 
					        'audio'  => %w(controls),
 | 
				
			||||||
 | 
					        'embed'  => %w(height src type width),
 | 
				
			||||||
 | 
					        'iframe' => %w(allowfullscreen frameborder height scrolling src width),
 | 
				
			||||||
 | 
					        'source' => %w(src type),
 | 
				
			||||||
 | 
					        'video'  => %w(controls height loop width),
 | 
				
			||||||
 | 
					        'div'    => [:data]
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      protocols: merge(
 | 
				
			||||||
 | 
					        RELAXED[:protocols],
 | 
				
			||||||
 | 
					        'embed'  => { 'src' => HTTP_PROTOCOLS },
 | 
				
			||||||
 | 
					        'iframe' => { 'src' => HTTP_PROTOCOLS },
 | 
				
			||||||
 | 
					        'source' => { 'src' => HTTP_PROTOCOLS }
 | 
				
			||||||
 | 
					      )
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@@ -3,6 +3,10 @@
 | 
				
			|||||||
class PreviewCard < ApplicationRecord
 | 
					class PreviewCard < ApplicationRecord
 | 
				
			||||||
  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 | 
					  IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  self.inheritance_column = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  enum type: [:link, :photo, :video, :rich]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  belongs_to :status
 | 
					  belongs_to :status
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
 | 
					  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,6 +1,8 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FetchAtomService < BaseService
 | 
					class FetchAtomService < BaseService
 | 
				
			||||||
 | 
					  include HttpHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def call(url)
 | 
					  def call(url)
 | 
				
			||||||
    return if url.blank?
 | 
					    return if url.blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -45,8 +47,4 @@ class FetchAtomService < BaseService
 | 
				
			|||||||
  def fetch(url)
 | 
					  def fetch(url)
 | 
				
			||||||
    http_client.get(url).to_s
 | 
					    http_client.get(url).to_s
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					 | 
				
			||||||
  def http_client
 | 
					 | 
				
			||||||
    HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,8 +1,9 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class FetchLinkCardService < BaseService
 | 
					class FetchLinkCardService < BaseService
 | 
				
			||||||
 | 
					  include HttpHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  URL_PATTERN = %r{https?://\S+}
 | 
					  URL_PATTERN = %r{https?://\S+}
 | 
				
			||||||
  USER_AGENT = "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::VERSION}; +http://#{Rails.configuration.x.local_domain}/)"
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def call(status)
 | 
					  def call(status)
 | 
				
			||||||
    # Get first http/https URL that isn't local
 | 
					    # Get first http/https URL that isn't local
 | 
				
			||||||
@@ -10,13 +11,53 @@ class FetchLinkCardService < BaseService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return if url.nil?
 | 
					    return if url.nil?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
 | 
				
			||||||
 | 
					    attempt_opengraph(card, url) unless attempt_oembed(card, url)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def attempt_oembed(card, url)
 | 
				
			||||||
 | 
					    response = OEmbed::Providers.get(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    card.type          = response.type
 | 
				
			||||||
 | 
					    card.title         = response.respond_to?(:title)         ? response.title         : ''
 | 
				
			||||||
 | 
					    card.author_name   = response.respond_to?(:author_name)   ? response.author_name   : ''
 | 
				
			||||||
 | 
					    card.author_url    = response.respond_to?(:author_url)    ? response.author_url    : ''
 | 
				
			||||||
 | 
					    card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
 | 
				
			||||||
 | 
					    card.provider_url  = response.respond_to?(:provider_url)  ? response.provider_url  : ''
 | 
				
			||||||
 | 
					    card.width         = 0
 | 
				
			||||||
 | 
					    card.height        = 0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    case card.type
 | 
				
			||||||
 | 
					    when 'link'
 | 
				
			||||||
 | 
					      card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
 | 
				
			||||||
 | 
					    when 'photo'
 | 
				
			||||||
 | 
					      card.url    = response.url
 | 
				
			||||||
 | 
					      card.width  = response.width.presence  || 0
 | 
				
			||||||
 | 
					      card.height = response.height.presence || 0
 | 
				
			||||||
 | 
					    when 'video'
 | 
				
			||||||
 | 
					      card.width  = response.width.presence  || 0
 | 
				
			||||||
 | 
					      card.height = response.height.presence || 0
 | 
				
			||||||
 | 
					      card.html   = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
 | 
				
			||||||
 | 
					    when 'rich'
 | 
				
			||||||
 | 
					      # Most providers rely on <script> tags, which is a no-no
 | 
				
			||||||
 | 
					      return false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    card.save_with_optional_image!
 | 
				
			||||||
 | 
					  rescue OEmbed::NotFound
 | 
				
			||||||
 | 
					    false
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def attempt_opengraph(card, url)
 | 
				
			||||||
    response = http_client.get(url)
 | 
					    response = http_client.get(url)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return if response.code != 200 || response.mime_type != 'text/html'
 | 
					    return if response.code != 200 || response.mime_type != 'text/html'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    page = Nokogiri::HTML(response.to_s)
 | 
					    page = Nokogiri::HTML(response.to_s)
 | 
				
			||||||
    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    card.type        = :link
 | 
				
			||||||
    card.title       = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
 | 
					    card.title       = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
 | 
				
			||||||
    card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
 | 
					    card.description = meta_property(page, 'og:description') || meta_property(page, 'description')
 | 
				
			||||||
    card.image       = URI.parse(Addressable::URI.parse(meta_property(page, 'og:image')).normalize.to_s) if meta_property(page, 'og:image')
 | 
					    card.image       = URI.parse(Addressable::URI.parse(meta_property(page, 'og:image')).normalize.to_s) if meta_property(page, 'og:image')
 | 
				
			||||||
@@ -26,12 +67,6 @@ class FetchLinkCardService < BaseService
 | 
				
			|||||||
    card.save_with_optional_image!
 | 
					    card.save_with_optional_image!
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def http_client
 | 
					 | 
				
			||||||
    HTTP.headers(user_agent: USER_AGENT).timeout(:per_operation, write: 10, connect: 10, read: 10).follow
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  def meta_property(html, property)
 | 
					  def meta_property(html, property)
 | 
				
			||||||
    html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
 | 
					    html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -2,6 +2,7 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
class FollowRemoteAccountService < BaseService
 | 
					class FollowRemoteAccountService < BaseService
 | 
				
			||||||
  include OStatus2::MagicKey
 | 
					  include OStatus2::MagicKey
 | 
				
			||||||
 | 
					  include HttpHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 | 
					  DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -73,7 +74,7 @@ class FollowRemoteAccountService < BaseService
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def get_feed(url)
 | 
					  def get_feed(url)
 | 
				
			||||||
    response = http_client.get(Addressable::URI.parse(url).normalize)
 | 
					    response = http_client(write: 20, connect: 20, read: 50).get(Addressable::URI.parse(url).normalize)
 | 
				
			||||||
    [response.to_s, Nokogiri::XML(response)]
 | 
					    [response.to_s, Nokogiri::XML(response)]
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -98,8 +99,4 @@ class FollowRemoteAccountService < BaseService
 | 
				
			|||||||
  def get_profile(body, account)
 | 
					  def get_profile(body, account)
 | 
				
			||||||
    RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), false)
 | 
					    RemoteProfileUpdateWorker.perform_async(account.id, body.force_encoding('UTF-8'), false)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					 | 
				
			||||||
  def http_client
 | 
					 | 
				
			||||||
    HTTP.timeout(:per_operation, write: 20, connect: 20, read: 50)
 | 
					 | 
				
			||||||
  end
 | 
					 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -34,7 +34,7 @@ class PostStatusService < BaseService
 | 
				
			|||||||
    process_mentions_service.call(status)
 | 
					    process_mentions_service.call(status)
 | 
				
			||||||
    process_hashtags_service.call(status)
 | 
					    process_hashtags_service.call(status)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    LinkCrawlWorker.perform_async(status.id)
 | 
					    LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text.present?
 | 
				
			||||||
    DistributionWorker.perform_async(status.id)
 | 
					    DistributionWorker.perform_async(status.id)
 | 
				
			||||||
    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
 | 
					    Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,7 @@
 | 
				
			|||||||
object @card
 | 
					object @card
 | 
				
			||||||
 | 
					
 | 
				
			||||||
attributes :url, :title, :description
 | 
					attributes :url, :title, :description, :type,
 | 
				
			||||||
 | 
					           :author_name, :author_url, :provider_name,
 | 
				
			||||||
 | 
					           :provider_url, :html, :width, :height
 | 
				
			||||||
 | 
					
 | 
				
			||||||
node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil }
 | 
					node(:image) { |card| card.image? ? full_asset_url(card.image.url(:original)) : nil }
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,7 +36,7 @@ Doorkeeper.configure do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  # Reuse access token for the same resource owner within an application (disabled by default)
 | 
					  # Reuse access token for the same resource owner within an application (disabled by default)
 | 
				
			||||||
  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
 | 
					  # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383
 | 
				
			||||||
  # reuse_access_token
 | 
					  reuse_access_token
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # Issue access tokens with refresh token (disabled by default)
 | 
					  # Issue access tokens with refresh token (disabled by default)
 | 
				
			||||||
  # use_refresh_token
 | 
					  # use_refresh_token
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,4 +1,5 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Kaminari.configure do |config|
 | 
					Kaminari.configure do |config|
 | 
				
			||||||
  config.default_per_page = 40
 | 
					  config.default_per_page = 40
 | 
				
			||||||
  config.window = 1
 | 
					  config.window = 1
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										4
									
								
								config/initializers/oembed.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								config/initializers/oembed.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require_relative '../../app/lib/provider_discovery'
 | 
				
			||||||
 | 
					OEmbed::Providers.register_fallback(ProviderDiscovery)
 | 
				
			||||||
							
								
								
									
										12
									
								
								db/migrate/20170425202925_add_oembed_to_preview_cards.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								db/migrate/20170425202925_add_oembed_to_preview_cards.rb
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
				
			|||||||
 | 
					class AddOEmbedToPreviewCards < ActiveRecord::Migration[5.0]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    add_column :preview_cards, :type, :integer, default: 0, null: false
 | 
				
			||||||
 | 
					    add_column :preview_cards, :html, :text, null: false, default: ''
 | 
				
			||||||
 | 
					    add_column :preview_cards, :author_name, :string, null: false, default: ''
 | 
				
			||||||
 | 
					    add_column :preview_cards, :author_url, :string, null: false, default: ''
 | 
				
			||||||
 | 
					    add_column :preview_cards, :provider_name, :string, null: false, default: ''
 | 
				
			||||||
 | 
					    add_column :preview_cards, :provider_url, :string, null: false, default: ''
 | 
				
			||||||
 | 
					    add_column :preview_cards, :width, :integer, default: 0, null: false
 | 
				
			||||||
 | 
					    add_column :preview_cards, :height, :integer, default: 0, null: false
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										12
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								db/schema.rb
									
									
									
									
									
								
							@@ -10,7 +10,7 @@
 | 
				
			|||||||
#
 | 
					#
 | 
				
			||||||
# It's strongly recommended that you check this file into your version control system.
 | 
					# It's strongly recommended that you check this file into your version control system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ActiveRecord::Schema.define(version: 20170425131920) do
 | 
					ActiveRecord::Schema.define(version: 20170425202925) do
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # These are extensions that must be enabled in order to support this database
 | 
					  # These are extensions that must be enabled in order to support this database
 | 
				
			||||||
  enable_extension "plpgsql"
 | 
					  enable_extension "plpgsql"
 | 
				
			||||||
@@ -203,6 +203,14 @@ ActiveRecord::Schema.define(version: 20170425131920) do
 | 
				
			|||||||
    t.datetime "image_updated_at"
 | 
					    t.datetime "image_updated_at"
 | 
				
			||||||
    t.datetime "created_at",                      null: false
 | 
					    t.datetime "created_at",                      null: false
 | 
				
			||||||
    t.datetime "updated_at",                      null: false
 | 
					    t.datetime "updated_at",                      null: false
 | 
				
			||||||
 | 
					    t.integer  "type",               default: 0,  null: false
 | 
				
			||||||
 | 
					    t.text     "html",               default: "", null: false
 | 
				
			||||||
 | 
					    t.string   "author_name",        default: "", null: false
 | 
				
			||||||
 | 
					    t.string   "author_url",         default: "", null: false
 | 
				
			||||||
 | 
					    t.string   "provider_name",      default: "", null: false
 | 
				
			||||||
 | 
					    t.string   "provider_url",       default: "", null: false
 | 
				
			||||||
 | 
					    t.integer  "width",              default: 0,  null: false
 | 
				
			||||||
 | 
					    t.integer  "height",             default: 0,  null: false
 | 
				
			||||||
    t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree
 | 
					    t.index ["status_id"], name: "index_preview_cards_on_status_id", unique: true, using: :btree
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -333,4 +341,4 @@ ActiveRecord::Schema.define(version: 20170425131920) do
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  add_foreign_key "statuses", "statuses", column: "reblog_of_id", on_delete: :cascade
 | 
					  add_foreign_key "statuses", "statuses", column: "reblog_of_id", on_delete: :cascade
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,6 +9,6 @@ RSpec.describe FetchLinkCardService do
 | 
				
			|||||||
    status = Fabricate(:status, text: 'Check out http://example.中国')
 | 
					    status = Fabricate(:status, text: 'Check out http://example.中国')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    FetchLinkCardService.new.call(status)
 | 
					    FetchLinkCardService.new.call(status)
 | 
				
			||||||
    expect(a_request(:get, 'http://example.xn--fiqs8s/')).to have_been_made
 | 
					    expect(a_request(:get, 'http://example.xn--fiqs8s/')).to have_been_made.at_least_once
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user