コミットを比較

...

7 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 360d3c2a9c #398 2026-07-10 01:16:08 +09:00
みてるぞ ff2954cc63 #398 2026-07-10 00:56:36 +09:00
みてるぞ 3a8f7d8d89 #398 2026-07-10 00:34:45 +09:00
みてるぞ 84fe36a405 #398 2026-07-09 23:33:10 +09:00
みてるぞ b19c24cb8e #398 2026-07-09 23:11:31 +09:00
みてるぞ c1902fbc99 素材管理で新規タグ紐づけ時にタグ履歴が発生しない問題 (#386) (#392)
開発環境で動作確認したらマージする.

Reviewed-on: #392
2026-07-09 06:43:06 +09:00
みてるぞ 2f2b6e2afa タグ D&D 時の表示位置修正 (#268) (#400)
Reviewed-on: #400
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-07-08 00:01:07 +09:00
18個のファイルの変更800行の追加1237行の削除
+10 -8
ファイルの表示
@@ -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
+42 -35
ファイルの表示
@@ -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
+21
ファイルの表示
@@ -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
+122
ファイルの表示
@@ -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
+31
ファイルの表示
@@ -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
+95
ファイルの表示
@@ -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
+55
ファイルの表示
@@ -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
-1102
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
-15
ファイルの表示
@@ -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"
}
}
-18
ファイルの表示
@@ -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 ()
}) ()
+89
ファイルの表示
@@ -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
+32 -14
ファイルの表示
@@ -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
+23
ファイルの表示
@@ -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
+51
ファイルの表示
@@ -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
+15
ファイルの表示
@@ -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
+75
ファイルの表示
@@ -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' })
})
})
+41 -12
ファイルの表示
@@ -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>)
} }
+79 -14
ファイルの表示
@@ -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>