logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 7dc5035031a697e7a2726fcd787fc9c294751027
parent: 2305f7c391325c7abf8746ebb2bb560c13df4437
Author: Eugen Rochko <eugen@zeonfederated.com>
Date:   Fri,  1 Sep 2017 16:20:16 +0200

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

Diffstat:

Mapp/controllers/api/v1/statuses_controller.rb2+-
Mapp/javascript/mastodon/features/status/components/card.js9+++++++--
Mapp/javascript/styles/components.scss12++++++++++++
Mapp/models/media_attachment.rb3+++
Mapp/models/preview_card.rb31+++++++++++++++++++++++--------
Mapp/models/status.rb3++-
Mapp/services/fetch_link_card_service.rb100+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mconfig/environment.rb2++
Adb/migrate/20170901141119_truncate_preview_cards.rb30++++++++++++++++++++++++++++++
Adb/migrate/20170901142658_create_join_table_preview_cards_statuses.rb7+++++++
Mdb/schema.rb22+++++++++++++---------
Mlib/tasks/mastodon.rake23+++++++++++++++++++++++
Mspec/services/fetch_link_card_service_spec.rb6+++---
13 files changed, 186 insertions(+), 64 deletions(-)

diff --git a/app/controllers/api/v1/statuses_controller.rb b/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 diff --git a/app/javascript/mastodon/features/status/components/card.js b/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'> diff --git a/app/javascript/styles/components.scss b/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; diff --git a/app/models/media_attachment.rb b/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 diff --git a/app/models/preview_card.rb b/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 diff --git a/app/models/status.rb b/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? diff --git a/app/services/fetch_link_card_service.rb b/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 diff --git a/config/environment.rb b/config/environment.rb @@ -3,3 +3,5 @@ require_relative 'application' # Initialize the Rails application. Rails.application.initialize! + +ActiveRecord::SchemaDumper.ignore_tables = ['deprecated_preview_cards'] diff --git a/db/migrate/20170901141119_truncate_preview_cards.rb b/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 diff --git a/db/migrate/20170901142658_create_join_table_preview_cards_statuses.rb b/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 diff --git a/db/schema.rb b/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 diff --git a/lib/tasks/mastodon.rake b/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 diff --git a/spec/services/fetch_link_card_service_spec.rb b/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