このコミットが含まれているのは:
@@ -2,6 +2,8 @@ class Post < ApplicationRecord
|
|||||||
require 'mini_magick'
|
require 'mini_magick'
|
||||||
require 'stringio'
|
require 'stringio'
|
||||||
|
|
||||||
|
class RemoteThumbnailFetchFailed < StandardError; end
|
||||||
|
|
||||||
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
|
ORIGINAL_CREATED_INVALID_MESSAGE = 'オリジナルの作成日時の形式が不正です.'.freeze
|
||||||
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
|
ORIGINAL_CREATED_MINUTE_PRECISION_MESSAGE =
|
||||||
'オリジナルの作成日時は分単位で入力してください.'.freeze
|
'オリジナルの作成日時は分単位で入力してください.'.freeze
|
||||||
@@ -12,7 +14,9 @@ class Post < ApplicationRecord
|
|||||||
def self.resized_thumbnail_attachment(upload)
|
def self.resized_thumbnail_attachment(upload)
|
||||||
upload.rewind
|
upload.rewind
|
||||||
image = MiniMagick::Image.read(upload.read)
|
image = MiniMagick::Image.read(upload.read)
|
||||||
image.resize '180x180'
|
image.resize '180x180^'
|
||||||
|
image.gravity 'Center'
|
||||||
|
image.extent '180x180'
|
||||||
image.format 'jpg'
|
image.format 'jpg'
|
||||||
|
|
||||||
{ io: StringIO.new(image.to_blob),
|
{ io: StringIO.new(image.to_blob),
|
||||||
@@ -125,7 +129,11 @@ class Post < ApplicationRecord
|
|||||||
'%d:%02d' % [min, s]
|
'%d:%02d' % [min, s]
|
||||||
end
|
end
|
||||||
|
|
||||||
remainder_ms.positive? ? "#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }" : base
|
if remainder_ms.positive?
|
||||||
|
"#{ base }.#{ remainder_ms.to_s.rjust(3, '0') }"
|
||||||
|
else
|
||||||
|
base
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
|
||||||
@@ -147,8 +155,33 @@ class Post < ApplicationRecord
|
|||||||
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attach_thumbnail_from_url! raw_url
|
||||||
|
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
|
||||||
|
thumbnail.attach(
|
||||||
|
io: StringIO.new(response.body),
|
||||||
|
filename: remote_thumbnail_filename(response.url),
|
||||||
|
content_type: response.content_type)
|
||||||
|
resized_thumbnail!
|
||||||
|
rescue Preview::UrlSafety::UnsafeUrl,
|
||||||
|
Preview::ThumbnailFetcher::GenerationFailed,
|
||||||
|
Preview::HttpFetcher::FetchFailed,
|
||||||
|
Preview::HttpFetcher::FetchTimeout,
|
||||||
|
Preview::HttpFetcher::ResponseTooLarge,
|
||||||
|
MiniMagick::Error => e
|
||||||
|
raise RemoteThumbnailFetchFailed, e.message
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remote_thumbnail_filename url
|
||||||
|
filename = File.basename(URI.parse(url).path.to_s)
|
||||||
|
return filename if filename.present? && filename != '/'
|
||||||
|
|
||||||
|
'thumbnail'
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
'thumbnail'
|
||||||
|
end
|
||||||
|
|
||||||
def validate_original_created_range
|
def validate_original_created_range
|
||||||
f = parse_original_created_value(:original_created_from)
|
f = parse_original_created_value(:original_created_from)
|
||||||
b = parse_original_created_value(:original_created_before)
|
b = parse_original_created_value(:original_created_before)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
class PostCreator
|
class PostCreator
|
||||||
class VideoMsParseError < ArgumentError; end
|
class VideoMsParseError < ArgumentError; end
|
||||||
|
|
||||||
|
attr_reader :field_warnings
|
||||||
|
|
||||||
def initialize actor:, attributes:
|
def initialize actor:, attributes:
|
||||||
@actor = actor
|
@actor = actor
|
||||||
@attributes = attributes.symbolize_keys
|
@attributes = attributes.symbolize_keys
|
||||||
|
@field_warnings = { }
|
||||||
end
|
end
|
||||||
|
|
||||||
def create!
|
def create!
|
||||||
@@ -13,8 +16,7 @@ class PostCreator
|
|||||||
uploaded_user: @actor,
|
uploaded_user: @actor,
|
||||||
original_created_from: @attributes[:original_created_from].presence,
|
original_created_from: @attributes[:original_created_from].presence,
|
||||||
original_created_before: @attributes[:original_created_before].presence)
|
original_created_before: @attributes[:original_created_before].presence)
|
||||||
thumbnail = @attributes[:thumbnail]
|
attach_thumbnail!(post)
|
||||||
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail)) if thumbnail.present?
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
@@ -34,6 +36,21 @@ class PostCreator
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def attach_thumbnail! post
|
||||||
|
thumbnail = @attributes[:thumbnail]
|
||||||
|
if thumbnail.present?
|
||||||
|
post.thumbnail.attach(Post.resized_thumbnail_attachment(thumbnail))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
thumbnail_base = post.thumbnail_base
|
||||||
|
return if thumbnail_base.blank?
|
||||||
|
|
||||||
|
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||||
|
rescue Post::RemoteThumbnailFetchFailed => e
|
||||||
|
@field_warnings[:thumbnail_base] = [e.message]
|
||||||
|
end
|
||||||
|
|
||||||
def tag_names = @attributes[:tags].to_s.split
|
def tag_names = @attributes[:tags].to_s.split
|
||||||
|
|
||||||
def parent_post_ids
|
def parent_post_ids
|
||||||
|
|||||||
@@ -36,8 +36,13 @@ class PostImportRunner
|
|||||||
|
|
||||||
attributes['tags'] = preview[:attributes]['tags']
|
attributes['tags'] = preview[:attributes]['tags']
|
||||||
attributes['url'] = row['url']
|
attributes['url'] = row['url']
|
||||||
post = PostCreator.new(actor: @actor, attributes:).create!
|
creator = PostCreator.new(actor: @actor, attributes:)
|
||||||
{ source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
post = creator.create!
|
||||||
|
result = { source_row: row['source_row'], status: 'created', post: PostRepr.base(post) }
|
||||||
|
if creator.field_warnings.present?
|
||||||
|
result[:field_warnings] = creator.field_warnings
|
||||||
|
end
|
||||||
|
result
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
existing_post = existing_post_for_race(row, e.record)
|
existing_post = existing_post_for_race(row, e.record)
|
||||||
if existing_post
|
if existing_post
|
||||||
|
|||||||
@@ -25,6 +25,22 @@ module Preview
|
|||||||
fetch_image!(metadata[:image_url])
|
fetch_image!(metadata[:image_url])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.fetch_image_response(raw_url)
|
||||||
|
uri, = UrlSafety.validate(raw_url)
|
||||||
|
response = HttpFetcher.fetch(uri.to_s)
|
||||||
|
unless allowed_image_content_type?(response.content_type)
|
||||||
|
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
rescue HttpFetcher::FetchTimeout
|
||||||
|
raise
|
||||||
|
rescue HttpFetcher::ResponseTooLarge
|
||||||
|
raise
|
||||||
|
rescue HttpFetcher::FetchFailed
|
||||||
|
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
|
||||||
|
end
|
||||||
|
|
||||||
def self.title(raw_url)
|
def self.title(raw_url)
|
||||||
uri, = UrlSafety.validate(raw_url)
|
uri, = UrlSafety.validate(raw_url)
|
||||||
HtmlMetadataExtractor.extract(
|
HtmlMetadataExtractor.extract(
|
||||||
@@ -45,18 +61,7 @@ module Preview
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.fetch_image!(url)
|
def self.fetch_image!(url)
|
||||||
response = HttpFetcher.fetch(url)
|
fetch_image_response(url).body
|
||||||
unless allowed_image_content_type?(response.content_type)
|
|
||||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
|
|
||||||
end
|
|
||||||
|
|
||||||
response.body
|
|
||||||
rescue HttpFetcher::FetchTimeout
|
|
||||||
raise
|
|
||||||
rescue HttpFetcher::ResponseTooLarge
|
|
||||||
raise
|
|
||||||
rescue HttpFetcher::FetchFailed
|
|
||||||
raise GenerationFailed, 'サムネール画像を取得できませんでした.'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.niconico_thumbnail_url(uri)
|
def self.niconico_thumbnail_url(uri)
|
||||||
@@ -70,16 +75,20 @@ module Preview
|
|||||||
|
|
||||||
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
|
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
|
||||||
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
|
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
|
||||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
payload = {
|
||||||
video_id:,
|
url: uri.to_s,
|
||||||
error: e.class.name,
|
video_id:,
|
||||||
message: e.message }.to_json }")
|
error: e.class.name,
|
||||||
|
message: e.message }
|
||||||
|
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
|
||||||
nil
|
nil
|
||||||
rescue Nokogiri::XML::SyntaxError => e
|
rescue Nokogiri::XML::SyntaxError => e
|
||||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
payload = {
|
||||||
video_id:,
|
url: uri.to_s,
|
||||||
error: e.class.name,
|
video_id:,
|
||||||
message: e.message }.to_json }")
|
error: e.class.name,
|
||||||
|
message: e.message }
|
||||||
|
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ payload.to_json }")
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
require 'open-uri'
|
|
||||||
require 'set'
|
require 'set'
|
||||||
require 'time'
|
require 'time'
|
||||||
|
|
||||||
@@ -127,12 +126,12 @@ module Youtube
|
|||||||
return if post.thumbnail.attached?
|
return if post.thumbnail.attached?
|
||||||
return if thumbnail_url.blank?
|
return if thumbnail_url.blank?
|
||||||
|
|
||||||
post.thumbnail.attach(
|
post.attach_thumbnail_from_url!(thumbnail_url)
|
||||||
io: URI.open(thumbnail_url),
|
rescue Post::RemoteThumbnailFetchFailed => e
|
||||||
filename: File.basename(URI.parse(thumbnail_url).path),
|
Rails.logger.info("youtube_sync_thumbnail_fetch_failed #{ { post_id: post.id,
|
||||||
content_type: 'image/jpeg')
|
thumbnail_url:,
|
||||||
|
error: e.class.name,
|
||||||
post.resized_thumbnail!
|
message: e.message }.to_json }")
|
||||||
end
|
end
|
||||||
|
|
||||||
def youtube_url_regexp id
|
def youtube_url_regexp id
|
||||||
|
|||||||
@@ -70,11 +70,17 @@ namespace :nico do
|
|||||||
unless post.thumbnail.attached?
|
unless post.thumbnail.attached?
|
||||||
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
|
thumbnail_base = fetch_thumbnail.(post.url) rescue nil
|
||||||
if thumbnail_base.present?
|
if thumbnail_base.present?
|
||||||
post.thumbnail.attach(
|
|
||||||
io: URI.open(thumbnail_base),
|
|
||||||
filename: File.basename(URI.parse(thumbnail_base).path),
|
|
||||||
content_type: 'image/jpeg')
|
|
||||||
attrs[:thumbnail_base] = thumbnail_base
|
attrs[:thumbnail_base] = thumbnail_base
|
||||||
|
begin
|
||||||
|
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||||
|
rescue Post::RemoteThumbnailFetchFailed => e
|
||||||
|
payload = {
|
||||||
|
post_id: post.id,
|
||||||
|
thumbnail_base:,
|
||||||
|
error: e.class.name,
|
||||||
|
message: e.message }
|
||||||
|
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -82,7 +88,6 @@ namespace :nico do
|
|||||||
post_changed = post.changed?
|
post_changed = post.changed?
|
||||||
if post_changed
|
if post_changed
|
||||||
post.save!
|
post.save!
|
||||||
post.resized_thumbnail! if post.thumbnail.attached?
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
post_created = true
|
post_created = true
|
||||||
@@ -91,13 +96,18 @@ namespace :nico do
|
|||||||
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
||||||
original_created_from:, original_created_before:)
|
original_created_from:, original_created_before:)
|
||||||
if thumbnail_base.present?
|
if thumbnail_base.present?
|
||||||
post.thumbnail.attach(
|
begin
|
||||||
io: URI.open(thumbnail_base),
|
post.attach_thumbnail_from_url!(thumbnail_base)
|
||||||
filename: File.basename(URI.parse(thumbnail_base).path),
|
rescue Post::RemoteThumbnailFetchFailed => e
|
||||||
content_type: 'image/jpeg')
|
payload = {
|
||||||
|
post_id: nil,
|
||||||
|
thumbnail_base:,
|
||||||
|
error: e.class.name,
|
||||||
|
message: e.message }
|
||||||
|
Rails.logger.info("nico_sync_thumbnail_fetch_failed #{ payload.to_json }")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
post.save!
|
post.save!
|
||||||
post.resized_thumbnail!
|
|
||||||
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
|
sync_post_tags!(post, [Tag.tagme.id, Tag.bot.id, Tag.niconico.id, Tag.video.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'tempfile'
|
||||||
|
|
||||||
RSpec.describe Post, type: :model do
|
RSpec.describe Post, type: :model do
|
||||||
before do
|
before do
|
||||||
@@ -65,4 +66,133 @@ RSpec.describe Post, type: :model do
|
|||||||
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'thumbnail processing' do
|
||||||
|
def image_blob(width:, height:, background:, draw: nil)
|
||||||
|
Tempfile.create(['post-thumbnail', '.png']) do |file|
|
||||||
|
MiniMagick::Tool::Convert.new do |convert|
|
||||||
|
convert.size "#{ width }x#{ height }"
|
||||||
|
convert.xc background
|
||||||
|
draw&.call(convert)
|
||||||
|
convert << file.path
|
||||||
|
end
|
||||||
|
File.binread(file.path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def upload_for(blob)
|
||||||
|
StringIO.new(blob).tap(&:rewind)
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_image(attachment)
|
||||||
|
blob =
|
||||||
|
attachment.is_a?(Hash) ? attachment.fetch(:io).read : attachment.download
|
||||||
|
MiniMagick::Image.read(blob)
|
||||||
|
end
|
||||||
|
|
||||||
|
def colour_at(image, x, y)
|
||||||
|
image.get_pixels.fetch(y).fetch(x)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expect_green(pixel)
|
||||||
|
expect(pixel[1]).to be > pixel[0] + 40
|
||||||
|
expect(pixel[1]).to be > pixel[2] + 40
|
||||||
|
end
|
||||||
|
|
||||||
|
def expect_red(pixel)
|
||||||
|
expect(pixel[0]).to be > pixel[1] + 40
|
||||||
|
expect(pixel[0]).to be > pixel[2] + 40
|
||||||
|
end
|
||||||
|
|
||||||
|
def expect_blue(pixel)
|
||||||
|
expect(pixel[2]).to be > pixel[0] + 40
|
||||||
|
expect(pixel[2]).to be > pixel[1] + 40
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.resized_thumbnail_attachment' do
|
||||||
|
it 'centre-crops a wide image to 180x180 without distorting it' do
|
||||||
|
blob = image_blob(
|
||||||
|
width: 360,
|
||||||
|
height: 180,
|
||||||
|
background: 'red',
|
||||||
|
draw: -> convert {
|
||||||
|
convert.fill 'green'
|
||||||
|
convert.draw 'rectangle 90,0 269,179'
|
||||||
|
})
|
||||||
|
|
||||||
|
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||||
|
image = read_image(resized)
|
||||||
|
|
||||||
|
expect(image.dimensions).to eq([180, 180])
|
||||||
|
expect_green(colour_at(image, 0, 90))
|
||||||
|
expect_green(colour_at(image, 90, 90))
|
||||||
|
expect_green(colour_at(image, 179, 90))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'centre-crops a tall image to 180x180 without distorting it' do
|
||||||
|
blob = image_blob(
|
||||||
|
width: 180,
|
||||||
|
height: 360,
|
||||||
|
background: 'red',
|
||||||
|
draw: -> convert {
|
||||||
|
convert.fill 'green'
|
||||||
|
convert.draw 'rectangle 0,90 179,269'
|
||||||
|
})
|
||||||
|
|
||||||
|
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||||
|
image = read_image(resized)
|
||||||
|
|
||||||
|
expect(image.dimensions).to eq([180, 180])
|
||||||
|
expect_green(colour_at(image, 90, 0))
|
||||||
|
expect_green(colour_at(image, 90, 90))
|
||||||
|
expect_green(colour_at(image, 90, 179))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps a square image square without distortion' do
|
||||||
|
blob = image_blob(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
background: 'red',
|
||||||
|
draw: -> convert {
|
||||||
|
convert.fill 'blue'
|
||||||
|
convert.draw 'rectangle 90,0 179,179'
|
||||||
|
})
|
||||||
|
|
||||||
|
resized = described_class.resized_thumbnail_attachment(upload_for(blob))
|
||||||
|
image = read_image(resized)
|
||||||
|
|
||||||
|
expect(image.dimensions).to eq([180, 180])
|
||||||
|
expect_red(colour_at(image, 20, 90))
|
||||||
|
expect_blue(colour_at(image, 160, 90))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#attach_thumbnail_from_url!' do
|
||||||
|
it 'attaches a fetched remote image through the common resize path' do
|
||||||
|
blob = image_blob(
|
||||||
|
width: 240,
|
||||||
|
height: 180,
|
||||||
|
background: 'red',
|
||||||
|
draw: -> convert {
|
||||||
|
convert.fill 'green'
|
||||||
|
convert.draw 'rectangle 30,0 209,179'
|
||||||
|
})
|
||||||
|
response = Preview::HttpFetcher::Response.new(
|
||||||
|
blob,
|
||||||
|
'image/png',
|
||||||
|
'https://example.com/thumb.png')
|
||||||
|
allow(Preview::ThumbnailFetcher).to receive(:fetch_image_response)
|
||||||
|
.with('https://example.com/thumb.png')
|
||||||
|
.and_return(response)
|
||||||
|
|
||||||
|
post = described_class.create!(title: 'title', url: 'https://example.com/post')
|
||||||
|
|
||||||
|
post.attach_thumbnail_from_url!('https://example.com/thumb.png')
|
||||||
|
|
||||||
|
expect(post.thumbnail).to be_attached
|
||||||
|
image = read_image(post.thumbnail)
|
||||||
|
expect(image.dimensions).to eq([180, 180])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
require 'base64'
|
||||||
|
|
||||||
|
RSpec.describe PostCreator do
|
||||||
|
let(:actor) { create(:user, :member) }
|
||||||
|
|
||||||
|
def real_thumbnail_upload
|
||||||
|
gif = Base64.decode64('R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||||
|
|
||||||
|
Rack::Test::UploadedFile.new(
|
||||||
|
StringIO.new(gif),
|
||||||
|
'image/gif',
|
||||||
|
original_filename: 'thumbnail.gif')
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(Tag).to receive(:normalise_tags!).and_return({ tags: [], sections: {} })
|
||||||
|
allow(TagVersioning).to receive(:record_tag_snapshots!)
|
||||||
|
allow(Tag).to receive(:expand_parent_tags).and_return([])
|
||||||
|
allow(PostVersionRecorder).to receive(:record!)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'prefers an explicit upload over thumbnail_base' do
|
||||||
|
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||||
|
io: StringIO.new('upload'),
|
||||||
|
filename: 'resized_thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg')
|
||||||
|
expect_any_instance_of(Post).not_to receive(:attach_thumbnail_from_url!)
|
||||||
|
|
||||||
|
post = described_class.new(
|
||||||
|
actor:,
|
||||||
|
attributes: {
|
||||||
|
title: 'title',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
thumbnail: real_thumbnail_upload,
|
||||||
|
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||||
|
tags: '' }).create!
|
||||||
|
|
||||||
|
expect(post.thumbnail).to be_attached
|
||||||
|
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the common remote thumbnail attach path when thumbnail_base is given' do
|
||||||
|
expect_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||||
|
.with('https://example.com/thumb.jpg') do |post, _url|
|
||||||
|
post.thumbnail.attach(
|
||||||
|
io: StringIO.new('thumbnail'),
|
||||||
|
filename: 'thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg')
|
||||||
|
end
|
||||||
|
|
||||||
|
post = described_class.new(
|
||||||
|
actor:,
|
||||||
|
attributes: {
|
||||||
|
title: 'title',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||||
|
tags: '' }).create!
|
||||||
|
|
||||||
|
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||||
|
expect(post.thumbnail).to be_attached
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps creating the post and records a warning when remote thumbnail fetch fails' do
|
||||||
|
allow_any_instance_of(Post).to receive(:attach_thumbnail_from_url!)
|
||||||
|
.and_raise(Post::RemoteThumbnailFetchFailed, 'サムネール画像を取得できませんでした.')
|
||||||
|
creator = described_class.new(
|
||||||
|
actor:,
|
||||||
|
attributes: {
|
||||||
|
title: 'title',
|
||||||
|
url: 'https://example.com/post',
|
||||||
|
thumbnail_base: 'https://example.com/thumb.jpg',
|
||||||
|
tags: '' })
|
||||||
|
|
||||||
|
post = creator.create!
|
||||||
|
|
||||||
|
expect(post.thumbnail_base).to eq('https://example.com/thumb.jpg')
|
||||||
|
expect(post.thumbnail).not_to be_attached
|
||||||
|
expect(creator.field_warnings).to eq(
|
||||||
|
thumbnail_base: ['サムネール画像を取得できませんでした.'])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -130,4 +130,23 @@ RSpec.describe PostImportRunner do
|
|||||||
|
|
||||||
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
|
expect(result).to include(status: 'skipped', existing_post_id: existing.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns thumbnail warnings from PostCreator on a created row' do
|
||||||
|
previewer = instance_double(PostImportPreviewer)
|
||||||
|
allow(PostImportPreviewer).to receive(:new).and_return(previewer)
|
||||||
|
allow(previewer).to receive(:preview_rows).and_return([preview])
|
||||||
|
created_post = create(:post)
|
||||||
|
creator = instance_double(
|
||||||
|
PostCreator,
|
||||||
|
create!: created_post,
|
||||||
|
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] })
|
||||||
|
allow(PostCreator).to receive(:new).and_return(creator)
|
||||||
|
|
||||||
|
result = described_class.new(actor:, rows: [row]).run.fetch(:rows).first
|
||||||
|
|
||||||
|
expect(result).to include(
|
||||||
|
status: 'created',
|
||||||
|
field_warnings: { thumbnail_base: ['サムネール画像を取得できませんでした.'] }
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,4 +48,56 @@ RSpec.describe Preview::ThumbnailFetcher do
|
|||||||
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '.fetch_image_response' do
|
||||||
|
it 'rejects an unsafe input URL before HTTP fetch' do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate)
|
||||||
|
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||||
|
expect(Preview::HttpFetcher).not_to receive(:fetch)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.fetch_image_response('https://unsafe.example.com/thumb.jpg')
|
||||||
|
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an unsafe redirect target' do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate)
|
||||||
|
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||||
|
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||||
|
.with('https://example.com/thumb.jpg')
|
||||||
|
.and_raise(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||||
|
}.to raise_error(Preview::UrlSafety::UnsafeUrl, '安全でない接続先は使用できません.')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an oversized image response' do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate)
|
||||||
|
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||||
|
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||||
|
.with('https://example.com/thumb.jpg')
|
||||||
|
.and_raise(Preview::HttpFetcher::ResponseTooLarge, 'too large')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||||
|
}.to raise_error(Preview::HttpFetcher::ResponseTooLarge)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a non-image content type' do
|
||||||
|
allow(Preview::UrlSafety).to receive(:validate)
|
||||||
|
.and_return([URI.parse('https://example.com/thumb.jpg'), ['8.8.8.8']])
|
||||||
|
allow(Preview::HttpFetcher).to receive(:fetch)
|
||||||
|
.with('https://example.com/thumb.jpg')
|
||||||
|
.and_return(
|
||||||
|
Preview::HttpFetcher::Response.new(
|
||||||
|
'<html></html>',
|
||||||
|
'text/html',
|
||||||
|
'https://example.com/thumb.jpg'))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.fetch_image_response('https://example.com/thumb.jpg')
|
||||||
|
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,6 +10,21 @@ RSpec.describe Youtube::Sync do
|
|||||||
allow(sync).to receive(:attach_thumbnail_if_needed!)
|
allow(sync).to receive(:attach_thumbnail_if_needed!)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#attach_thumbnail_if_needed!' do
|
||||||
|
it 'uses the common remote thumbnail attach path' do
|
||||||
|
post = create(:post, thumbnail_base: nil)
|
||||||
|
allow(sync).to receive(:attach_thumbnail_if_needed!).and_call_original
|
||||||
|
|
||||||
|
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||||
|
.with('https://example.com/thumb.jpg')
|
||||||
|
|
||||||
|
sync.send(
|
||||||
|
:attach_thumbnail_if_needed!,
|
||||||
|
post,
|
||||||
|
'https://example.com/thumb.jpg')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#sync!' do
|
describe '#sync!' do
|
||||||
it 'returns without fetching video details when no video ids are discovered' do
|
it 'returns without fetching video details when no video ids are discovered' do
|
||||||
allow(sync).to receive(:query_terms).and_return([])
|
allow(sync).to receive(:query_terms).and_return([])
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ RSpec.describe "nico:sync" do
|
|||||||
expect(active_tag_names).to include("bot操作")
|
expect(active_tag_names).to include("bot操作")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it '既存 post のサムネール取得に共通 attach 経路を使ふ' do
|
||||||
|
post = Post.create!(
|
||||||
|
title: 'old',
|
||||||
|
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||||
|
uploaded_user: nil)
|
||||||
|
Tag.bot
|
||||||
|
Tag.tagme
|
||||||
|
|
||||||
|
stub_python([{ 'code' => 'sm9', 'title' => 't', 'tags' => [] }])
|
||||||
|
allow(URI).to receive(:open)
|
||||||
|
.and_return(
|
||||||
|
StringIO.new(
|
||||||
|
'<meta name="thumbnail" content="https://example.com/thumb.jpg">'))
|
||||||
|
expect(post).to receive(:attach_thumbnail_from_url!)
|
||||||
|
.with('https://example.com/thumb.jpg')
|
||||||
|
|
||||||
|
run_rake_task('nico:sync')
|
||||||
|
end
|
||||||
|
|
||||||
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
it "既存 post にあった古い nico tag は active から外され、履歴として discard される" do
|
||||||
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
post = Post.create!(title: "old", url: "https://www.nicovideo.jp/watch/sm9", uploaded_user: nil)
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ export const mergeImportResults = (
|
|||||||
skipReason: undefined,
|
skipReason: undefined,
|
||||||
createdPostId: result.post.id,
|
createdPostId: result.post.id,
|
||||||
existingPostId: undefined,
|
existingPostId: undefined,
|
||||||
|
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||||
|
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
case 'skipped':
|
case 'skipped':
|
||||||
return {
|
return {
|
||||||
@@ -144,6 +146,8 @@ export const mergeImportResults = (
|
|||||||
skipReason: 'existing',
|
skipReason: 'existing',
|
||||||
createdPostId: undefined,
|
createdPostId: undefined,
|
||||||
existingPostId: result.existingPostId,
|
existingPostId: result.existingPostId,
|
||||||
|
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||||
|
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return {
|
return {
|
||||||
@@ -152,6 +156,8 @@ export const mergeImportResults = (
|
|||||||
skipReason: undefined,
|
skipReason: undefined,
|
||||||
createdPostId: undefined,
|
createdPostId: undefined,
|
||||||
existingPostId: undefined,
|
existingPostId: undefined,
|
||||||
|
fieldWarnings: result.fieldWarnings ?? row.fieldWarnings,
|
||||||
|
baseWarnings: result.baseWarnings ?? row.baseWarnings,
|
||||||
importErrors: result.errors }
|
importErrors: result.errors }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,15 +41,21 @@ export type PostImportResultRow =
|
|||||||
sourceRow: number
|
sourceRow: number
|
||||||
status: 'created'
|
status: 'created'
|
||||||
post: { id: number }
|
post: { id: number }
|
||||||
|
fieldWarnings?: Record<string, string[]>
|
||||||
|
baseWarnings?: string[]
|
||||||
errors?: Record<string, string[]> }
|
errors?: Record<string, string[]> }
|
||||||
| {
|
| {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
status: 'skipped'
|
status: 'skipped'
|
||||||
existingPostId: number
|
existingPostId: number
|
||||||
|
fieldWarnings?: Record<string, string[]>
|
||||||
|
baseWarnings?: string[]
|
||||||
errors?: Record<string, string[]> }
|
errors?: Record<string, string[]> }
|
||||||
| {
|
| {
|
||||||
sourceRow: number
|
sourceRow: number
|
||||||
status: 'failed'
|
status: 'failed'
|
||||||
|
fieldWarnings?: Record<string, string[]>
|
||||||
|
baseWarnings?: string[]
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>
|
||||||
recoverable?: boolean }
|
recoverable?: boolean }
|
||||||
|
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする