コミットを比較
8 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 5cf46406a9 | |||
| 29c385ed9f | |||
| abeee76ddd | |||
| 347a0ebbba | |||
| f8e8d8fbb0 | |||
| 33f9c6602f | |||
| 2d6af0aa5b | |||
| c0879ac117 |
@@ -89,13 +89,15 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
tag = resolve_material_tag!(tag_name_raw)
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
material = Material.new(tag:, url:,
|
material = Material.new(tag:, url:,
|
||||||
created_by_user: current_user,
|
created_by_user: current_user,
|
||||||
updated_by_user: current_user)
|
updated_by_user: current_user)
|
||||||
material.file.attach(uploaded_blob) if uploaded_blob
|
material.file.attach(uploaded_blob) if uploaded_blob
|
||||||
material.save!
|
material.save!
|
||||||
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -137,7 +139,10 @@ class MaterialsController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
MaterialVersionRecorder.ensure_snapshot!(material, created_by_user: current_user)
|
||||||
tag = resolve_material_tag!(tag_name_raw)
|
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
||||||
|
tag = tag_name.tag
|
||||||
|
tag = Tag.create!(tag_name:, category: :material) unless tag
|
||||||
|
|
||||||
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
material.assign_attributes(tag:, url:, updated_by_user: current_user)
|
||||||
if uploaded_blob
|
if uploaded_blob
|
||||||
material.file.attach(uploaded_blob)
|
material.file.attach(uploaded_blob)
|
||||||
@@ -145,7 +150,6 @@ class MaterialsController < ApplicationController
|
|||||||
material.file.detach
|
material.file.detach
|
||||||
end
|
end
|
||||||
material.save!
|
material.save!
|
||||||
TagVersioning.record_tag_snapshot!(tag, created_by_user: current_user)
|
|
||||||
upsert_export_paths!(material)
|
upsert_export_paths!(material)
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :update,
|
MaterialVersionRecorder.record!(material:, event_type: :update,
|
||||||
created_by_user: current_user)
|
created_by_user: current_user)
|
||||||
@@ -236,12 +240,6 @@ class MaterialsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_material_tag! tag_name_raw
|
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
|
||||||
tag = tag_name.tag
|
|
||||||
tag || Tag.create!(tag_name:, category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
def material_index_needs_tag_name? filters
|
def material_index_needs_tag_name? filters
|
||||||
filters[:q].present? || filters[:sort] == 'tag_name'
|
filters[:q].present? || filters[:sort] == 'tag_name'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,57 +1,50 @@
|
|||||||
class PreviewController < ApplicationController
|
class PreviewController < ApplicationController
|
||||||
before_action :require_member!
|
|
||||||
|
|
||||||
def title
|
def title
|
||||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに.
|
||||||
|
return head :unauthorized unless current_user
|
||||||
|
|
||||||
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
|
url = params[:url]
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
return render_bad_request('URL は必須です.') unless url.present?
|
||||||
|
|
||||||
|
unless url.start_with?(/http(s)?:\/\//)
|
||||||
|
url = 'http://' + url
|
||||||
|
end
|
||||||
|
|
||||||
|
html = URI.open(url, open_timeout: 5, read_timeout: 5).read
|
||||||
|
doc = Nokogiri::HTML.parse(html)
|
||||||
|
title = doc.at('title')&.text&.strip
|
||||||
|
|
||||||
|
render json: { title: title }
|
||||||
|
rescue => e
|
||||||
render_bad_request(e.message)
|
render_bad_request(e.message)
|
||||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
|
||||||
render_preview_error(e.message, :gateway_timeout)
|
|
||||||
rescue Preview::HttpFetcher::ResponseTooLarge => e
|
|
||||||
render_preview_error(e.message, :payload_too_large)
|
|
||||||
rescue Preview::HttpFetcher::FetchFailed => e
|
|
||||||
render_preview_error(e.message, :bad_gateway)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail
|
def thumbnail
|
||||||
return render_bad_request('URL は必須です.') if params[:url].blank?
|
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする.
|
||||||
|
|
||||||
image = MiniMagick::Image.read(Preview::ThumbnailFetcher.fetch(params[:url]))
|
|
||||||
image.auto_orient
|
|
||||||
image.resize '180x180>'
|
|
||||||
image.format 'png'
|
|
||||||
width, height = image.dimensions
|
|
||||||
raise Preview::ThumbnailFetcher::GenerationFailed, 'サムネール画像の変換に失敗しました.' if width > 180 || height > 180
|
|
||||||
|
|
||||||
send_data image.to_blob, type: 'image/png', disposition: 'inline'
|
|
||||||
rescue Preview::UrlSafety::UnsafeUrl => e
|
|
||||||
render_bad_request(e.message)
|
|
||||||
rescue Preview::HttpFetcher::FetchTimeout => e
|
|
||||||
render_preview_error(e.message, :gateway_timeout)
|
|
||||||
rescue Preview::HttpFetcher::ResponseTooLarge => e
|
|
||||||
render_preview_error(e.message, :payload_too_large)
|
|
||||||
rescue Preview::HttpFetcher::FetchFailed => e
|
|
||||||
render_preview_error(e.message, :bad_gateway)
|
|
||||||
rescue Preview::ThumbnailFetcher::GenerationFailed, MiniMagick::Error => e
|
|
||||||
render_unprocessable_entity(e.message)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def require_member!
|
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return if current_user.gte_member?
|
|
||||||
|
|
||||||
head :forbidden
|
url = params[:url]
|
||||||
|
return render_bad_request('URL は必須です.') if url.blank?
|
||||||
|
|
||||||
|
unless url.start_with?(/http(s)?:\/\//)
|
||||||
|
url = 'http://' + url
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_preview_error(message, status)
|
path = Rails.root.join('tmp', "thumb_#{ SecureRandom.hex }.png")
|
||||||
render json: { type: status.to_s,
|
system("node #{ Rails.root }/lib/screenshot.js #{ Shellwords.escape(url) } #{ path }")
|
||||||
message:,
|
|
||||||
|
if File.exist?(path)
|
||||||
|
image = MiniMagick::Image.open(path)
|
||||||
|
image.resize '180x180'
|
||||||
|
File.delete(path) rescue nil
|
||||||
|
send_file image.path, type: 'image/png', disposition: 'inline'
|
||||||
|
else
|
||||||
|
render json: { type: 'internal_server_error',
|
||||||
|
message: 'サムネールを生成できませんでした.',
|
||||||
errors: { },
|
errors: { },
|
||||||
base_errors: [message] },
|
base_errors: ['サムネールを生成できませんでした.'] },
|
||||||
status:
|
status: :internal_server_error
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -200,45 +200,14 @@ class TagsController < ApplicationController
|
|||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
return head :not_found unless tag
|
return head :not_found unless tag
|
||||||
|
|
||||||
rows = normalise_deerjikist_rows(tag)
|
|
||||||
return if performed?
|
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tag.lock!
|
tag.deerjikists = []
|
||||||
|
params[:_json].each.with_index do |item, i|
|
||||||
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
platform = item[:platform]
|
||||||
row_indexes_by_key = rows_by_key(rows)
|
code = normalise_deerjikist_code(platform, item[:code])
|
||||||
locked_deerjikists = lock_deerjikists_for_tag_update(tag.id, requested_keys)
|
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
|
||||||
current_deerjikists = locked_deerjikists.filter { |deerjikist|
|
deerjikist.tag = tag
|
||||||
deerjikist.tag_id == tag.id
|
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save
|
||||||
}
|
|
||||||
requested_deerjikists = locked_deerjikists.filter { |deerjikist|
|
|
||||||
row_indexes_by_key.key?([deerjikist.platform, deerjikist.code])
|
|
||||||
}
|
|
||||||
|
|
||||||
render_deerjikist_conflicts(requested_deerjikists, row_indexes_by_key, tag)
|
|
||||||
raise ActiveRecord::Rollback if performed?
|
|
||||||
|
|
||||||
requested_keys_set = requested_keys.to_set
|
|
||||||
current_deerjikists.each do |deerjikist|
|
|
||||||
key = [deerjikist.platform, deerjikist.code]
|
|
||||||
deerjikist.destroy! unless requested_keys_set.include?(key)
|
|
||||||
end
|
|
||||||
|
|
||||||
existing_keys = requested_deerjikists.to_h { |deerjikist|
|
|
||||||
[[deerjikist.platform, deerjikist.code], true]
|
|
||||||
}
|
|
||||||
requested_keys.each do |platform, code|
|
|
||||||
next if existing_keys[[platform, code]]
|
|
||||||
|
|
||||||
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
|
||||||
row_index = row_indexes_by_key[[platform, code]].first
|
|
||||||
begin
|
|
||||||
render_deerjikist_form_record_invalid(deerjikist, row_index) unless deerjikist.save
|
|
||||||
rescue ActiveRecord::RecordNotUnique
|
|
||||||
conflicts = lock_deerjikists_for_tag_update(tag.id, [[platform, code]])
|
|
||||||
render_deerjikist_conflicts(conflicts, row_indexes_by_key, tag)
|
|
||||||
end
|
|
||||||
raise ActiveRecord::Rollback if performed?
|
raise ActiveRecord::Rollback if performed?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -684,7 +653,6 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def normalise_deerjikist_code platform, code
|
def normalise_deerjikist_code platform, code
|
||||||
code = code.to_s
|
|
||||||
return code if platform != 'youtube' || code[0] != '@'
|
return code if platform != 'youtube' || code[0] != '@'
|
||||||
|
|
||||||
url = "https://www.youtube.com/#{ code }"
|
url = "https://www.youtube.com/#{ code }"
|
||||||
@@ -701,82 +669,6 @@ class TagsController < ApplicationController
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalise_deerjikist_rows tag
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
params[:_json].each.with_index do |item, index|
|
|
||||||
platform = item[:platform]
|
|
||||||
unless Deerjikist.platforms.key?(platform)
|
|
||||||
render_deerjikist_platform_invalid(index)
|
|
||||||
return rows
|
|
||||||
end
|
|
||||||
|
|
||||||
code = normalise_deerjikist_code(platform, item[:code])
|
|
||||||
deerjikist = Deerjikist.new(platform:, code:, tag:)
|
|
||||||
unless deerjikist.valid?
|
|
||||||
render_deerjikist_form_record_invalid(deerjikist, index)
|
|
||||||
return rows
|
|
||||||
end
|
|
||||||
|
|
||||||
rows << { index:, platform:, code: }
|
|
||||||
end
|
|
||||||
|
|
||||||
rows
|
|
||||||
end
|
|
||||||
|
|
||||||
def lock_deerjikists_for_tag_update tag_id, keys
|
|
||||||
clauses = ['tag_id = ?']
|
|
||||||
values = [tag_id]
|
|
||||||
|
|
||||||
keys.each do |platform, code|
|
|
||||||
clauses << '(platform = ? AND code = ?)'
|
|
||||||
values << platform << code
|
|
||||||
end
|
|
||||||
|
|
||||||
Deerjikist
|
|
||||||
.where(clauses.join(' OR '), *values)
|
|
||||||
.order(:platform, :code)
|
|
||||||
.lock
|
|
||||||
.to_a
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_conflicts deerjikists, row_indexes_by_key, tag
|
|
||||||
conflicts = deerjikists.filter { |deerjikist| deerjikist.tag_id != tag.id }
|
|
||||||
return if conflicts.empty?
|
|
||||||
|
|
||||||
tag_names_by_id = Tag
|
|
||||||
.joins(:tag_name)
|
|
||||||
.where(id: conflicts.map(&:tag_id).uniq)
|
|
||||||
.pluck('tags.id', 'tag_names.name')
|
|
||||||
.to_h
|
|
||||||
fields = { }
|
|
||||||
|
|
||||||
conflicts.each do |deerjikist|
|
|
||||||
message = "この情報は既に「#{ tag_names_by_id[deerjikist.tag_id] }」に紐づいてゐます."
|
|
||||||
row_indexes_by_key[[deerjikist.platform, deerjikist.code]].each do |index|
|
|
||||||
field = :"deerjikists.#{ index }.code"
|
|
||||||
fields[field] ||= []
|
|
||||||
fields[field] << message
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
render_validation_error fields:
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_platform_invalid index
|
|
||||||
render_validation_error fields: {
|
|
||||||
:"deerjikists.#{ index }.platform" => ['値が不正です.'],
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def rows_by_key rows
|
|
||||||
rows.each_with_object({ }) do |row, result|
|
|
||||||
key = [row[:platform], row[:code]]
|
|
||||||
result[key] ||= []
|
|
||||||
result[key] << row[:index]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_deerjikist_form_record_invalid deerjikist, index
|
def render_deerjikist_form_record_invalid deerjikist, index
|
||||||
fields = { }
|
fields = { }
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
module Preview
|
|
||||||
class HtmlMetadataExtractor
|
|
||||||
IMAGE_SELECTORS = [
|
|
||||||
'meta[property="og:image"]',
|
|
||||||
'meta[name="twitter:image"]',
|
|
||||||
'meta[name="thumbnail"]'
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
def self.extract(response)
|
|
||||||
document = Nokogiri::HTML.parse(response.body)
|
|
||||||
image_url = IMAGE_SELECTORS.filter_map {
|
|
||||||
document.at_css(_1)&.[]('content')&.strip.presence
|
|
||||||
}.first
|
|
||||||
|
|
||||||
{ title: document.at_css('title')&.text&.strip,
|
|
||||||
image_url: image_url ? URI.join(response.url, image_url).to_s : nil }
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
{ title: document.at_css('title')&.text&.strip, image_url: nil }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
require 'net/http'
|
|
||||||
require 'json'
|
|
||||||
|
|
||||||
module Preview
|
|
||||||
class HttpFetcher
|
|
||||||
class FetchFailed < StandardError; end
|
|
||||||
class FetchTimeout < FetchFailed; end
|
|
||||||
class ResponseTooLarge < FetchFailed; end
|
|
||||||
|
|
||||||
MAX_REDIRECTS = 5
|
|
||||||
DEFAULT_MAX_BYTES = 5.megabytes
|
|
||||||
|
|
||||||
Response = Data.define(:body, :content_type, :url)
|
|
||||||
|
|
||||||
def self.fetch(raw_url, max_bytes: DEFAULT_MAX_BYTES, redirects: MAX_REDIRECTS)
|
|
||||||
uri, addresses = UrlSafety.validate(raw_url)
|
|
||||||
response = request(uri, addresses.first, max_bytes)
|
|
||||||
|
|
||||||
if response.is_a?(Net::HTTPRedirection)
|
|
||||||
location = response['location']
|
|
||||||
if redirects.zero?
|
|
||||||
log_failure(:redirect_limit,
|
|
||||||
url: uri.to_s,
|
|
||||||
redirects:,
|
|
||||||
location:,
|
|
||||||
content_type: response['content-type'],
|
|
||||||
content_length: response['content-length'])
|
|
||||||
raise FetchFailed, 'redirect が多すぎます.'
|
|
||||||
end
|
|
||||||
|
|
||||||
if location.blank?
|
|
||||||
log_failure(:blank_redirect_location,
|
|
||||||
url: uri.to_s,
|
|
||||||
redirects:,
|
|
||||||
content_type: response['content-type'],
|
|
||||||
content_length: response['content-length'])
|
|
||||||
raise FetchFailed, 'redirect 先が不正です.'
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_url =
|
|
||||||
begin
|
|
||||||
URI.join(uri, location).to_s
|
|
||||||
rescue URI::InvalidURIError => e
|
|
||||||
log_failure(:invalid_redirect_location,
|
|
||||||
url: uri.to_s,
|
|
||||||
redirects:,
|
|
||||||
location:,
|
|
||||||
error: e.class.name,
|
|
||||||
message: e.message)
|
|
||||||
raise FetchFailed, 'redirect 先が不正です.'
|
|
||||||
end
|
|
||||||
|
|
||||||
return fetch(redirect_url, max_bytes:, redirects: redirects - 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
unless response.is_a?(Net::HTTPSuccess)
|
|
||||||
log_failure(:http_status,
|
|
||||||
url: uri.to_s,
|
|
||||||
code: response.code,
|
|
||||||
content_type: response['content-type'],
|
|
||||||
content_length: response['content-length'])
|
|
||||||
raise FetchFailed, "外部サーバーが HTTP #{ response.code } を返しました."
|
|
||||||
end
|
|
||||||
|
|
||||||
Response.new(response.body, response['content-type'].to_s, uri.to_s)
|
|
||||||
rescue Net::OpenTimeout, Net::ReadTimeout, Timeout::Error => e
|
|
||||||
log_failure(:timeout, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
|
||||||
raise FetchTimeout, e.message
|
|
||||||
rescue SocketError, SystemCallError, OpenSSL::SSL::SSLError, EOFError => e
|
|
||||||
log_failure(:network_error, url: uri&.to_s || raw_url, error: e.class.name, message: e.message)
|
|
||||||
raise FetchFailed, e.message
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.request(uri, ip_address, max_bytes)
|
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
|
||||||
http.ipaddr = ip_address
|
|
||||||
http.use_ssl = uri.scheme == 'https'
|
|
||||||
http.open_timeout = 5
|
|
||||||
http.read_timeout = 8
|
|
||||||
http.write_timeout = 5
|
|
||||||
|
|
||||||
request = Net::HTTP::Get.new(uri)
|
|
||||||
request['User-Agent'] = 'BTRC-Hub thumbnail preview'
|
|
||||||
request['Accept'] = 'text/html,image/*;q=0.9,*/*;q=0.1'
|
|
||||||
|
|
||||||
http.request(request) do |response|
|
|
||||||
length = response['content-length'].to_i
|
|
||||||
if length > max_bytes
|
|
||||||
log_failure(:response_too_large,
|
|
||||||
url: uri.to_s,
|
|
||||||
content_type: response['content-type'],
|
|
||||||
content_length: response['content-length'],
|
|
||||||
max_bytes:)
|
|
||||||
raise ResponseTooLarge, '外部データが大きすぎます.'
|
|
||||||
end
|
|
||||||
|
|
||||||
body = +''
|
|
||||||
response.read_body do |chunk|
|
|
||||||
body << chunk
|
|
||||||
next unless body.bytesize > max_bytes
|
|
||||||
|
|
||||||
log_failure(:response_too_large,
|
|
||||||
url: uri.to_s,
|
|
||||||
content_type: response['content-type'],
|
|
||||||
content_length: response['content-length'],
|
|
||||||
bytes_read: body.bytesize,
|
|
||||||
max_bytes:)
|
|
||||||
raise ResponseTooLarge, '外部データが大きすぎます.'
|
|
||||||
end
|
|
||||||
response.instance_variable_set(:@body, body)
|
|
||||||
response.instance_variable_set(:@read, true)
|
|
||||||
return response
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.log_failure(reason, **payload)
|
|
||||||
Rails.logger.warn("preview_http_fetcher_failure #{ { reason:, **payload }.to_json }")
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :request, :log_failure
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
module Preview
|
|
||||||
class KnownSiteExtractor
|
|
||||||
def self.thumbnail_url(uri)
|
|
||||||
youtube_thumbnail(uri)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.niconico_video_id(uri)
|
|
||||||
case uri.host&.downcase
|
|
||||||
when 'www.nicovideo.jp', 'nicovideo.jp'
|
|
||||||
uri.path[%r{\A/watch/(sm\d+)\z}, 1]
|
|
||||||
when 'nico.ms'
|
|
||||||
uri.path[%r{\A/(sm\d+)\z}, 1]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.youtube_thumbnail(uri)
|
|
||||||
id =
|
|
||||||
case uri.host&.downcase
|
|
||||||
when 'youtu.be'
|
|
||||||
uri.path.split('/').reject(&:blank?).first
|
|
||||||
when 'www.youtube.com', 'youtube.com', 'm.youtube.com'
|
|
||||||
uri.path == '/watch' ? URI.decode_www_form(uri.query.to_s).to_h['v'] : nil
|
|
||||||
end
|
|
||||||
return unless id&.match?(/\A[A-Za-z0-9_-]{6,20}\z/)
|
|
||||||
|
|
||||||
"https://i.ytimg.com/vi/#{ id }/hqdefault.jpg"
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :youtube_thumbnail
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
module Preview
|
|
||||||
class ThumbnailFetcher
|
|
||||||
class GenerationFailed < StandardError; end
|
|
||||||
ALLOWED_IMAGE_CONTENT_TYPES = [
|
|
||||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp'
|
|
||||||
].freeze
|
|
||||||
HTML_MAX_BYTES = 1.megabyte
|
|
||||||
NICONICO_XML_MAX_BYTES = 256.kilobytes
|
|
||||||
|
|
||||||
def self.fetch(raw_url)
|
|
||||||
uri, = UrlSafety.validate(raw_url)
|
|
||||||
|
|
||||||
known_url = KnownSiteExtractor.thumbnail_url(uri)
|
|
||||||
image = fetch_image_or_nil(known_url) if known_url
|
|
||||||
return image if image
|
|
||||||
|
|
||||||
niconico_url = niconico_thumbnail_url(uri)
|
|
||||||
image = fetch_image_or_nil(niconico_url) if niconico_url
|
|
||||||
return image if image
|
|
||||||
|
|
||||||
page = HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES)
|
|
||||||
metadata = HtmlMetadataExtractor.extract(page)
|
|
||||||
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' if metadata[:image_url].blank?
|
|
||||||
|
|
||||||
fetch_image!(metadata[:image_url])
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.title(raw_url)
|
|
||||||
uri, = UrlSafety.validate(raw_url)
|
|
||||||
HtmlMetadataExtractor.extract(
|
|
||||||
HttpFetcher.fetch(uri.to_s, max_bytes: HTML_MAX_BYTES))[:title]
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.fetch_image_or_nil(url)
|
|
||||||
return nil if url.blank?
|
|
||||||
|
|
||||||
response = HttpFetcher.fetch(url)
|
|
||||||
return nil unless allowed_image_content_type?(response.content_type)
|
|
||||||
|
|
||||||
response.body
|
|
||||||
rescue HttpFetcher::FetchTimeout
|
|
||||||
raise
|
|
||||||
rescue HttpFetcher::FetchFailed
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.fetch_image!(url)
|
|
||||||
response = HttpFetcher.fetch(url)
|
|
||||||
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
|
|
||||||
|
|
||||||
def self.niconico_thumbnail_url(uri)
|
|
||||||
video_id = KnownSiteExtractor.niconico_video_id(uri)
|
|
||||||
return nil if video_id.blank?
|
|
||||||
|
|
||||||
response = HttpFetcher.fetch("https://ext.nicovideo.jp/api/getthumbinfo/#{ video_id }",
|
|
||||||
max_bytes: NICONICO_XML_MAX_BYTES)
|
|
||||||
xml = Nokogiri::XML(response.body)
|
|
||||||
return nil unless xml.at_xpath('/nicovideo_thumb_response/@status')&.value == 'ok'
|
|
||||||
|
|
||||||
xml.at_xpath('//thumbnail_url')&.text&.strip.presence
|
|
||||||
rescue HttpFetcher::FetchFailed, HttpFetcher::FetchTimeout => e
|
|
||||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
|
||||||
video_id:,
|
|
||||||
error: e.class.name,
|
|
||||||
message: e.message }.to_json }")
|
|
||||||
nil
|
|
||||||
rescue Nokogiri::XML::SyntaxError => e
|
|
||||||
Rails.logger.info("preview_niconico_getthumbinfo_fallback #{ { url: uri.to_s,
|
|
||||||
video_id:,
|
|
||||||
error: e.class.name,
|
|
||||||
message: e.message }.to_json }")
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.allowed_image_content_type?(content_type)
|
|
||||||
mime_type = content_type.to_s.split(';', 2).first.downcase.strip
|
|
||||||
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type)
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :fetch_image_or_nil, :fetch_image!,
|
|
||||||
:niconico_thumbnail_url,
|
|
||||||
:allowed_image_content_type?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
require 'resolv'
|
|
||||||
require 'ipaddr'
|
|
||||||
require 'uri'
|
|
||||||
|
|
||||||
module Preview
|
|
||||||
class UrlSafety
|
|
||||||
class UnsafeUrl < StandardError; end
|
|
||||||
|
|
||||||
FORBIDDEN_NETWORKS = [
|
|
||||||
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8',
|
|
||||||
'169.254.0.0/16', '172.16.0.0/12', '192.0.0.0/24',
|
|
||||||
'192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15',
|
|
||||||
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4',
|
|
||||||
'240.0.0.0/4', '::/128', '::1/128', 'fc00::/7', 'fe80::/10',
|
|
||||||
'ff00::/8', '2001:db8::/32', '::ffff:0:0/96'
|
|
||||||
].map { IPAddr.new(_1) }.freeze
|
|
||||||
|
|
||||||
def self.validate(raw_url)
|
|
||||||
value = raw_url.to_s.strip
|
|
||||||
if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
|
||||||
unless value.match?(/\Ahttps?:\/\//i)
|
|
||||||
raise UnsafeUrl, 'http または https の URL を指定してください.'
|
|
||||||
end
|
|
||||||
else
|
|
||||||
value = "http://#{ value }"
|
|
||||||
end
|
|
||||||
uri = URI.parse(value)
|
|
||||||
|
|
||||||
unless ['http', 'https'].include?(uri.scheme&.downcase) && uri.host.present?
|
|
||||||
raise UnsafeUrl, 'http または https の URL を指定してください.'
|
|
||||||
end
|
|
||||||
raise UnsafeUrl, 'userinfo つき URL は使用できません.' if uri.userinfo.present?
|
|
||||||
|
|
||||||
addresses = Resolv.getaddresses(uri.host)
|
|
||||||
raise UnsafeUrl, 'URL のホストを解決できません.' if addresses.empty?
|
|
||||||
|
|
||||||
parsed_addresses = addresses.map { IPAddr.new(_1) }
|
|
||||||
if parsed_addresses.any? { |address| forbidden?(address) }
|
|
||||||
raise UnsafeUrl, '安全でない接続先は使用できません.'
|
|
||||||
end
|
|
||||||
|
|
||||||
[uri, parsed_addresses.map(&:to_s)]
|
|
||||||
rescue Resolv::ResolvError
|
|
||||||
raise UnsafeUrl, 'URL のホストを解決できません.'
|
|
||||||
rescue URI::InvalidURIError, IPAddr::InvalidAddressError
|
|
||||||
raise UnsafeUrl, 'URL が不正です.'
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.forbidden?(address)
|
|
||||||
FORBIDDEN_NETWORKS.any? { _1.include?(address) }
|
|
||||||
end
|
|
||||||
|
|
||||||
private_class_method :forbidden?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
+1102
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "lib",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "screenshot.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer": "^24.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
const puppeteer = require ('puppeteer')
|
||||||
|
const fs = require ('fs')
|
||||||
|
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const url = process.argv[2]
|
||||||
|
const output = process.argv[3]
|
||||||
|
|
||||||
|
const browser = await puppeteer.launch ({
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox'] })
|
||||||
|
|
||||||
|
const page = await browser.newPage ()
|
||||||
|
await page.setViewport ({ width: 960, height: 960 })
|
||||||
|
await page.goto (url, { waitUntil: 'networkidle2', timeout: 15000 })
|
||||||
|
|
||||||
|
await page.screenshot ({ path: output })
|
||||||
|
await browser.close ()
|
||||||
|
}) ()
|
||||||
@@ -287,25 +287,6 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
|
expect(json.dig('export_paths', 'legacy_drive')).to eq('伊地知ニジカ/created.png')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a create tag_version for a newly created material tag' do
|
|
||||||
expect do
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_versioned_tag',
|
|
||||||
file: dummy_upload(filename: 'created.png')
|
|
||||||
}
|
|
||||||
end.to change(TagVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_create_versioned_tag' })
|
|
||||||
version = tag.tag_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version.event_type).to eq('create')
|
|
||||||
expect(version.name).to eq('material_create_versioned_tag')
|
|
||||||
expect(version.category).to eq('material')
|
|
||||||
expect(version.created_by_user).to eq(member_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'snapshots attached file metadata and sha256' do
|
it 'snapshots attached file metadata and sha256' do
|
||||||
post '/materials', params: {
|
post '/materials', params: {
|
||||||
tag: 'material_create_file_version',
|
tag: 'material_create_file_version',
|
||||||
@@ -485,73 +466,6 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a create tag_version when update creates a new tag' do
|
|
||||||
expect do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_versioned_tag',
|
|
||||||
file: dummy_upload(filename: 'updated.png')
|
|
||||||
}
|
|
||||||
end.to change(Tag, :count).by(1)
|
|
||||||
.and change(TagName, :count).by(1)
|
|
||||||
.and change(TagVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'material_update_versioned_tag' })
|
|
||||||
version = tag.tag_versions.order(:version_no).last
|
|
||||||
|
|
||||||
expect(version.event_type).to eq('create')
|
|
||||||
expect(version.name).to eq('material_update_versioned_tag')
|
|
||||||
expect(version.category).to eq('material')
|
|
||||||
expect(version.created_by_user).to eq(member_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'backfills a create tag_version for an existing material tag without history' do
|
|
||||||
existing_tag =
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'material_update_existing_no_history'),
|
|
||||||
category: :material)
|
|
||||||
|
|
||||||
expect(existing_tag.tag_versions).to be_empty
|
|
||||||
|
|
||||||
expect do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_existing_no_history',
|
|
||||||
file: dummy_upload(filename: 'updated.png')
|
|
||||||
}
|
|
||||||
end.to change(TagVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
version = existing_tag.reload.tag_versions.order(:version_no).last
|
|
||||||
expect(version.event_type).to eq('create')
|
|
||||||
expect(version.name).to eq('material_update_existing_no_history')
|
|
||||||
expect(version.category).to eq('material')
|
|
||||||
expect(version.created_by_user).to eq(member_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'backfills a create tag_version for an existing character tag without history' do
|
|
||||||
existing_tag =
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'material_update_character_no_history'),
|
|
||||||
category: :character)
|
|
||||||
|
|
||||||
expect(existing_tag.tag_versions).to be_empty
|
|
||||||
|
|
||||||
expect do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_character_no_history',
|
|
||||||
file: dummy_upload(filename: 'updated.png')
|
|
||||||
}
|
|
||||||
end.to change(TagVersion, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
version = existing_tag.reload.tag_versions.order(:version_no).last
|
|
||||||
expect(version.event_type).to eq('create')
|
|
||||||
expect(version.name).to eq('material_update_character_no_history')
|
|
||||||
expect(version.category).to eq('character')
|
|
||||||
expect(version.created_by_user).to eq(member_user)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'detaches the existing file without purging blob when url replaces file' do
|
it 'detaches the existing file without purging blob when url replaces file' do
|
||||||
old_blob_id = material.file.blob.id
|
old_blob_id = material.file.blob.id
|
||||||
|
|
||||||
@@ -580,7 +494,6 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
it 'does not increase version for the same snapshot update' do
|
it 'does not increase version for the same snapshot update' do
|
||||||
MaterialVersionRecorder.record!(material:, event_type: :create,
|
MaterialVersionRecorder.record!(material:, event_type: :create,
|
||||||
created_by_user: member_user)
|
created_by_user: member_user)
|
||||||
TagVersioning.ensure_snapshot!(tag, created_by_user: member_user)
|
|
||||||
|
|
||||||
expect do
|
expect do
|
||||||
put "/materials/#{ material.id }", params: {
|
put "/materials/#{ material.id }", params: {
|
||||||
@@ -588,8 +501,6 @@ RSpec.describe 'Materials API', type: :request do
|
|||||||
}
|
}
|
||||||
end.not_to change(MaterialVersion, :count)
|
end.not_to change(MaterialVersion, :count)
|
||||||
|
|
||||||
expect(tag.reload.tag_versions.count).to eq(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(material.reload.version_no).to eq(1)
|
expect(material.reload.version_no).to eq(1)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,46 +1,28 @@
|
|||||||
require 'rails_helper'
|
require "rails_helper"
|
||||||
|
|
||||||
|
|
||||||
RSpec.describe 'Preview', type: :request do
|
RSpec.describe "Preview", type: :request do
|
||||||
describe 'GET /preview/title' do
|
describe "GET /preview/title" do
|
||||||
it '401 unless logged in' do
|
it "401 unless logged in" do
|
||||||
sign_out
|
sign_out
|
||||||
get '/preview/title', params: { url: 'example.com' }
|
get "/preview/title", params: { url: "example.com" }
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
|
|
||||||
it '403 when logged in as guest' do
|
it "400 when url blank" do
|
||||||
sign_in_as(create(:user, :guest))
|
sign_in_as(create(:user))
|
||||||
get '/preview/title', params: { url: 'example.com' }
|
get "/preview/title", params: { url: "" }
|
||||||
expect(response).to have_http_status(:forbidden)
|
|
||||||
end
|
|
||||||
|
|
||||||
it '400 when url blank' do
|
|
||||||
sign_in_as(create(:user, :member))
|
|
||||||
get '/preview/title', params: { url: '' }
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns parsed title' do
|
it "returns parsed title (stubbing URI.open)" do
|
||||||
sign_in_as(create(:user, :member))
|
sign_in_as(create(:user))
|
||||||
allow(Preview::ThumbnailFetcher)
|
fake_html = "<html><head><title> Hello </title></head></html>"
|
||||||
.to receive(:title)
|
allow(URI).to receive(:open).and_return(StringIO.new(fake_html))
|
||||||
.with('example.com')
|
|
||||||
.and_return('Hello')
|
|
||||||
|
|
||||||
get '/preview/title', params: { url: 'example.com' }
|
get "/preview/title", params: { url: "example.com" }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json['title']).to eq('Hello')
|
expect(json["title"]).to eq("Hello")
|
||||||
end
|
|
||||||
|
|
||||||
it '413 when fetched response is too large' do
|
|
||||||
sign_in_as(create(:user, :member))
|
|
||||||
allow(Preview::ThumbnailFetcher)
|
|
||||||
.to receive(:title)
|
|
||||||
.and_raise(Preview::HttpFetcher::ResponseTooLarge, '外部データが大きすぎます.')
|
|
||||||
|
|
||||||
get '/preview/title', params: { url: 'example.com' }
|
|
||||||
expect(response).to have_http_status(:payload_too_large)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -226,14 +226,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
|
|||||||
[platform2, code2],
|
[platform2, code2],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'locks the tag before replacing the complete list' do
|
|
||||||
expect_any_instance_of(Tag).to receive(:lock!).and_call_original
|
|
||||||
|
|
||||||
do_request
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when tag already has deerjikists' do
|
context 'when tag already has deerjikists' do
|
||||||
@@ -307,81 +299,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when platform is outside the enum' do
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: 'invalid', code: code1 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 with an indexed platform error without changing the list' do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deerjikists.0.platform' => [be_present],
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a requested deerjikist belongs to another tag' do
|
|
||||||
let!(:other_tag) { create(:tag, category: :deerjikist) }
|
|
||||||
let!(:owned_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
end
|
|
||||||
let!(:conflicting_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform2, code: code2, tag: other_tag)
|
|
||||||
end
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: 'nico', code: 'new-code' },
|
|
||||||
{ platform: platform2, code: code2 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
other_tag.tag_name.update!(name: 'existing-deerjikist')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns an indexed 422 error and rolls back the complete replacement' do
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { Deerjikist.order(:platform, :code).pluck(:platform, :code, :tag_id) }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
expect(json.fetch('errors')).to include(
|
|
||||||
'deerjikists.1.code' => [include('existing-deerjikist')],
|
|
||||||
)
|
|
||||||
expect(owned_deerjikist.reload.tag_id).to eq(tag.id)
|
|
||||||
expect(conflicting_deerjikist.reload.tag_id).to eq(other_tag.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when a requested deerjikist already belongs to the same tag' do
|
|
||||||
let!(:existing_deerjikist) do
|
|
||||||
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
|
|
||||||
end
|
|
||||||
let(:payload) do
|
|
||||||
[
|
|
||||||
{ platform: platform1, code: code1 },
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'keeps the existing row' do
|
|
||||||
expect {
|
|
||||||
do_request
|
|
||||||
}.not_to change { existing_deerjikist.reload.created_at }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(Deerjikist.where(tag: tag).pluck(:platform, :code))
|
|
||||||
.to eq([[platform1, code1]])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when youtube code is handle' do
|
context 'when youtube code is handle' do
|
||||||
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
|
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
|
||||||
let(:payload) do
|
let(:payload) do
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Preview::HttpFetcher do
|
|
||||||
describe '.fetch' do
|
|
||||||
it 'raises FetchFailed when redirect location is invalid' do
|
|
||||||
redirect = Net::HTTPFound.new('1.1', '302', 'Found')
|
|
||||||
redirect['location'] = 'http://[invalid'
|
|
||||||
|
|
||||||
allow(Preview::UrlSafety).to receive(:validate)
|
|
||||||
.with('https://example.com/page')
|
|
||||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
|
||||||
allow(described_class).to receive(:request)
|
|
||||||
.and_return(redirect)
|
|
||||||
allow(Rails.logger).to receive(:warn)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.fetch('https://example.com/page')
|
|
||||||
}.to raise_error(Preview::HttpFetcher::FetchFailed)
|
|
||||||
expect(Rails.logger).to have_received(:warn)
|
|
||||||
.with(/invalid_redirect_location/)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Preview::ThumbnailFetcher do
|
|
||||||
describe '.fetch' do
|
|
||||||
it 'rejects svg thumbnails' do
|
|
||||||
page = Preview::HttpFetcher::Response.new(
|
|
||||||
'<meta property="og:image" content="https://example.com/thumb.svg">',
|
|
||||||
'text/html',
|
|
||||||
'https://example.com/page')
|
|
||||||
svg = Preview::HttpFetcher::Response.new(
|
|
||||||
'<svg></svg>',
|
|
||||||
'image/svg+xml',
|
|
||||||
'https://example.com/thumb.svg')
|
|
||||||
|
|
||||||
allow(Preview::UrlSafety).to receive(:validate)
|
|
||||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
|
||||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
|
||||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
|
||||||
.and_return(page)
|
|
||||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
|
||||||
.with('https://example.com/thumb.svg')
|
|
||||||
.and_return(svg)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.fetch('https://example.com/page')
|
|
||||||
}.to raise_error(Preview::ThumbnailFetcher::GenerationFailed)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'accepts allowed image content type with parameters' do
|
|
||||||
page = Preview::HttpFetcher::Response.new(
|
|
||||||
'<meta property="og:image" content="https://example.com/thumb.jpg">',
|
|
||||||
'text/html',
|
|
||||||
'https://example.com/page')
|
|
||||||
image = Preview::HttpFetcher::Response.new(
|
|
||||||
'jpeg-bytes',
|
|
||||||
'image/jpeg; charset=binary',
|
|
||||||
'https://example.com/thumb.jpg')
|
|
||||||
|
|
||||||
allow(Preview::UrlSafety).to receive(:validate)
|
|
||||||
.and_return([URI.parse('https://example.com/page'), ['203.0.113.10']])
|
|
||||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
|
||||||
.with('https://example.com/page', max_bytes: described_class::HTML_MAX_BYTES)
|
|
||||||
.and_return(page)
|
|
||||||
allow(Preview::HttpFetcher).to receive(:fetch)
|
|
||||||
.with('https://example.com/thumb.jpg')
|
|
||||||
.and_return(image)
|
|
||||||
|
|
||||||
expect(described_class.fetch('https://example.com/page')).to eq('jpeg-bytes')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Preview::UrlSafety do
|
|
||||||
describe '.validate' do
|
|
||||||
it 'raises UnsafeUrl when DNS resolution fails' do
|
|
||||||
allow(Resolv).to receive(:getaddresses)
|
|
||||||
.with('missing.example')
|
|
||||||
.and_raise(Resolv::ResolvError)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
described_class.validate('https://missing.example')
|
|
||||||
}.to raise_error(Preview::UrlSafety::UnsafeUrl)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
新しい課題から参照
ユーザをブロックする