このコミットが含まれているのは:
2026-07-18 01:28:22 +09:00
コミット 09ac2576bb
8個のファイルの変更196行の追加35行の削除
+91 -3
ファイルの表示
@@ -1,6 +1,7 @@
class Post < ApplicationRecord class Post < ApplicationRecord
require 'date' require 'date'
require 'mini_magick' require 'mini_magick'
require 'nokogiri'
require 'stringio' require 'stringio'
class RemoteThumbnailFetchFailed < StandardError; end class RemoteThumbnailFetchFailed < StandardError; end
@@ -11,10 +12,13 @@ class Post < ApplicationRecord
ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze
ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE = ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE =
'オリジナルの作成日時の範囲は1分以上必要です.'.freeze 'オリジナルの作成日時の範囲は1分以上必要です.'.freeze
REMOTE_SVG_CONTENT_TYPE = 'image/svg+xml'.freeze
MAX_SVG_DIMENSION = 4_096
MAX_SVG_PIXELS = 16_777_216
def self.resized_thumbnail_attachment(upload) def self.resized_thumbnail_attachment(upload, content_type: nil)
upload.rewind upload.rewind
image = MiniMagick::Image.read(upload.read) image = image_for_thumbnail_upload(upload.read, content_type:)
image.resize '180x180' image.resize '180x180'
image.format 'jpg' image.format 'jpg'
@@ -27,7 +31,9 @@ class Post < ApplicationRecord
def self.remote_thumbnail_attachment(raw_url) def self.remote_thumbnail_attachment(raw_url)
response = Preview::ThumbnailFetcher.fetch_image_response(raw_url) response = Preview::ThumbnailFetcher.fetch_image_response(raw_url)
resized_thumbnail_attachment(StringIO.new(response.body)) resized_thumbnail_attachment(
StringIO.new(response.body),
content_type: response.content_type)
rescue Preview::UrlSafety::UnsafeUrl, rescue Preview::UrlSafety::UnsafeUrl,
Preview::ThumbnailFetcher::GenerationFailed, Preview::ThumbnailFetcher::GenerationFailed,
Preview::HttpFetcher::FetchFailed, Preview::HttpFetcher::FetchFailed,
@@ -209,6 +215,88 @@ class Post < ApplicationRecord
self.url = PostUrlNormaliser.normalise(url) || url.strip self.url = PostUrlNormaliser.normalise(url) || url.strip
end end
def self.image_for_thumbnail_upload(bytes, content_type: nil)
return MiniMagick::Image.read(bytes) unless svg_content_type?(content_type)
MiniMagick::Image.read(sanitised_svg_bytes(bytes))
end
def self.svg_content_type?(content_type)
content_type.to_s.split(';', 2).first.to_s.downcase.strip == REMOTE_SVG_CONTENT_TYPE
end
def self.sanitised_svg_bytes(bytes)
document = Nokogiri::XML(
bytes,
nil,
nil,
Nokogiri::XML::ParseOptions::STRICT
| Nokogiri::XML::ParseOptions::NONET)
root = document.root
raise MiniMagick::Error, 'SVG が不正です.' if root == nil || root.name != 'svg'
raise MiniMagick::Error, 'SVG が不正です.' if document.internal_subset != nil
raise MiniMagick::Error, 'SVG が不正です.' if svg_uses_disallowed_features?(document)
width, height = svg_dimensions(root)
raise MiniMagick::Error, 'SVG が大きすぎます.' if width == nil || height == nil
if width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS
raise MiniMagick::Error, 'SVG が大きすぎます.'
end
document.to_xml
rescue Nokogiri::XML::SyntaxError
raise MiniMagick::Error, 'SVG が不正です.'
end
def self.svg_uses_disallowed_features?(document)
document.traverse.any? do |node|
next false unless node.element?
name = node.name.to_s.downcase
next true if name == 'script' || name == 'foreignobject'
node.attribute_nodes.any? do |attribute|
attribute_name = attribute.name.to_s.downcase
attribute_value = attribute.value.to_s
attribute_name.start_with?('on') ||
attribute_name == 'href' ||
attribute_name == 'xlink:href' ||
attribute_name == 'src' ||
(attribute_name == 'style' && attribute_value.match?(/url\s*\(/i)) ||
attribute_value.match?(/\A\s*(?:https?:|data:|\/\/)/i)
end
end
end
def self.svg_dimensions(root)
width = svg_length_to_pixels(root['width'])
height = svg_length_to_pixels(root['height'])
return [width, height] if width && height
view_box = root['viewBox'].to_s.strip.split(/\s+/).map { Float(_1) rescue nil }
return [nil, nil] if view_box.length != 4 || view_box.any?(&:nil?)
[view_box[2], view_box[3]]
end
def self.svg_length_to_pixels(value)
return nil if value.blank?
matched = /\A([0-9]+(?:\.[0-9]+)?)(px)?\z/i.match(value.to_s.strip)
return nil if matched == nil
Float(matched[1])
rescue ArgumentError
nil
end
private_class_method :image_for_thumbnail_upload,
:svg_content_type?,
:sanitised_svg_bytes,
:svg_uses_disallowed_features?,
:svg_dimensions,
:svg_length_to_pixels
def parse_original_created_value field def parse_original_created_value field
raw_value = public_send("#{ field }_before_type_cast") raw_value = public_send("#{ field }_before_type_cast")
value = public_send(field) value = public_send(field)
+4 -2
ファイルの表示
@@ -1,12 +1,14 @@
class PostThumbnailUploadValidator class PostThumbnailUploadValidator
MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024 MAX_THUMBNAIL_BYTES = 20 * 1024 * 1024
ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::ALLOWED_IMAGE_CONTENT_TYPES.freeze ALLOWED_CONTENT_TYPES = Preview::ThumbnailFetcher::RASTER_IMAGE_CONTENT_TYPES.freeze
class InvalidUpload < StandardError; end class InvalidUpload < StandardError; end
def self.validate! thumbnail def self.validate! thumbnail
return if thumbnail.blank? return if thumbnail.blank?
raise InvalidUpload, 'thumbnail upload が不正です.' unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile) unless thumbnail.is_a?(ActionDispatch::Http::UploadedFile)
raise InvalidUpload, 'thumbnail upload が不正です.'
end
raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES raise InvalidUpload, 'thumbnail file size が大きすぎます.' if thumbnail.size > MAX_THUMBNAIL_BYTES
raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type) raise InvalidUpload, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type)
+10 -6
ファイルの表示
@@ -1,9 +1,13 @@
module Preview module Preview
class ThumbnailFetcher class ThumbnailFetcher
class GenerationFailed < StandardError; end class GenerationFailed < StandardError; end
ALLOWED_IMAGE_CONTENT_TYPES = [ RASTER_IMAGE_CONTENT_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp' 'image/jpeg', 'image/png', 'image/gif', 'image/webp'
].freeze ].freeze
REMOTE_IMAGE_CONTENT_TYPES = [
*RASTER_IMAGE_CONTENT_TYPES,
'image/svg+xml'
].freeze
HTML_MAX_BYTES = 1.megabyte HTML_MAX_BYTES = 1.megabyte
NICONICO_XML_MAX_BYTES = 256.kilobytes NICONICO_XML_MAX_BYTES = 256.kilobytes
@@ -28,7 +32,7 @@ module Preview
def self.fetch_image_response(raw_url) def self.fetch_image_response(raw_url)
uri, = UrlSafety.validate(raw_url) uri, = UrlSafety.validate(raw_url)
response = HttpFetcher.fetch(uri.to_s) response = HttpFetcher.fetch(uri.to_s)
unless allowed_image_content_type?(response.content_type) unless allowed_remote_image_content_type?(response.content_type)
raise GenerationFailed, 'サムネール画像が見つかりませんでした.' raise GenerationFailed, 'サムネール画像が見つかりませんでした.'
end end
@@ -51,7 +55,7 @@ module Preview
return nil if url.blank? return nil if url.blank?
response = HttpFetcher.fetch(url) response = HttpFetcher.fetch(url)
return nil unless allowed_image_content_type?(response.content_type) return nil unless allowed_remote_image_content_type?(response.content_type)
response.body response.body
rescue HttpFetcher::FetchTimeout rescue HttpFetcher::FetchTimeout
@@ -92,13 +96,13 @@ module Preview
nil nil
end end
def self.allowed_image_content_type?(content_type) def self.allowed_remote_image_content_type?(content_type)
mime_type = content_type.to_s.split(';', 2).first.downcase.strip mime_type = content_type.to_s.split(';', 2).first.downcase.strip
ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type) REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type)
end end
private_class_method :fetch_image_or_nil, :fetch_image!, private_class_method :fetch_image_or_nil, :fetch_image!,
:niconico_thumbnail_url, :niconico_thumbnail_url,
:allowed_image_content_type? :allowed_remote_image_content_type?
end end
end end
+3 -1
ファイルの表示
@@ -16,7 +16,9 @@ const hasWarnings = (row: PostImportRow): boolean =>
export const displayPostImportStatus = ( export const displayPostImportStatus = (
row: PostImportRow, row: PostImportRow,
): PostImportDisplayStatus | null => ): PostImportDisplayStatus | null =>
(row.skipReason != null || row.importStatus === 'skipped') row.status === 'pending'
? null
: (row.skipReason != null || row.importStatus === 'skipped')
? 'skipped' ? 'skipped'
: (row.importStatus === 'created') : (row.importStatus === 'created')
? 'created' ? 'created'
+28 -3
ファイルの表示
@@ -73,8 +73,30 @@ export const validatePostImportSessionId = (value: unknown): string | null => {
} }
export const generatePostImportSessionId = (): string => const generateUuidV4FromRandomValues = (): string => {
crypto.randomUUID () const bytes = new Uint8Array (16)
crypto.getRandomValues (bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from (
bytes,
byte => byte.toString (16).padStart (2, '0'))
return [
hex.slice (0, 4).join (''),
hex.slice (4, 6).join (''),
hex.slice (6, 8).join (''),
hex.slice (8, 10).join (''),
hex.slice (10, 16).join ('')].join ('-')
}
export const generatePostImportSessionId = (): string => {
if (typeof crypto.randomUUID === 'function')
return crypto.randomUUID ()
return generateUuidV4FromRandomValues ()
}
const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }` const sessionKey = (sessionId: string): string => `${ SESSION_PREFIX }${ sessionId }`
@@ -156,7 +178,10 @@ const ensureStringListRecord = (value: unknown): Record<string, string[]> | null
const isValidStatus = ( const isValidStatus = (
value: unknown, value: unknown,
): value is PostImportRow['status'] => ): value is PostImportRow['status'] =>
value === 'ready' || value === 'warning' || value === 'error' value === 'pending'
|| value === 'ready'
|| value === 'warning'
|| value === 'error'
const isValidImportStatus = ( const isValidImportStatus = (
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ export type PostImportRow = {
importErrors?: Record<string, string[]> importErrors?: Record<string, string[]>
provenance: Record<string, PostImportOrigin> provenance: Record<string, PostImportOrigin>
tagSources?: Record<PostImportOrigin, string> tagSources?: Record<PostImportOrigin, string>
status: 'ready' | 'warning' | 'error' status: 'pending' | 'ready' | 'warning' | 'error'
skipReason?: PostImportSkipReason skipReason?: PostImportSkipReason
existingPostId?: number existingPostId?: number
existingPost?: PostImportExistingPost existingPost?: PostImportExistingPost
+1 -1
ファイルの表示
@@ -344,7 +344,7 @@ const buildRow = (state: FullRowState): PostImportRow => {
validationErrors: { }, validationErrors: { },
provenance, provenance,
tagSources, tagSources,
status: 'ready', status: 'pending',
skipReason: state.skip ?? undefined, skipReason: state.skip ?? undefined,
existingPostId: state.existing_post_id ?? undefined, existingPostId: state.existing_post_id ?? undefined,
resetSnapshot: { resetSnapshot: {
+58 -18
ファイルの表示
@@ -75,7 +75,7 @@ const buildInitialRows = (source: string): PostImportRow[] =>
tagSources: { tagSources: {
automatic: '', automatic: '',
manual: '' }, manual: '' },
status: 'ready' as const, status: 'pending' as const,
resetSnapshot: { resetSnapshot: {
url: row.url, url: row.url,
attributes: { attributes: {
@@ -122,6 +122,9 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
.filter (value => value != null) .filter (value => value != null)
.join (' ') .join (' ')
const showStorageError = (message: string) =>
toast ({ description: message })
useEffect (() => { useEffect (() => {
cleanupExpiredPostImportSessions (message => cleanupExpiredPostImportSessions (message =>
toast ({ title: '保存済みデータを整理できませんでした', description: message })) toast ({ title: '保存済みデータを整理できませんでした', description: message }))
@@ -176,28 +179,65 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
{ {
const nextRows = initialisePreviewRows (buildInitialRows (source)) const nextRows = initialisePreviewRows (buildInitialRows (source))
const serialised = await serialisePostNewState (nextRows) const serialised = await serialisePostNewState (nextRows)
if (serialised == null) const sessionId = (() => {
return try
{
const sessionId = generatePostImportSessionId () return generatePostImportSessionId ()
}
catch
{
return null
}
}) ()
const session = { const session = {
source, source,
rows: nextRows, rows: nextRows,
repairMode: resultRepairMode (nextRows) } repairMode: resultRepairMode (nextRows) }
const saved = savePostImportSession (sessionId, session) const saved =
const sessionLoaded = loadPostImportSession (sessionId) sessionId == null
if (!(serialised.complete) ? false
&& (!(saved)) : savePostImportSession (sessionId, session, showStorageError)
&& !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows))) const loadedSession =
return sessionId == null
if (saved && !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows))) ? null
return : loadPostImportSession (sessionId, showStorageError)
const sessionRoundTripSucceeded =
saved
&& serialisedPostImportRowsEqual (
loadedSession?.rows ?? [],
nextRows)
const canNavigate =
serialised?.complete === true
|| sessionRoundTripSucceeded
if (!(canNavigate))
{
showStorageError ('ブラウザへ保存できませんでした.')
return
}
navigate (appendPostNewSessionId (serialised.path, sessionId)) const nextPath = (() => {
if (serialised != null)
{
return sessionId == null
? serialised.path
: appendPostNewSessionId (serialised.path, sessionId)
}
if (sessionRoundTripSucceeded && sessionId != null)
return `/posts/new?session_id=${ sessionId }`
return null
}) ()
if (nextPath == null)
{
showStorageError ('ブラウザへ保存できませんでした.')
return
}
navigate (nextPath)
} }
catch catch (error)
{ {
return console.error (error)
showStorageError ('ブラウザへ保存できませんでした.')
} }
finally finally
{ {
@@ -249,13 +289,13 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
</li>))} </li>))}
</ul> </ul>
<div className="flex items-center justify-between gap-3"> <div className="relative z-10 flex items-center justify-between gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-300"> <div className="text-sm text-neutral-600 dark:text-neutral-300">
{lineCount} / {MAX_ROWS} {lineCount} / {MAX_ROWS}
</div> </div>
<Button <Button
type="button" type="button"
className="shrink-0" className="pointer-events-auto shrink-0"
onClick={preview} onClick={preview}
disabled={loading}> disabled={loading}>