Browse Source

Make PreviewCard records reuseable between statuses (#4642)

* Make PreviewCard records reuseable between statuses

**Warning!** Migration truncates preview_cards tablec

* Allow a wider thumbnail for link preview, display it in horizontal layout (#4648)

* Delete preview cards files before truncating

* Rename old table instead of truncating it

* Add mastodon:maintenance:remove_deprecated_preview_cards

* Ignore deprecated_preview_cards in schema definition

* Fix null behaviour
Eugen Rochko 6 years ago
parent
commit
7dc5035031

+ 1 - 1
app/controllers/api/v1/statuses_controller.rb

@@ -29,7 +29,7 @@ class Api::V1::StatusesController < Api::BaseController
   end
 
   def card
-    @card = PreviewCard.find_by(status: @status)
+    @card = @status.preview_cards.first
 
     if @card.nil?
       render_empty

+ 7 - 2
app/javascript/mastodon/features/status/components/card.js

@@ -1,6 +1,7 @@
 import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import punycode from 'punycode';
+import classnames from 'classnames';
 
 const IDNA_PREFIX = 'xn--';
 
@@ -32,7 +33,7 @@ export default class Card extends React.PureComponent {
     if (card.get('image')) {
       image = (
         <div className='status-card__image'>
-          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' />
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' width={card.get('width')} height={card.get('height')} />
         </div>
       );
     }
@@ -41,8 +42,12 @@ export default class Card extends React.PureComponent {
       provider = decodeIDNA(getHostname(card.get('url')));
     }
 
+    const className = classnames('status-card', {
+      'horizontal': card.get('width') > card.get('height'),
+    });
+
     return (
-      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener'>
         {image}
 
         <div className='status-card__content'>

+ 12 - 0
app/javascript/styles/components.scss

@@ -2057,6 +2057,18 @@ button.icon-button.active i.fa-retweet {
   background: lighten($ui-base-color, 8%);
 }
 
+.status-card.horizontal {
+  display: block;
+
+  .status-card__image {
+    width: 100%;
+  }
+
+  .status-card__image-image {
+    border-radius: 4px 4px 0 0;
+  }
+}
+
 .status-card__image-image {
   border-radius: 4px 0 0 4px;
   display: block;

+ 3 - 0
app/models/media_attachment.rb

@@ -142,9 +142,11 @@ class MediaAttachment < ApplicationRecord
 
   def populate_meta
     meta = {}
+
     file.queued_for_write.each do |style, file|
       begin
         geo = Paperclip::Geometry.from_file file
+
         meta[style] = {
           width: geo.width.to_i,
           height: geo.height.to_i,
@@ -155,6 +157,7 @@ class MediaAttachment < ApplicationRecord
         meta[style] = {}
       end
     end
+
     meta
   end
 

+ 23 - 8
app/models/preview_card.rb

@@ -4,16 +4,13 @@
 # Table name: preview_cards
 #
 #  id                 :integer          not null, primary key
-#  status_id          :integer
 #  url                :string           default(""), not null
-#  title              :string
-#  description        :string
+#  title              :string           default(""), not null
+#  description        :string           default(""), not null
 #  image_file_name    :string
 #  image_content_type :string
 #  image_file_size    :integer
 #  image_updated_at   :datetime
-#  created_at         :datetime         not null
-#  updated_at         :datetime         not null
 #  type               :integer          default("link"), not null
 #  html               :text             default(""), not null
 #  author_name        :string           default(""), not null
@@ -22,6 +19,8 @@
 #  provider_url       :string           default(""), not null
 #  width              :integer          default(0), not null
 #  height             :integer          default(0), not null
+#  created_at         :datetime         not null
+#  updated_at         :datetime         not null
 #
 
 class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
 
   enum type: [:link, :photo, :video, :rich]
 
-  belongs_to :status
+  has_and_belongs_to_many :statuses
 
-  has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+  has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' }
 
   include Attachmentable
   include Remotable
 
-  validates :url, presence: true
+  validates :url, presence: true, uniqueness: true
   validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
   validates_attachment_size :image, less_than: 1.megabytes
 
+  before_save :extract_dimensions, if: :link?
+
   def save_with_optional_image!
     save!
   rescue ActiveRecord::RecordInvalid
     self.image = nil
     save!
   end
+
+  private
+
+  def extract_dimensions
+    file = image.queued_for_write[:original]
+
+    return if file.nil?
+
+    geo         = Paperclip::Geometry.from_file(file)
+    self.width  = geo.width.to_i
+    self.height = geo.height.to_i
+  rescue Paperclip::Errors::NotIdentifiedByImageMagickError
+    nil
+  end
 end

+ 2 - 1
app/models/status.rb

@@ -47,10 +47,11 @@ class Status < ApplicationRecord
   has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
   has_many :mentions, dependent: :destroy
   has_many :media_attachments, dependent: :destroy
+
   has_and_belongs_to_many :tags
+  has_and_belongs_to_many :preview_cards
 
   has_one :notification, as: :activity, dependent: :destroy
-  has_one :preview_card, dependent: :destroy
   has_one :stream_entry, as: :activity, inverse_of: :status
 
   validates :uri, uniqueness: true, unless: :local?

+ 60 - 40
app/services/fetch_link_card_service.rb

@@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService
   URL_PATTERN = %r{https?://\S+}
 
   def call(status)
-    # Get first http/https URL that isn't local
-    url = parse_urls(status)
+    @status = status
+    @url    = parse_urls
 
-    return if url.nil?
+    return if @url.nil? || @status.preview_cards.any?
 
-    url  = url.to_s
-    card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
-    res  = Request.new(:head, url).perform
+    @url = @url.to_s
 
-    return if res.code != 200 || res.mime_type != 'text/html'
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        @card = PreviewCard.find_by(url: @url)
+        process_url if @card.nil?
+      end
+    end
 
-    attempt_opengraph(card, url) unless attempt_oembed(card, url)
+    attach_card unless @card.nil?
   rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
     nil
   end
 
   private
 
-  def parse_urls(status)
-    if status.local?
-      urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
+  def process_url
+    @card = PreviewCard.new(url: @url)
+    res   = Request.new(:head, @url).perform
+
+    return if res.code != 200 || res.mime_type != 'text/html'
+
+    attempt_oembed || attempt_opengraph
+  end
+
+  def attach_card
+    @status.preview_cards << @card
+  end
+
+  def parse_urls
+    if @status.local?
+      urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
     else
-      html  = Nokogiri::HTML(status.text)
+      html  = Nokogiri::HTML(@status.text)
       links = html.css('a')
       urls  = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
     end
@@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService
     a['rel']&.include?('tag') || a['class']&.include?('u-url')
   end
 
-  def attempt_oembed(card, url)
-    response = OEmbed::Providers.get(url)
+  def attempt_oembed
+    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
+    @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
+    case @card.type
     when 'link'
-      card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
+      @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
+      @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)
+      @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!
+    @card.save_with_optional_image!
   rescue OEmbed::NotFound
     false
   end
 
-  def attempt_opengraph(card, url)
-    response = Request.new(:get, url).perform
+  def attempt_opengraph
+    response = Request.new(:get, @url).perform
 
     return if response.code != 200 || response.mime_type != 'text/html'
 
@@ -88,19 +104,23 @@ class FetchLinkCardService < BaseService
     detector.strip_tags = true
 
     guess = detector.detect(html, response.charset)
-    page = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
+    page  = Nokogiri::HTML(html, nil, guess&.fetch(:encoding))
 
-    card.type             = :link
-    card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content
-    card.description      = meta_property(page, 'og:description') || meta_property(page, 'description')
-    card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
+    @card.type             = :link
+    @card.title            = meta_property(page, 'og:title') || page.at_xpath('//title')&.content || ''
+    @card.description      = meta_property(page, 'og:description') || meta_property(page, 'description') || ''
+    @card.image_remote_url = meta_property(page, 'og:image') if meta_property(page, 'og:image')
 
-    return if card.title.blank?
+    return if @card.title.blank?
 
-    card.save_with_optional_image!
+    @card.save_with_optional_image!
   end
 
   def meta_property(html, property)
     html.at_xpath("//meta[@property=\"#{property}\"]")&.attribute('content')&.value || html.at_xpath("//meta[@name=\"#{property}\"]")&.attribute('content')&.value
   end
+
+  def lock_options
+    { redis: Redis.current, key: "fetch:#{@url}" }
+  end
 end

+ 2 - 0
config/environment.rb

@@ -3,3 +3,5 @@ require_relative 'application'
 
 # Initialize the Rails application.
 Rails.application.initialize!
+
+ActiveRecord::SchemaDumper.ignore_tables = ['deprecated_preview_cards']

+ 30 - 0
db/migrate/20170901141119_truncate_preview_cards.rb

@@ -0,0 +1,30 @@
+class TruncatePreviewCards < ActiveRecord::Migration[5.1]
+  def up
+    rename_table :preview_cards, :deprecated_preview_cards
+
+    create_table :preview_cards do |t|
+      t.string     :url, default: '', null: false, index: { unique: true }
+      t.string     :title, default: '', null: false
+      t.string     :description, default: '', null: false
+      t.attachment :image
+      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.timestamps
+    end
+  end
+
+  def down
+    if ActiveRecord::Base.connection.table_exists? 'deprecated_preview_cards'
+      drop_table :preview_cards
+      rename_table :deprecated_preview_cards, :preview_cards
+    else
+      raise ActiveRecord::IrreversibleMigration, 'Previous preview cards table has already been removed'
+    end
+  end
+end

+ 7 - 0
db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb

@@ -0,0 +1,7 @@
+class CreateJoinTablePreviewCardsStatuses < ActiveRecord::Migration[5.1]
+  def change
+    create_join_table :preview_cards, :statuses do |t|
+      t.index [:status_id, :preview_card_id]
+    end
+  end
+end

+ 13 - 9
db/schema.rb

@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 20170829215220) do
+ActiveRecord::Schema.define(version: 20170901142658) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -224,17 +224,14 @@ ActiveRecord::Schema.define(version: 20170829215220) do
     t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
   end
 
-  create_table "preview_cards", id: :serial, force: :cascade do |t|
-    t.bigint "status_id"
+  create_table "preview_cards", force: :cascade do |t|
     t.string "url", default: "", null: false
-    t.string "title"
-    t.string "description"
+    t.string "title", default: "", null: false
+    t.string "description", default: "", null: false
     t.string "image_file_name"
     t.string "image_content_type"
     t.integer "image_file_size"
     t.datetime "image_updated_at"
-    t.datetime "created_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
@@ -243,7 +240,15 @@ ActiveRecord::Schema.define(version: 20170829215220) do
     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
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["url"], name: "index_preview_cards_on_url", unique: true
+  end
+
+  create_table "preview_cards_statuses", id: false, force: :cascade do |t|
+    t.bigint "preview_card_id", null: false
+    t.bigint "status_id", null: false
+    t.index ["status_id", "preview_card_id"], name: "index_preview_cards_statuses_on_status_id_and_preview_card_id"
   end
 
   create_table "reports", id: :serial, force: :cascade do |t|
@@ -432,7 +437,6 @@ ActiveRecord::Schema.define(version: 20170829215220) do
   add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id", on_delete: :cascade
   add_foreign_key "oauth_access_tokens", "users", column: "resource_owner_id", on_delete: :cascade
   add_foreign_key "oauth_applications", "users", column: "owner_id", on_delete: :cascade
-  add_foreign_key "preview_cards", "statuses", on_delete: :cascade
   add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", on_delete: :nullify
   add_foreign_key "reports", "accounts", column: "target_account_id", on_delete: :cascade
   add_foreign_key "reports", "accounts", on_delete: :cascade

+ 23 - 0
lib/tasks/mastodon.rake

@@ -270,5 +270,28 @@ namespace :mastodon do
       ActiveRecord::Base.connection.execute('UPDATE media_attachments SET account_id = NULL FROM media_attachments ma LEFT JOIN accounts a ON a.id = ma.account_id WHERE media_attachments.id = ma.id AND ma.account_id IS NOT NULL AND a.id IS NULL')
       ActiveRecord::Base.connection.execute('UPDATE reports SET action_taken_by_account_id = NULL FROM reports r LEFT JOIN accounts a ON a.id = r.action_taken_by_account_id WHERE reports.id = r.id AND r.action_taken_by_account_id IS NOT NULL AND a.id IS NULL')
     end
+
+    desc 'Remove deprecated preview cards'
+    task remove_deprecated_preview_cards: :environment do
+      return unless ActiveRecord::Base.connection.table_exists? 'deprecated_preview_cards'
+
+      class DeprecatedPreviewCard < PreviewCard
+        self.table_name = 'deprecated_preview_cards'
+      end
+
+      puts 'Delete records and associated files from deprecated preview cards? [y/N]: '
+      confirm = STDIN.gets.chomp
+
+      if confirm.casecmp?('y')
+        DeprecatedPreviewCard.in_batches.destroy_all
+
+        puts 'Drop deprecated preview cards table? [y/N]: '
+        confirm = STDIN.gets.chomp
+
+        if confirm.casecmp?('y')
+          ActiveRecord::Migration.drop_table :deprecated_preview_cards
+        end
+      end
+    end
   end
 end

+ 3 - 3
spec/services/fetch_link_card_service_spec.rb

@@ -31,7 +31,7 @@ RSpec.describe FetchLinkCardService do
 
       it 'works with SJIS' do
         expect(a_request(:get, 'http://example.com/sjis')).to have_been_made.at_least_once
-        expect(status.preview_card.title).to eq("SJISのページ")
+        expect(status.preview_cards.first.title).to eq("SJISのページ")
       end
     end
 
@@ -40,7 +40,7 @@ RSpec.describe FetchLinkCardService do
 
       it 'works with SJIS even with wrong charset header' do
         expect(a_request(:get, 'http://example.com/sjis_with_wrong_charset')).to have_been_made.at_least_once
-        expect(status.preview_card.title).to eq("SJISのページ")
+        expect(status.preview_cards.first.title).to eq("SJISのページ")
       end
     end
 
@@ -49,7 +49,7 @@ RSpec.describe FetchLinkCardService do
 
       it 'works with koi8-r' do
         expect(a_request(:get, 'http://example.com/koi8-r')).to have_been_made.at_least_once
-        expect(status.preview_card.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.")
+        expect(status.preview_cards.first.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.")
       end
     end
   end