diff --git a/Gemfile b/Gemfile
index 701c724ee2..2909d8e454 100644
--- a/Gemfile
+++ b/Gemfile
@@ -49,6 +49,7 @@ gem 'rails-settings-cached'
gem 'redis', '~>3.2', require: ['redis', 'redis/connection/hiredis']
gem 'rqrcode'
gem 'ruby-oembed', require: 'oembed'
+gem 'sanitize'
gem 'sidekiq'
gem 'sidekiq-unique-jobs'
gem 'simple-navigation'
diff --git a/Gemfile.lock b/Gemfile.lock
index 14567dc5a4..fc8d28104f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -123,6 +123,7 @@ GEM
connection_pool (2.2.1)
crack (0.4.3)
safe_yaml (~> 1.0.0)
+ crass (1.0.2)
debug_inspector (0.0.2)
devise (4.2.1)
bcrypt (~> 3.0)
@@ -258,6 +259,8 @@ GEM
nio4r (2.0.0)
nokogiri (1.7.1)
mini_portile2 (~> 2.1.0)
+ nokogumbo (1.4.10)
+ nokogiri
oj (2.18.5)
openssl (2.0.3)
orm_adapter (0.5.0)
@@ -398,6 +401,10 @@ GEM
ruby-oembed (0.12.0)
ruby-progressbar (1.8.1)
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-rails (5.0.6)
railties (>= 4.0.0, < 6)
@@ -540,6 +547,7 @@ DEPENDENCIES
rspec-sidekiq
rubocop
ruby-oembed
+ sanitize
sass-rails (~> 5.0)
sidekiq
sidekiq-unique-jobs
diff --git a/app/assets/javascripts/components/actions/cards.jsx b/app/assets/javascripts/components/actions/cards.jsx
index d4c1eda601..805be9709b 100644
--- a/app/assets/javascripts/components/actions/cards.jsx
+++ b/app/assets/javascripts/components/actions/cards.jsx
@@ -13,7 +13,7 @@ export function fetchStatusCard(id) {
dispatch(fetchStatusCardRequest(id));
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;
}
diff --git a/app/assets/javascripts/components/features/status/components/card.jsx b/app/assets/javascripts/components/features/status/components/card.jsx
index 1b5722b6ae..a5ce7f08af 100644
--- a/app/assets/javascripts/components/features/status/components/card.jsx
+++ b/app/assets/javascripts/components/features/status/components/card.jsx
@@ -14,14 +14,11 @@ const getHostname = url => {
class Card extends React.PureComponent {
- render () {
+ renderLink () {
const { card } = this.props;
- if (card === null) {
- return null;
- }
-
- let image = '';
+ let image = '';
+ let provider = card.get('provider_name');
if (card.get('image')) {
image = (
@@ -31,18 +28,64 @@ class Card extends React.PureComponent {
);
}
+ if (provider.length < 1) {
+ provider = getHostname(card.get('url'))
+ }
+
return (
{image}
{card.get('title')}
-
{card.get('description').substring(0, 50)}
-
{getHostname(card.get('url'))}
+
{(card.get('description') || '').substring(0, 50)}
+
{provider}
);
}
+
+ renderPhoto () {
+ const { card } = this.props;
+
+ return (
+
+
+
+ );
+ }
+
+ renderVideo () {
+ const { card } = this.props;
+ const content = { __html: card.get('html') };
+
+ return (
+
+ );
+ }
+
+ 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 = {
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index cbbe746c1b..4225756397 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -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 {
display: block;
font-weight: 500;
diff --git a/app/controllers/api/oembed_controller.rb b/app/controllers/api/oembed_controller.rb
index 2ea4822966..58d8207f60 100644
--- a/app/controllers/api/oembed_controller.rb
+++ b/app/controllers/api/oembed_controller.rb
@@ -14,8 +14,20 @@ class Api::OEmbedController < ApiController
def stream_entry_from_url(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
diff --git a/app/helpers/http_helper.rb b/app/helpers/http_helper.rb
new file mode 100644
index 0000000000..1e1ac82567
--- /dev/null
+++ b/app/helpers/http_helper.rb
@@ -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
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 1d8e90d1ff..5ae6238d9d 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
require 'singleton'
+require_relative './sanitize_config'
class Formatter
include Singleton
include RoutingHelper
include ActionView::Helpers::TextHelper
- include ActionView::Helpers::SanitizeHelper
def format(status)
return reformat(status.content) unless status.local?
@@ -23,7 +23,7 @@ class Formatter
end
def reformat(html)
- sanitize(html, tags: %w(a br p span), attributes: %w(href rel class))
+ sanitize(html, Sanitize::Config::MASTODON_STRICT)
end
def plaintext(status)
@@ -43,6 +43,10 @@ class Formatter
html.html_safe # rubocop:disable Rails/OutputSafety
end
+ def sanitize(html, config)
+ Sanitize.fragment(html, config)
+ end
+
private
def encode(html)
diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb
new file mode 100644
index 0000000000..761ddae0f5
--- /dev/null
+++ b/app/lib/provider_discovery.rb
@@ -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
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
new file mode 100644
index 0000000000..7cf1c30626
--- /dev/null
+++ b/app/lib/sanitize_config.rb
@@ -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
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index e59b05eb87..0aa7711012 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -3,6 +3,10 @@
class PreviewCard < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
+ self.inheritance_column = false
+
+ enum type: [:link, :photo, :video, :rich]
+
belongs_to :status
has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index c3dad1eb93..9c514aa9f9 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class FetchAtomService < BaseService
+ include HttpHelper
+
def call(url)
return if url.blank?
@@ -45,8 +47,4 @@ class FetchAtomService < BaseService
def fetch(url)
http_client.get(url).to_s
end
-
- def http_client
- HTTP.timeout(:per_operation, write: 10, connect: 10, read: 10).follow
- end
end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index f74a0d34dc..416c5fdadf 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -1,8 +1,9 @@
# frozen_string_literal: true
class FetchLinkCardService < BaseService
+ include HttpHelper
+
URL_PATTERN = %r{https?://\S+}
- USER_AGENT = "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::VERSION}; +http://#{Rails.configuration.x.local_domain}/)"
def call(status)
# Get first http/https URL that isn't local
@@ -10,13 +11,53 @@ class FetchLinkCardService < BaseService
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