コミットを比較
8 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 589fbb0aae | |||
| 071ef09359 | |||
| f50134e458 | |||
| 9a6e6f6053 | |||
| b7b284c076 | |||
| 6c451d260f | |||
| c1902fbc99 | |||
| 2f2b6e2afa |
@@ -89,15 +89,13 @@ class MaterialsController < ApplicationController
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
Material.transaction do
|
Material.transaction do
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag = resolve_material_tag!(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)
|
||||||
@@ -139,10 +137,7 @@ 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_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
tag = resolve_material_tag!(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)
|
||||||
@@ -150,6 +145,7 @@ 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)
|
||||||
@@ -240,6 +236,12 @@ 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,50 +1,57 @@
|
|||||||
class PreviewController < ApplicationController
|
class PreviewController < ApplicationController
|
||||||
|
before_action :require_member!
|
||||||
|
|
||||||
def title
|
def title
|
||||||
# TODO: # 既知サイトなら決まったフォーマットで title 取得するやぅに.
|
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||||
return head :unauthorized unless current_user
|
|
||||||
|
|
||||||
url = params[:url]
|
render json: { title: Preview::ThumbnailFetcher.title(params[:url]) }
|
||||||
return render_bad_request('URL は必須です.') unless url.present?
|
rescue Preview::UrlSafety::UnsafeUrl => e
|
||||||
|
|
||||||
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
|
||||||
# TODO: 既知ドメインであれば指定のアドレスからサムネールを取得するやぅにする.
|
return render_bad_request('URL は必須です.') if params[:url].blank?
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
url = params[:url]
|
head :forbidden
|
||||||
return render_bad_request('URL は必須です.') if url.blank?
|
|
||||||
|
|
||||||
unless url.start_with?(/http(s)?:\/\//)
|
|
||||||
url = 'http://' + url
|
|
||||||
end
|
end
|
||||||
|
|
||||||
path = Rails.root.join('tmp', "thumb_#{ SecureRandom.hex }.png")
|
def render_preview_error(message, status)
|
||||||
system("node #{ Rails.root }/lib/screenshot.js #{ Shellwords.escape(url) } #{ path }")
|
render json: { type: status.to_s,
|
||||||
|
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: ['サムネールを生成できませんでした.'] },
|
base_errors: [message] },
|
||||||
status: :internal_server_error
|
status:
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
attribute :version_no, :integer, default: 1
|
attribute :version_no, :integer, default: 1
|
||||||
|
|
||||||
before_validation :normalise_url
|
before_validation :normalise_url, if: :will_save_change_to_url?
|
||||||
|
|
||||||
validates :url, presence: true, uniqueness: true
|
validates :url, presence: true, uniqueness: true, length: { maximum: 768 }
|
||||||
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true
|
||||||
|
|
||||||
validate :validate_original_created_range
|
validate :validate_original_created_range
|
||||||
@@ -180,7 +180,7 @@ class Post < ApplicationRecord
|
|||||||
|
|
||||||
u.host = u.host.downcase if u.host
|
u.host = u.host.downcase if u.host
|
||||||
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
|
u.path = u.path.sub(/\/\Z/, '') if u.path.present?
|
||||||
self.url = u.to_s
|
self.url = PostUrlSanitisationRule.sanitise(u.to_s)
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
;
|
;
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
class PostUrlSanitisationRule < ApplicationRecord
|
||||||
|
include Discard::Model
|
||||||
|
|
||||||
|
class InvalidUrlError < StandardError
|
||||||
|
attr_reader :invalid_rows
|
||||||
|
|
||||||
|
def initialize(invalid_rows)
|
||||||
|
@invalid_rows = invalid_rows
|
||||||
|
ids = invalid_rows.map { _1.fetch(:post_id) }.join(', ')
|
||||||
|
super("post URL sanitisation produced invalid URLs for posts #{ ids }")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class UrlConflictError < StandardError
|
||||||
|
attr_reader :conflicts
|
||||||
|
|
||||||
|
def initialize(conflicts)
|
||||||
|
@conflicts = conflicts
|
||||||
|
urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ')
|
||||||
|
super("post URL sanitisation conflicts detected for #{ urls }")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self.primary_key = :priority
|
||||||
|
|
||||||
|
default_scope -> { kept }
|
||||||
|
|
||||||
|
validates :source_pattern, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
validate :source_pattern_must_be_regexp
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def sanitise(url) = sanitise_with_rules(url, rules)
|
||||||
|
|
||||||
|
def apply!
|
||||||
|
rewrites = nil
|
||||||
|
|
||||||
|
Post.transaction do
|
||||||
|
compiled_rules = rules
|
||||||
|
|
||||||
|
rewrites = Post.order(:id)
|
||||||
|
.lock('FOR UPDATE')
|
||||||
|
.pluck(:id, :url)
|
||||||
|
.map do |post_id, original_url|
|
||||||
|
{ post_id:,
|
||||||
|
original_url:,
|
||||||
|
sanitised_url: sanitise_with_rules(original_url, compiled_rules) }
|
||||||
|
end
|
||||||
|
|
||||||
|
invalid_rows = rewrites.filter { invalid_sanitised_url?(_1.fetch(:sanitised_url)) }
|
||||||
|
.map { { post_id: _1.fetch(:post_id),
|
||||||
|
original_url: _1.fetch(:original_url),
|
||||||
|
sanitised_url: _1.fetch(:sanitised_url) } }
|
||||||
|
raise InvalidUrlError.new(invalid_rows) if invalid_rows.present?
|
||||||
|
|
||||||
|
conflicts = build_conflicts(rewrites)
|
||||||
|
raise UrlConflictError.new(conflicts) if conflicts.present?
|
||||||
|
|
||||||
|
changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
|
||||||
|
return if changed.empty?
|
||||||
|
|
||||||
|
token = SecureRandom.hex(6)
|
||||||
|
|
||||||
|
changed.each do |row|
|
||||||
|
Post.where(id: row.fetch(:post_id))
|
||||||
|
.update_all(url: temporary_url_for(row.fetch(:post_id), token))
|
||||||
|
end
|
||||||
|
|
||||||
|
changed.each do |row|
|
||||||
|
Post.where(id: row.fetch(:post_id))
|
||||||
|
.update_all(url: row.fetch(:sanitised_url))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotUnique => error
|
||||||
|
conflicts = build_persisted_conflicts(rewrites)
|
||||||
|
raise error if conflicts.empty?
|
||||||
|
|
||||||
|
raise UrlConflictError.new(conflicts), cause: error
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] }
|
||||||
|
|
||||||
|
def sanitise_with_rules(url, compiled_rules)
|
||||||
|
compiled_rules.reduce(url.dup) do |value, (pattern, replacement)|
|
||||||
|
value.sub(pattern, replacement)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def temporary_url_for(post_id, token) =
|
||||||
|
"https://post-url-sanitising.invalid/#{ token }/#{ post_id }"
|
||||||
|
|
||||||
|
def invalid_sanitised_url?(url)
|
||||||
|
return true if url.blank?
|
||||||
|
return true if url.length > 768
|
||||||
|
|
||||||
|
parsed = URI.parse(url)
|
||||||
|
return true if !(parsed in URI::HTTP)
|
||||||
|
return true if parsed.host.blank?
|
||||||
|
|
||||||
|
false
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_conflicts(rewrites)
|
||||||
|
rewrites
|
||||||
|
.group_by { _1.fetch(:sanitised_url).downcase }
|
||||||
|
.values
|
||||||
|
.filter { _1.size > 1 }
|
||||||
|
.flatten
|
||||||
|
.map { { url: _1.fetch(:sanitised_url),
|
||||||
|
post_id: _1.fetch(:post_id),
|
||||||
|
original_url: _1.fetch(:original_url) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_persisted_conflicts(rewrites)
|
||||||
|
return [] if rewrites.blank?
|
||||||
|
|
||||||
|
target_rows = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) }
|
||||||
|
target_keys = target_rows.map { _1.fetch(:sanitised_url).downcase }.uniq
|
||||||
|
return [] if target_keys.empty?
|
||||||
|
|
||||||
|
target_pairs = target_rows.to_h do |row|
|
||||||
|
[row.fetch(:post_id), row.fetch(:sanitised_url).downcase]
|
||||||
|
end
|
||||||
|
|
||||||
|
persisted_rows = Post.order(:id)
|
||||||
|
.where('LOWER(url) IN (?)', target_keys)
|
||||||
|
.pluck(:id, :url)
|
||||||
|
.reject { |post_id, original_url| target_pairs[post_id] == original_url.downcase }
|
||||||
|
.map { |post_id, original_url|
|
||||||
|
{ url: original_url,
|
||||||
|
post_id:,
|
||||||
|
original_url:,
|
||||||
|
conflict_key: original_url.downcase }
|
||||||
|
}
|
||||||
|
|
||||||
|
target_conflicts = target_rows.map { { url: _1.fetch(:sanitised_url),
|
||||||
|
post_id: _1.fetch(:post_id),
|
||||||
|
original_url: _1.fetch(:original_url),
|
||||||
|
conflict_key: _1.fetch(:sanitised_url).downcase } }
|
||||||
|
|
||||||
|
(persisted_rows + target_conflicts)
|
||||||
|
.group_by { _1.fetch(:conflict_key) }
|
||||||
|
.values
|
||||||
|
.filter { _1.size > 1 }
|
||||||
|
.flatten
|
||||||
|
.map { _1.except(:conflict_key) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def source_pattern_must_be_regexp
|
||||||
|
return if source_pattern.blank?
|
||||||
|
|
||||||
|
Regexp.new(source_pattern)
|
||||||
|
rescue RegexpError
|
||||||
|
errors.add :source_pattern, '変な正規表現だね〜(笑)'
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
class CreatePostUrlSanitisationRules < ActiveRecord::Migration[8.0]
|
||||||
|
class PostUrlSanitisationRule < ActiveRecord::Base
|
||||||
|
self.table_name = 'post_url_sanitisation_rules'
|
||||||
|
end
|
||||||
|
|
||||||
|
def up
|
||||||
|
create_table :post_url_sanitisation_rules, id: :integer, primary_key: :priority do |t|
|
||||||
|
t.string :source_pattern, null: false
|
||||||
|
t.string :replacement, null: false
|
||||||
|
t.timestamps
|
||||||
|
t.datetime :discarded_at
|
||||||
|
|
||||||
|
t.index :source_pattern, unique: true
|
||||||
|
t.index :discarded_at
|
||||||
|
end
|
||||||
|
|
||||||
|
now = Time.current
|
||||||
|
|
||||||
|
PostUrlSanitisationRule.insert_all!([
|
||||||
|
{ priority: 10,
|
||||||
|
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 20,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/live/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 30,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/shorts/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 40,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/embed/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 50,
|
||||||
|
source_pattern:
|
||||||
|
'\Ahttps?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#&]+&)*v=([^&#]+)(?:[&#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 60,
|
||||||
|
source_pattern: '\Ahttps?://nico\.ms/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.nicovideo.jp/watch/\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now },
|
||||||
|
{ priority: 70,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.)?nicovideo\.jp/watch/([^?#/]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.nicovideo.jp/watch/\1',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now }])
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
drop_table :post_url_sanitisation_rules
|
||||||
|
end
|
||||||
|
end
|
||||||
生成ファイル
+11
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
|
ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do
|
||||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "record_type", null: false
|
t.string "record_type", null: false
|
||||||
@@ -331,6 +331,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
|
|||||||
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "post_url_sanitisation_rules", primary_key: "priority", id: :integer, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
|
t.string "source_pattern", null: false
|
||||||
|
t.string "replacement", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.datetime "discarded_at"
|
||||||
|
t.index ["discarded_at"], name: "index_post_url_sanitisation_rules_on_discarded_at"
|
||||||
|
t.index ["source_pattern"], name: "index_post_url_sanitisation_rules_on_source_pattern", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "post_id", null: false
|
t.bigint "post_id", null: false
|
||||||
t.integer "version_no", null: false
|
t.integer "version_no", null: false
|
||||||
|
|||||||
@@ -8,6 +8,43 @@
|
|||||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||||
# end
|
# end
|
||||||
|
|
||||||
|
post_url_sanitisation_rules = [
|
||||||
|
{ priority: 10,
|
||||||
|
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1' },
|
||||||
|
{ priority: 20,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/live/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1' },
|
||||||
|
{ priority: 30,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/shorts/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1' },
|
||||||
|
{ priority: 40,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.|m\.)?youtube\.com/embed/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1' },
|
||||||
|
{ priority: 50,
|
||||||
|
source_pattern:
|
||||||
|
'\Ahttps?://(?:www\.|m\.)?youtube\.com/watch\?(?:[^#&]+&)*v=([^&#]+)(?:[&#].*)?\z',
|
||||||
|
replacement: 'https://www.youtube.com/watch?v=\1' },
|
||||||
|
{ priority: 60,
|
||||||
|
source_pattern: '\Ahttps?://nico\.ms/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.nicovideo.jp/watch/\1' },
|
||||||
|
{ priority: 70,
|
||||||
|
source_pattern: '\Ahttps?://(?:www\.)?nicovideo\.jp/watch/([^?#/]+)(?:[?#].*)?\z',
|
||||||
|
replacement: 'https://www.nicovideo.jp/watch/\1' }
|
||||||
|
]
|
||||||
|
|
||||||
|
post_url_sanitisation_rule_scope = PostUrlSanitisationRule.unscoped
|
||||||
|
|
||||||
|
post_url_sanitisation_rules.each do |attributes|
|
||||||
|
priority = attributes.fetch(:priority)
|
||||||
|
source_pattern = attributes.fetch(:source_pattern)
|
||||||
|
|
||||||
|
next if post_url_sanitisation_rule_scope.exists?(priority:)
|
||||||
|
next if post_url_sanitisation_rule_scope.exists?(source_pattern:)
|
||||||
|
|
||||||
|
post_url_sanitisation_rule_scope.create!(attributes)
|
||||||
|
end
|
||||||
|
|
||||||
material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI']
|
material_sync_source_uri = ENV['MATERIAL_SYNC_SOURCE_URI']
|
||||||
material_sync_source_file_id = ENV['MATERIAL_SYNC_SOURCE_FILE_ID']
|
material_sync_source_file_id = ENV['MATERIAL_SYNC_SOURCE_FILE_ID']
|
||||||
|
|
||||||
|
|||||||
-1102
ファイル差分が大きすぎるため省略します
差分を読込み
@@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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 ()
|
|
||||||
}) ()
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'database seeds' do
|
||||||
|
before do
|
||||||
|
PostUrlSanitisationRule.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'registers the initial post URL sanitisation rules' do
|
||||||
|
load_seeds
|
||||||
|
|
||||||
|
urls = {
|
||||||
|
'https://youtu.be/abc123?si=share' => youtube_url('abc123'),
|
||||||
|
'https://www.youtube.com/live/abc123?t=10' => youtube_url('abc123'),
|
||||||
|
'https://youtube.com/shorts/abc123?feature=share' => youtube_url('abc123'),
|
||||||
|
'https://m.youtube.com/embed/abc123' => youtube_url('abc123'),
|
||||||
|
'https://youtube.com/watch?feature=share&v=abc123&t=10' => youtube_url('abc123'),
|
||||||
|
'https://nico.ms/sm123?from=share#fragment' => nico_url('sm123'),
|
||||||
|
'https://www.nicovideo.jp/watch/sm123?ref=share#fragment' => nico_url('sm123')
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(PostUrlSanitisationRule.count).to eq(7)
|
||||||
|
urls.each do |url, canonical_url|
|
||||||
|
expect(PostUrlSanitisationRule.sanitise(url)).to eq(canonical_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not overwrite or restore an existing rule' do
|
||||||
|
rule = PostUrlSanitisationRule.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\Ahttps://example\.com/custom\z',
|
||||||
|
replacement: 'https://example.com/replacement'
|
||||||
|
)
|
||||||
|
rule.discard!
|
||||||
|
original_attributes = rule.reload.attributes
|
||||||
|
|
||||||
|
2.times { load_seeds }
|
||||||
|
|
||||||
|
persisted_rule = PostUrlSanitisationRule.unscoped.find(10)
|
||||||
|
expect(persisted_rule.attributes).to eq(original_attributes)
|
||||||
|
expect(PostUrlSanitisationRule.unscoped.count).to eq(7)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not duplicate a rule moved to another priority' do
|
||||||
|
source_pattern = '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z'
|
||||||
|
PostUrlSanitisationRule.create!(
|
||||||
|
priority: 80,
|
||||||
|
source_pattern:,
|
||||||
|
replacement: 'https://example.com/custom/\1'
|
||||||
|
)
|
||||||
|
|
||||||
|
load_seeds
|
||||||
|
|
||||||
|
rules = PostUrlSanitisationRule.unscoped
|
||||||
|
expect(rules.where(source_pattern:).count).to eq(1)
|
||||||
|
expect(rules.find(80).replacement).to eq('https://example.com/custom/\1')
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_seeds
|
||||||
|
load Rails.root.join('db/seeds.rb')
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_url(video_id) = "https://www.youtube.com/watch?v=#{ video_id }"
|
||||||
|
|
||||||
|
def nico_url(video_id) = "https://www.nicovideo.jp/watch/#{ video_id }"
|
||||||
|
end
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Post, type: :model do
|
||||||
|
before do
|
||||||
|
PostUrlSanitisationRule.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'URL normalisation' do
|
||||||
|
it 'normalises the HTTP URL before applying sanitisation rules' do
|
||||||
|
PostUrlSanitisationRule.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/videos/([^/]+)\\z',
|
||||||
|
replacement: 'https://example.com/watch/\\1'
|
||||||
|
)
|
||||||
|
|
||||||
|
post = described_class.create!(
|
||||||
|
title: 'normalised URL',
|
||||||
|
url: ' https://EXAMPLE.com/videos/123/ '
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(post.url).to eq('https://example.com/watch/123')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not normalise an unchanged URL when another attribute changes' do
|
||||||
|
post = create(:post)
|
||||||
|
post.update_column(:url, 'https://EXAMPLE.com/unchanged/')
|
||||||
|
|
||||||
|
post.update!(title: 'updated title')
|
||||||
|
|
||||||
|
expect(post.reload.url).to eq('https://EXAMPLE.com/unchanged/')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates the sanitised URL length' do
|
||||||
|
path = 'a' * 375
|
||||||
|
|
||||||
|
PostUrlSanitisationRule.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
|
||||||
|
replacement: 'https://example.com/\\1\\1'
|
||||||
|
)
|
||||||
|
|
||||||
|
post = described_class.new(
|
||||||
|
title: 'long URL',
|
||||||
|
url: "https://example.com/#{ path }"
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(post).to be_invalid
|
||||||
|
expect(post.errors.details.fetch(:url)).to include(
|
||||||
|
error: :too_long,
|
||||||
|
count: 768
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates uniqueness after sanitisation' do
|
||||||
|
PostUrlSanitisationRule.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/alias\\z',
|
||||||
|
replacement: 'https://example.com/canonical'
|
||||||
|
)
|
||||||
|
create(:post, url: 'https://example.com/canonical')
|
||||||
|
|
||||||
|
post = described_class.new(title: 'duplicate URL', url: 'https://example.com/alias')
|
||||||
|
|
||||||
|
expect(post).to be_invalid
|
||||||
|
expect(post.errors.details.fetch(:url)).to include(error: :taken, value: post.url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PostUrlSanitisationRule, type: :model do
|
||||||
|
before do
|
||||||
|
described_class.unscoped.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'validations' do
|
||||||
|
it 'requires a source pattern' do
|
||||||
|
rule = described_class.new(priority: 10, source_pattern: nil, replacement: '')
|
||||||
|
|
||||||
|
expect(rule).to be_invalid
|
||||||
|
expect(rule.errors.details.fetch(:source_pattern)).to eq([{ error: :blank }])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'requires a unique source pattern' do
|
||||||
|
described_class.create!(priority: 10, source_pattern: 'source', replacement: 'first')
|
||||||
|
rule = described_class.new(
|
||||||
|
priority: 20,
|
||||||
|
source_pattern: 'source',
|
||||||
|
replacement: 'second'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(rule).to be_invalid
|
||||||
|
expect(rule.errors.details.fetch(:source_pattern)).to include(
|
||||||
|
error: :taken,
|
||||||
|
value: 'source'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an invalid regexp' do
|
||||||
|
rule = described_class.new(priority: 10, source_pattern: '[', replacement: '')
|
||||||
|
|
||||||
|
expect(rule).to be_invalid
|
||||||
|
expect(rule.errors[:source_pattern]).to include('変な正規表現だね〜(笑)')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.sanitise' do
|
||||||
|
it 'applies each active rule once in priority order' do
|
||||||
|
described_class.create!(priority: 30, source_pattern: 'c', replacement: 'd')
|
||||||
|
described_class.create!(priority: 10, source_pattern: 'a', replacement: 'aa')
|
||||||
|
described_class.create!(priority: 20, source_pattern: 'aa', replacement: 'c')
|
||||||
|
discarded = described_class.create!(
|
||||||
|
priority: 5,
|
||||||
|
source_pattern: '.',
|
||||||
|
replacement: 'discarded'
|
||||||
|
)
|
||||||
|
discarded.discard!
|
||||||
|
|
||||||
|
expect(described_class.sanitise('a')).to eq('d')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'canonicalises the initial YouTube and Nico URL forms' do
|
||||||
|
create_initial_rules
|
||||||
|
|
||||||
|
urls = {
|
||||||
|
'https://youtu.be/abc123?si=share' => youtube_url('abc123'),
|
||||||
|
'https://www.youtube.com/live/abc123?t=10' => youtube_url('abc123'),
|
||||||
|
'https://youtube.com/shorts/abc123?feature=share' => youtube_url('abc123'),
|
||||||
|
'https://m.youtube.com/embed/abc123' => youtube_url('abc123'),
|
||||||
|
'https://youtube.com/watch?feature=share&v=abc123&t=10' => youtube_url('abc123'),
|
||||||
|
'https://nico.ms/sm123?from=share#fragment' => nico_url('sm123'),
|
||||||
|
'https://www.nicovideo.jp/watch/sm123?ref=share#fragment' => nico_url('sm123')
|
||||||
|
}
|
||||||
|
|
||||||
|
urls.each do |url, canonical_url|
|
||||||
|
expect(described_class.sanitise(url)).to eq(canonical_url)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.apply!' do
|
||||||
|
it 'locks posts in ID order and updates through temporary URLs' do
|
||||||
|
first = create(:post, url: 'https://example.com/source/1')
|
||||||
|
second = create(:post, url: 'https://example.com/source/2')
|
||||||
|
create_source_rule
|
||||||
|
sql = capture_sql { described_class.apply! }
|
||||||
|
|
||||||
|
lock_sql = sql.find { _1.match?(/SELECT .*posts.*FOR UPDATE/i) }
|
||||||
|
expect(lock_sql).to match(/ORDER BY .*posts.*id.* ASC/i)
|
||||||
|
expect(sql.grep(/post-url-sanitising\.invalid/).size).to eq(2)
|
||||||
|
expect(first.reload.url).to eq('https://example.com/canonical/1')
|
||||||
|
expect(second.reload.url).to eq('https://example.com/canonical/2')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'loads and compiles rules only once' do
|
||||||
|
create(:post, url: 'https://example.com/source/1')
|
||||||
|
create(:post, url: 'https://example.com/source/2')
|
||||||
|
create_source_rule
|
||||||
|
|
||||||
|
expect(described_class).to receive(:rules).once.and_call_original
|
||||||
|
|
||||||
|
described_class.apply!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not change post versions, version_no, or updated_at' do
|
||||||
|
post = create(:post, url: 'https://example.com/source/1')
|
||||||
|
post.update_columns(version_no: 7, updated_at: 1.day.ago)
|
||||||
|
original_updated_at = post.reload.updated_at
|
||||||
|
create_source_rule
|
||||||
|
|
||||||
|
expect { described_class.apply! }.not_to change(PostVersion, :count)
|
||||||
|
|
||||||
|
post.reload
|
||||||
|
expect(post.url).to eq('https://example.com/canonical/1')
|
||||||
|
expect(post.version_no).to eq(7)
|
||||||
|
expect(post.updated_at).to eq(original_updated_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
invalid_urls = {
|
||||||
|
'a blank URL' => '',
|
||||||
|
'an unparseable URL' => 'https://[',
|
||||||
|
'a non-HTTP URL' => 'ftp://example.com/file',
|
||||||
|
'an HTTP URL without a host' => 'https:/path'
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid_urls.each do |description, sanitised_url|
|
||||||
|
it "rolls back every update when sanitisation produces #{ description }" do
|
||||||
|
valid_post = create(:post, url: 'https://example.com/source/1')
|
||||||
|
invalid_post = create(:post, url: 'https://example.com/invalid')
|
||||||
|
create_source_rule
|
||||||
|
described_class.create!(
|
||||||
|
priority: 20,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/invalid\\z',
|
||||||
|
replacement: sanitised_url
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.apply! }
|
||||||
|
.to raise_error(described_class::InvalidUrlError) { |error|
|
||||||
|
expect(error.invalid_rows).to eq([
|
||||||
|
{ post_id: invalid_post.id,
|
||||||
|
original_url: 'https://example.com/invalid',
|
||||||
|
sanitised_url: }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
expect(valid_post.reload.url).to eq('https://example.com/source/1')
|
||||||
|
expect(invalid_post.reload.url).to eq('https://example.com/invalid')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rolls back every update when sanitisation produces a URL longer than 768 characters' do
|
||||||
|
path = 'a' * 375
|
||||||
|
original_url = "https://example.com/#{ path }"
|
||||||
|
sanitised_url = "https://example.com/#{ path }#{ path }"
|
||||||
|
valid_post = create(:post, url: 'https://example.com/source/1')
|
||||||
|
invalid_post = create(:post, url: original_url)
|
||||||
|
create_source_rule
|
||||||
|
described_class.create!(
|
||||||
|
priority: 20,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/(a+)\\z',
|
||||||
|
replacement: 'https://example.com/\\1\\1'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.apply! }
|
||||||
|
.to raise_error(described_class::InvalidUrlError) { |error|
|
||||||
|
expect(error.invalid_rows).to eq([
|
||||||
|
{ post_id: invalid_post.id,
|
||||||
|
original_url:,
|
||||||
|
sanitised_url: }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
expect(valid_post.reload.url).to eq('https://example.com/source/1')
|
||||||
|
expect(invalid_post.reload.url).to eq(original_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects sanitised URL collisions case-insensitively without changing posts' do
|
||||||
|
source = create(:post, url: 'https://example.com/source/Foo')
|
||||||
|
canonical = create(:post, url: 'https://example.com/canonical/foo')
|
||||||
|
create_source_rule
|
||||||
|
|
||||||
|
expect { described_class.apply! }
|
||||||
|
.to raise_error(described_class::UrlConflictError) { |error|
|
||||||
|
expect(error.conflicts).to contain_exactly(
|
||||||
|
{ url: 'https://example.com/canonical/Foo',
|
||||||
|
post_id: source.id,
|
||||||
|
original_url: 'https://example.com/source/Foo' },
|
||||||
|
{ url: 'https://example.com/canonical/foo',
|
||||||
|
post_id: canonical.id,
|
||||||
|
original_url: 'https://example.com/canonical/foo' }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
expect(source.reload.url).to eq('https://example.com/source/Foo')
|
||||||
|
expect(canonical.reload.url).to eq('https://example.com/canonical/foo')
|
||||||
|
expect(Post.count).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts a persisted URL constraint race into UrlConflictError' do
|
||||||
|
post = create(:post, url: 'https://example.com/source/1')
|
||||||
|
create_source_rule
|
||||||
|
conflict = { url: 'https://example.com/canonical/1',
|
||||||
|
post_id: post.id,
|
||||||
|
original_url: post.url }
|
||||||
|
database_error = ActiveRecord::RecordNotUnique.new('duplicate URL')
|
||||||
|
allow_any_instance_of(ActiveRecord::Relation)
|
||||||
|
.to receive(:update_all).and_raise(database_error)
|
||||||
|
allow(described_class).to receive(:build_persisted_conflicts).and_return([conflict])
|
||||||
|
|
||||||
|
expect { described_class.apply! }
|
||||||
|
.to raise_error(described_class::UrlConflictError) { |error|
|
||||||
|
expect(error.conflicts).to eq([conflict])
|
||||||
|
expect(error.cause).to equal(database_error)
|
||||||
|
}
|
||||||
|
expect(post.reload.url).to eq('https://example.com/source/1')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 're-raises an unidentified RecordNotUnique error' do
|
||||||
|
post = create(:post, url: 'https://example.com/source/1')
|
||||||
|
create_source_rule
|
||||||
|
database_error = ActiveRecord::RecordNotUnique.new('another unique constraint')
|
||||||
|
allow_any_instance_of(ActiveRecord::Relation)
|
||||||
|
.to receive(:update_all).and_raise(database_error)
|
||||||
|
allow(described_class).to receive(:build_persisted_conflicts).and_return([])
|
||||||
|
|
||||||
|
expect { described_class.apply! }.to raise_error(database_error)
|
||||||
|
expect(post.reload.url).to eq('https://example.com/source/1')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def capture_sql
|
||||||
|
statements = []
|
||||||
|
subscriber = lambda do |_name, _start, _finish, _id, payload|
|
||||||
|
binds = payload.fetch(:binds, []).map { _1.value_for_database.to_s }
|
||||||
|
statements << ([payload.fetch(:sql)] + binds).join(' ')
|
||||||
|
end
|
||||||
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') { yield }
|
||||||
|
statements
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_source_rule
|
||||||
|
described_class.create!(
|
||||||
|
priority: 10,
|
||||||
|
source_pattern: '\\Ahttps://example\\.com/source/(.+)\\z',
|
||||||
|
replacement: 'https://example.com/canonical/\\1'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_initial_rules
|
||||||
|
rules = [
|
||||||
|
['\\Ahttps?://youtu\\.be/([^/?#]+)(?:[?#].*)?\\z', youtube_url('\\1')],
|
||||||
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/live/([^/?#]+)(?:[?#].*)?\\z',
|
||||||
|
youtube_url('\\1')],
|
||||||
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/shorts/([^/?#]+)(?:[?#].*)?\\z',
|
||||||
|
youtube_url('\\1')],
|
||||||
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/embed/([^/?#]+)(?:[?#].*)?\\z',
|
||||||
|
youtube_url('\\1')],
|
||||||
|
['\\Ahttps?://(?:www\\.|m\\.)?youtube\\.com/watch\\?(?:[^#&]+&)*' \
|
||||||
|
'v=([^&#]+)(?:[&#].*)?\\z', youtube_url('\\1')],
|
||||||
|
['\\Ahttps?://nico\\.ms/([^/?#]+)(?:[?#].*)?\\z', nico_url('\\1')],
|
||||||
|
['\\Ahttps?://(?:www\\.)?nicovideo\\.jp/watch/([^?#/]+)(?:[?#].*)?\\z',
|
||||||
|
nico_url('\\1')]
|
||||||
|
]
|
||||||
|
rules.each_with_index do |(source_pattern, replacement), index|
|
||||||
|
described_class.create!(
|
||||||
|
priority: (index + 1) * 10,
|
||||||
|
source_pattern:,
|
||||||
|
replacement:
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_url(video_id) = "https://www.youtube.com/watch?v=#{ video_id }"
|
||||||
|
|
||||||
|
def nico_url(video_id) = "https://www.nicovideo.jp/watch/#{ video_id }"
|
||||||
|
end
|
||||||
@@ -287,6 +287,25 @@ 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',
|
||||||
@@ -466,6 +485,73 @@ 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
|
||||||
|
|
||||||
@@ -494,6 +580,7 @@ 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: {
|
||||||
@@ -501,6 +588,8 @@ 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,28 +1,46 @@
|
|||||||
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 "400 when url blank" do
|
it '403 when logged in as guest' do
|
||||||
sign_in_as(create(:user))
|
sign_in_as(create(:user, :guest))
|
||||||
get "/preview/title", params: { url: "" }
|
get '/preview/title', params: { url: 'example.com' }
|
||||||
|
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 (stubbing URI.open)" do
|
it 'returns parsed title' do
|
||||||
sign_in_as(create(:user))
|
sign_in_as(create(:user, :member))
|
||||||
fake_html = "<html><head><title> Hello </title></head></html>"
|
allow(Preview::ThumbnailFetcher)
|
||||||
allow(URI).to receive(:open).and_return(StringIO.new(fake_html))
|
.to receive(:title)
|
||||||
|
.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
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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
|
||||||
@@ -62,7 +62,7 @@ RSpec.describe 'nico:export' do
|
|||||||
|
|
||||||
it 'deduplicates video ids' do
|
it 'deduplicates video ids' do
|
||||||
create_post('https://www.nicovideo.jp/watch/sm12345')
|
create_post('https://www.nicovideo.jp/watch/sm12345')
|
||||||
create_post('https://www.nicovideo.jp/watch/sm12345?from=1')
|
create_post('https://sp.nicovideo.jp/watch/sm12345')
|
||||||
|
|
||||||
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
|
expect(Open3).to receive(:capture3) do |_env, *args, **_kwargs|
|
||||||
expect(args.drop(3)).to eq(['sm12345'])
|
expect(args.drop(3)).to eq(['sm12345'])
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||||
|
import { buildTag } from '@/test/factories'
|
||||||
|
import { renderWithProviders } from '@/test/render'
|
||||||
|
|
||||||
|
const dndKit = vi.hoisted (() => ({
|
||||||
|
useDraggable: vi.fn (),
|
||||||
|
useDroppable: vi.fn (),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock ('@dnd-kit/core', () => dndKit)
|
||||||
|
|
||||||
|
const tag = buildTag ({ id: 7, name: 'ドラッグ元', postCount: 3 })
|
||||||
|
|
||||||
|
const renderRow = (activeDndId?: string) => {
|
||||||
|
renderWithProviders (
|
||||||
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
|
tag={tag}
|
||||||
|
nestLevel={2}
|
||||||
|
pathKey="cat-general-7"
|
||||||
|
suppressClickRef={{ current: false }}/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagBody = (): HTMLElement => {
|
||||||
|
const link = document.querySelector<HTMLElement> ('a[title="ドラッグ元"]')
|
||||||
|
|
||||||
|
if (link == null)
|
||||||
|
throw new Error ('tag link not found')
|
||||||
|
|
||||||
|
const body = link.closest ('div')
|
||||||
|
|
||||||
|
if (body == null)
|
||||||
|
throw new Error ('tag body not found')
|
||||||
|
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
describe ('DraggableDroppableTagRow', () => {
|
||||||
|
beforeEach (() => {
|
||||||
|
vi.clearAllMocks ()
|
||||||
|
dndKit.useDraggable.mockReturnValue ({
|
||||||
|
attributes: { 'aria-describedby': 'drag-source' },
|
||||||
|
listeners: { onPointerDown: vi.fn () },
|
||||||
|
setNodeRef: vi.fn (),
|
||||||
|
transform: null })
|
||||||
|
dndKit.useDroppable.mockReturnValue ({
|
||||||
|
isOver: false,
|
||||||
|
setNodeRef: vi.fn () })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('passes dndId through draggable data for active-row tracking', () => {
|
||||||
|
renderRow ()
|
||||||
|
|
||||||
|
expect (dndKit.useDraggable).toHaveBeenCalledWith (
|
||||||
|
expect.objectContaining ({
|
||||||
|
id: 'tag-node:cat-general-7',
|
||||||
|
data: expect.objectContaining ({
|
||||||
|
dndId: 'tag-node:cat-general-7',
|
||||||
|
nestLevel: 2,
|
||||||
|
tagId: 7 }) }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('hides only the active drag source from the explicit active dnd id', () => {
|
||||||
|
renderRow ('tag-node:cat-general-7')
|
||||||
|
expect (tagBody ()).toHaveStyle ({ visibility: 'hidden' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it ('keeps inactive tag rows visible while another tag is dragged', () => {
|
||||||
|
renderRow ('tag-node:other')
|
||||||
|
expect (tagBody ()).toHaveStyle ({ visibility: 'visible' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ import type { CSSProperties, FC, MutableRefObject } from 'react'
|
|||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
activeDndId?: string
|
||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel: number
|
nestLevel: number
|
||||||
pathKey: string
|
pathKey: string
|
||||||
@@ -21,7 +22,15 @@ type Props = {
|
|||||||
sp?: boolean }
|
sp?: boolean }
|
||||||
|
|
||||||
|
|
||||||
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => {
|
const DraggableDroppableTagRow: FC<Props> = ({
|
||||||
|
activeDndId,
|
||||||
|
tag,
|
||||||
|
nestLevel,
|
||||||
|
pathKey,
|
||||||
|
parentTagId,
|
||||||
|
suppressClickRef,
|
||||||
|
sp,
|
||||||
|
}) => {
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
@@ -53,18 +62,23 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
const { attributes,
|
const { attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef: setDragRef,
|
setNodeRef: setDragRef,
|
||||||
transform,
|
transform } = useDraggable ({ id: dndId,
|
||||||
isDragging: dragging } = useDraggable ({ id: dndId,
|
|
||||||
data: { kind: 'tag',
|
data: { kind: 'tag',
|
||||||
|
dndId,
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
parentTagId } })
|
parentTagId,
|
||||||
|
nestLevel } })
|
||||||
|
|
||||||
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
const { setNodeRef: setDropRef, isOver: over } = useDroppable ({
|
||||||
id: dndId,
|
id: dndId,
|
||||||
data: { kind: 'tag', tagId: tag.id } })
|
data: { kind: 'tag', tagId: tag.id } })
|
||||||
|
|
||||||
|
const activeDragging = activeDndId === dndId
|
||||||
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
const style: CSSProperties = { transform: CSS.Translate.toString (transform),
|
||||||
visibility: dragging ? 'hidden' : 'visible' }
|
visibility: activeDragging ? 'hidden' : 'visible' }
|
||||||
|
const innerClassName = cn (
|
||||||
|
'inline-flex min-w-0 max-w-full items-baseline overflow-hidden',
|
||||||
|
sp && 'touch-pan-y')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -77,12 +91,16 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
return
|
return
|
||||||
const dx = e.clientX - p.x
|
const dx = e.clientX - p.x
|
||||||
const dy = e.clientY - p.y
|
const dy = e.clientY - p.y
|
||||||
|
|
||||||
if (dx * dx + dy * dy >= 9)
|
if (dx * dx + dy * dy >= 9)
|
||||||
armEatNextClick ()
|
armEatNextClick ()
|
||||||
}}
|
}}
|
||||||
onPointerUpCapture={() => {
|
onPointerUpCapture={() => {
|
||||||
downPosRef.current = null
|
downPosRef.current = null
|
||||||
}}
|
}}
|
||||||
|
onPointerCancelCapture={() => {
|
||||||
|
downPosRef.current = null
|
||||||
|
}}
|
||||||
onClickCapture={e => {
|
onClickCapture={e => {
|
||||||
if (suppressClickRef.current)
|
if (suppressClickRef.current)
|
||||||
{
|
{
|
||||||
@@ -90,24 +108,35 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
e.stopPropagation ()
|
e.stopPropagation ()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
ref={node => {
|
ref={setDropRef}
|
||||||
setDragRef (node)
|
|
||||||
setDropRef (node)
|
|
||||||
}}
|
|
||||||
style={style}
|
|
||||||
className={cn (
|
className={cn (
|
||||||
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
||||||
|
sp && 'touch-pan-y',
|
||||||
over && 'ring-2 ring-offset-2')}
|
over && 'ring-2 ring-offset-2')}
|
||||||
|
>
|
||||||
|
{activeDragging
|
||||||
|
? (
|
||||||
|
<div
|
||||||
|
ref={setDragRef}
|
||||||
|
style={style}
|
||||||
|
className={innerClassName}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}>
|
{...listeners}>
|
||||||
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
|
</div>)
|
||||||
|
: (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
ref={setDragRef}
|
||||||
|
style={style}
|
||||||
|
className={innerClassName}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
layoutId={animationMode === 'off'
|
layoutId={animationMode === 'off'
|
||||||
? undefined
|
? undefined
|
||||||
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
: `tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
</motion.div>
|
</motion.div>)}
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { DndContext,
|
import { DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
MeasuringStrategy,
|
||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
|
pointerWithin,
|
||||||
useDroppable,
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors } from '@dnd-kit/core'
|
useSensors } from '@dnd-kit/core'
|
||||||
import { restrictToWindowEdges } from '@dnd-kit/modifiers'
|
import { restrictToWindowEdges, snapCenterToCursor } from '@dnd-kit/modifiers'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -25,15 +27,23 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
|||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { DragEndEvent } from '@dnd-kit/core'
|
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
|
||||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||||
|
|
||||||
import type { Category, Post, TagWithSections } from '@/types'
|
import type { Category, Post, TagWithSections } from '@/types'
|
||||||
|
|
||||||
type TagByCategory = { [key in Category]: TagWithSections[] }
|
type TagByCategory = { [key in Category]: TagWithSections[] }
|
||||||
|
|
||||||
|
const tagCollisionDetection: CollisionDetection = args => {
|
||||||
|
return pointerWithin (args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const alwaysMeasureDroppables = {
|
||||||
|
droppable: { strategy: MeasuringStrategy.Always } } as const
|
||||||
|
|
||||||
|
|
||||||
const renderTagTree = (
|
const renderTagTree = (
|
||||||
|
activeDndId: string | undefined,
|
||||||
tag: TagWithSections,
|
tag: TagWithSections,
|
||||||
nestLevel: number,
|
nestLevel: number,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -45,6 +55,7 @@ const renderTagTree = (
|
|||||||
const self = (
|
const self = (
|
||||||
<li key={key} className="mb-1">
|
<li key={key} className="mb-1">
|
||||||
<DraggableDroppableTagRow
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
nestLevel={nestLevel}
|
nestLevel={nestLevel}
|
||||||
pathKey={key}
|
pathKey={key}
|
||||||
@@ -58,7 +69,14 @@ const renderTagTree = (
|
|||||||
...((tag.children
|
...((tag.children
|
||||||
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
?.sort ((a, b) => a.name < b.name ? -1 : 1)
|
||||||
.flatMap (child =>
|
.flatMap (child =>
|
||||||
renderTagTree (child, nestLevel + 1, key, suppressClickRef, tag.id, sp)))
|
renderTagTree (
|
||||||
|
activeDndId,
|
||||||
|
child,
|
||||||
|
nestLevel + 1,
|
||||||
|
key,
|
||||||
|
suppressClickRef,
|
||||||
|
tag.id,
|
||||||
|
sp)))
|
||||||
?? [])]
|
?? [])]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,12 +195,35 @@ const DropSlot = ({ cat }: { cat: Category }) => {
|
|||||||
</li>)
|
</li>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EmptyCategoryDropSection = (
|
||||||
|
{ cat, children }: { cat: Category
|
||||||
|
children: ReactNode }) => {
|
||||||
|
const { setNodeRef, isOver: over } = useDroppable ({
|
||||||
|
id: `slot:${ cat }`,
|
||||||
|
data: { kind: 'slot', cat } })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} className="my-3">
|
||||||
|
{children}
|
||||||
|
<ul>
|
||||||
|
<li className="h-1">
|
||||||
|
{over && <div className="h-0.5 w-full rounded bg-sky-400"/>}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
post: Post
|
post: Post
|
||||||
sp?: boolean }
|
sp?: boolean }
|
||||||
|
|
||||||
|
type ActiveTagDrag = {
|
||||||
|
dndId: string
|
||||||
|
tagId: number
|
||||||
|
nestLevel: number }
|
||||||
|
|
||||||
|
|
||||||
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||||
sp = Boolean (sp)
|
sp = Boolean (sp)
|
||||||
@@ -212,7 +253,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
return tagsTmp
|
return tagsTmp
|
||||||
}, [post])
|
}, [post])
|
||||||
|
|
||||||
const [activeTagId, setActiveTagId] = useState<number | null> (null)
|
const [activeTagDrag, setActiveTagDrag] = useState<ActiveTagDrag | null> (null)
|
||||||
const [dragging, setDragging] = useState (false)
|
const [dragging, setDragging] = useState (false)
|
||||||
const [saving, setSaving] = useState (false)
|
const [saving, setSaving] = useState (false)
|
||||||
const [tags, setTags] = useState (baseTags)
|
const [tags, setTags] = useState (baseTags)
|
||||||
@@ -220,6 +261,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
() => buildFlatTagByCategory (tags),
|
() => buildFlatTagByCategory (tags),
|
||||||
[tags],
|
[tags],
|
||||||
)
|
)
|
||||||
|
const activeDndId = activeTagDrag?.dndId
|
||||||
|
|
||||||
const suppressClickRef = useRef (false)
|
const suppressClickRef = useRef (false)
|
||||||
|
|
||||||
@@ -328,9 +370,19 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
<TagSearch/>
|
<TagSearch/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={tagCollisionDetection}
|
||||||
|
measuring={alwaysMeasureDroppables}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (e.active.data.current?.kind === 'tag')
|
if (e.active.data.current?.kind === 'tag')
|
||||||
setActiveTagId (e.active.data.current?.tagId ?? null)
|
{
|
||||||
|
const tagId = e.active.data.current?.tagId
|
||||||
|
const nestLevel = e.active.data.current?.nestLevel
|
||||||
|
const dndId = e.active.data.current?.dndId
|
||||||
|
setActiveTagDrag (
|
||||||
|
tagId == null || nestLevel == null || dndId == null
|
||||||
|
? null
|
||||||
|
: { dndId, tagId, nestLevel })
|
||||||
|
}
|
||||||
setDragging (true)
|
setDragging (true)
|
||||||
suppressClickRef.current = true
|
suppressClickRef.current = true
|
||||||
document.body.style.userSelect = 'none'
|
document.body.style.userSelect = 'none'
|
||||||
@@ -341,13 +393,13 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
suppressClickRef.current = false}, { capture: true, once: true })
|
suppressClickRef.current = false}, { capture: true, once: true })
|
||||||
}}
|
}}
|
||||||
onDragCancel={() => {
|
onDragCancel={() => {
|
||||||
setActiveTagId (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
suppressClickRef.current = false
|
suppressClickRef.current = false
|
||||||
}}
|
}}
|
||||||
onDragEnd={async e => {
|
onDragEnd={async e => {
|
||||||
setActiveTagId (null)
|
setActiveTagDrag (null)
|
||||||
setDragging (false)
|
setDragging (false)
|
||||||
await onDragEnd (e)
|
await onDragEnd (e)
|
||||||
document.body.style.userSelect = ''
|
document.body.style.userSelect = ''
|
||||||
@@ -362,8 +414,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
if (!(categoryTags.length > 0 || dragging))
|
if (!(categoryTags.length > 0 || dragging))
|
||||||
return null
|
return null
|
||||||
|
|
||||||
return (
|
const sectionTitle = (
|
||||||
<div className="my-3" key={cat}>
|
|
||||||
<SubsectionTitle>
|
<SubsectionTitle>
|
||||||
<motion.div
|
<motion.div
|
||||||
layoutId={animationMode === 'off'
|
layoutId={animationMode === 'off'
|
||||||
@@ -372,12 +423,22 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
transition={{ layout: layoutTransition }}>
|
transition={{ layout: layoutTransition }}>
|
||||||
{CATEGORY_NAMES[cat]}
|
{CATEGORY_NAMES[cat]}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</SubsectionTitle>
|
</SubsectionTitle>)
|
||||||
|
|
||||||
|
if (!(categoryTags.length > 0))
|
||||||
|
return (
|
||||||
|
<EmptyCategoryDropSection cat={cat} key={cat}>
|
||||||
|
{sectionTitle}
|
||||||
|
</EmptyCategoryDropSection>)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-3" key={cat}>
|
||||||
|
{sectionTitle}
|
||||||
<ul>
|
<ul>
|
||||||
{(tagRelationDisplay === 'grouped'
|
{(tagRelationDisplay === 'grouped'
|
||||||
? (tags[cat] ?? []).flatMap (tag =>
|
? (tags[cat] ?? []).flatMap (tag =>
|
||||||
renderTagTree (
|
renderTagTree (
|
||||||
|
activeDndId,
|
||||||
tag,
|
tag,
|
||||||
0,
|
0,
|
||||||
`cat-${ cat }`,
|
`cat-${ cat }`,
|
||||||
@@ -389,6 +450,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
: (flatTagsByCategory[cat] ?? []).map (tag => (
|
: (flatTagsByCategory[cat] ?? []).map (tag => (
|
||||||
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
|
<li key={`flat-${ cat }-${ tag.id }`} className="mb-1">
|
||||||
<DraggableDroppableTagRow
|
<DraggableDroppableTagRow
|
||||||
|
activeDndId={activeDndId}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
nestLevel={0}
|
nestLevel={0}
|
||||||
pathKey={`flat-${ cat }-${ tag.id }`}
|
pathKey={`flat-${ cat }-${ tag.id }`}
|
||||||
@@ -441,11 +503,14 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
|
|
||||||
<DragOverlay adjustScale={false}>
|
<DragOverlay
|
||||||
|
adjustScale={false}
|
||||||
|
modifiers={[snapCenterToCursor]}
|
||||||
|
dropAnimation={animationMode === 'off' ? null : undefined}>
|
||||||
<div className="pointer-events-none">
|
<div className="pointer-events-none">
|
||||||
{activeTagId != null && (() => {
|
{activeTagDrag != null && (() => {
|
||||||
const tag = findTag (tags, activeTagId)
|
const tag = findTag (tags, activeTagDrag.tagId)
|
||||||
return tag && <TagLink tag={tag}/>
|
return tag && <TagLink tag={tag} nestLevel={activeTagDrag.nestLevel}/>
|
||||||
}) ()}
|
}) ()}
|
||||||
</div>
|
</div>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ body
|
|||||||
background: var(--top-nav-submenu-bg);
|
background: var(--top-nav-submenu-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-mobile-menu .top-nav-submenu
|
||||||
|
{
|
||||||
|
background: var(--top-nav-mobile-active-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.top-nav-mobile-menu
|
.top-nav-mobile-menu
|
||||||
{
|
{
|
||||||
background: var(--top-nav-mobile-menu-bg);
|
background: var(--top-nav-mobile-menu-bg);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
|
||||||
|
const indexCss = readFileSync (
|
||||||
|
resolve (process.cwd (), 'src/index.css'),
|
||||||
|
'utf8')
|
||||||
|
const compactIndexCss = indexCss.replace (/\s+/g, ' ')
|
||||||
|
const mobileActiveRule = '.top-nav-mobile-active {'
|
||||||
|
+ ' background: var(--top-nav-mobile-active-bg); }'
|
||||||
|
const mobileSubmenuRule = '.top-nav-mobile-menu .top-nav-submenu {'
|
||||||
|
+ ' background: var(--top-nav-mobile-active-bg); }'
|
||||||
|
|
||||||
|
|
||||||
|
describe ('TopNav mobile menu colours', () => {
|
||||||
|
it ('uses one background variable for active rows and expanded submenus', () => {
|
||||||
|
expect (compactIndexCss).toContain (mobileActiveRule)
|
||||||
|
expect (compactIndexCss).toContain (mobileSubmenuRule)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -89,8 +89,12 @@ describe ('settings', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
|
it ('derives TopNav colours without mixing mobile active backgrounds', () => {
|
||||||
expect (buildThemeTokens ('light', { }).topNavMobileActiveBackground).toBe (
|
const tokens = buildThemeTokens ('light', { })
|
||||||
|
|
||||||
|
expect (tokens.topNavMobileActiveBackground).toBe (
|
||||||
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
|
LIGHT_THEME_TOKENS.topNavRootBackgroundDesktop)
|
||||||
|
expect (tokens.topNavMobileActiveBackground).not.toBe (
|
||||||
|
tokens.topNavRootBackgroundMobile)
|
||||||
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
|
expect (buildThemeTokens ('dark', { }).topNavMobileActiveBackground).toBe (
|
||||||
DARK_THEME_TOKENS.topNavActiveBackground)
|
DARK_THEME_TOKENS.topNavActiveBackground)
|
||||||
})
|
})
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする