From 09ac2576bb6df81bb2592811137e5443a0e228ae Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 18 Jul 2026 01:28:22 +0900 Subject: [PATCH] #399 --- backend/app/models/post.rb | 94 ++++++++++++++++++- .../post_thumbnail_upload_validator.rb | 6 +- .../app/services/preview/thumbnail_fetcher.rb | 16 ++-- .../posts/import/postImportRowStatus.ts | 4 +- frontend/src/lib/postImportStorage.ts | 31 +++++- frontend/src/lib/postImportTypes.ts | 2 +- frontend/src/lib/postNewQueryState.ts | 2 +- .../src/pages/posts/PostImportSourcePage.tsx | 76 +++++++++++---- 8 files changed, 196 insertions(+), 35 deletions(-) diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index 0c7fc2a..a812fe5 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -1,6 +1,7 @@ class Post < ApplicationRecord require 'date' require 'mini_magick' + require 'nokogiri' require 'stringio' class RemoteThumbnailFetchFailed < StandardError; end @@ -11,10 +12,13 @@ class Post < ApplicationRecord ORIGINAL_CREATED_ORDER_MESSAGE = 'オリジナルの作成日時の順番がをかしぃです.'.freeze ORIGINAL_CREATED_MINIMUM_RANGE_MESSAGE = 'オリジナルの作成日時の範囲は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 - image = MiniMagick::Image.read(upload.read) + image = image_for_thumbnail_upload(upload.read, content_type:) image.resize '180x180' image.format 'jpg' @@ -27,7 +31,9 @@ class Post < ApplicationRecord def self.remote_thumbnail_attachment(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, Preview::ThumbnailFetcher::GenerationFailed, Preview::HttpFetcher::FetchFailed, @@ -209,6 +215,88 @@ class Post < ApplicationRecord self.url = PostUrlNormaliser.normalise(url) || url.strip 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 raw_value = public_send("#{ field }_before_type_cast") value = public_send(field) diff --git a/backend/app/services/post_thumbnail_upload_validator.rb b/backend/app/services/post_thumbnail_upload_validator.rb index fc2b14b..74d3650 100644 --- a/backend/app/services/post_thumbnail_upload_validator.rb +++ b/backend/app/services/post_thumbnail_upload_validator.rb @@ -1,12 +1,14 @@ class PostThumbnailUploadValidator 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 def self.validate! thumbnail 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, 'サムネイル画像の形式が不正です.' unless allowed_content_type?(thumbnail.content_type) diff --git a/backend/app/services/preview/thumbnail_fetcher.rb b/backend/app/services/preview/thumbnail_fetcher.rb index 0be0f98..b552b17 100644 --- a/backend/app/services/preview/thumbnail_fetcher.rb +++ b/backend/app/services/preview/thumbnail_fetcher.rb @@ -1,9 +1,13 @@ module Preview class ThumbnailFetcher class GenerationFailed < StandardError; end - ALLOWED_IMAGE_CONTENT_TYPES = [ + RASTER_IMAGE_CONTENT_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp' ].freeze + REMOTE_IMAGE_CONTENT_TYPES = [ + *RASTER_IMAGE_CONTENT_TYPES, + 'image/svg+xml' + ].freeze HTML_MAX_BYTES = 1.megabyte NICONICO_XML_MAX_BYTES = 256.kilobytes @@ -28,7 +32,7 @@ module Preview def self.fetch_image_response(raw_url) uri, = UrlSafety.validate(raw_url) response = HttpFetcher.fetch(uri.to_s) - unless allowed_image_content_type?(response.content_type) + unless allowed_remote_image_content_type?(response.content_type) raise GenerationFailed, 'サムネール画像が見つかりませんでした.' end @@ -51,7 +55,7 @@ module Preview return nil if url.blank? 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 rescue HttpFetcher::FetchTimeout @@ -92,13 +96,13 @@ module Preview nil 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 - ALLOWED_IMAGE_CONTENT_TYPES.include?(mime_type) + REMOTE_IMAGE_CONTENT_TYPES.include?(mime_type) end private_class_method :fetch_image_or_nil, :fetch_image!, :niconico_thumbnail_url, - :allowed_image_content_type? + :allowed_remote_image_content_type? end end diff --git a/frontend/src/components/posts/import/postImportRowStatus.ts b/frontend/src/components/posts/import/postImportRowStatus.ts index 4e4af51..3f99f8c 100644 --- a/frontend/src/components/posts/import/postImportRowStatus.ts +++ b/frontend/src/components/posts/import/postImportRowStatus.ts @@ -16,7 +16,9 @@ const hasWarnings = (row: PostImportRow): boolean => export const displayPostImportStatus = ( row: PostImportRow, ): PostImportDisplayStatus | null => - (row.skipReason != null || row.importStatus === 'skipped') + row.status === 'pending' + ? null + : (row.skipReason != null || row.importStatus === 'skipped') ? 'skipped' : (row.importStatus === 'created') ? 'created' diff --git a/frontend/src/lib/postImportStorage.ts b/frontend/src/lib/postImportStorage.ts index b35aa3e..16d1815 100644 --- a/frontend/src/lib/postImportStorage.ts +++ b/frontend/src/lib/postImportStorage.ts @@ -73,8 +73,30 @@ export const validatePostImportSessionId = (value: unknown): string | null => { } -export const generatePostImportSessionId = (): string => - crypto.randomUUID () +const generateUuidV4FromRandomValues = (): string => { + 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 }` @@ -156,7 +178,10 @@ const ensureStringListRecord = (value: unknown): Record | null const isValidStatus = ( value: unknown, ): value is PostImportRow['status'] => - value === 'ready' || value === 'warning' || value === 'error' + value === 'pending' + || value === 'ready' + || value === 'warning' + || value === 'error' const isValidImportStatus = ( diff --git a/frontend/src/lib/postImportTypes.ts b/frontend/src/lib/postImportTypes.ts index baf9423..45de47d 100644 --- a/frontend/src/lib/postImportTypes.ts +++ b/frontend/src/lib/postImportTypes.ts @@ -34,7 +34,7 @@ export type PostImportRow = { importErrors?: Record provenance: Record tagSources?: Record - status: 'ready' | 'warning' | 'error' + status: 'pending' | 'ready' | 'warning' | 'error' skipReason?: PostImportSkipReason existingPostId?: number existingPost?: PostImportExistingPost diff --git a/frontend/src/lib/postNewQueryState.ts b/frontend/src/lib/postNewQueryState.ts index 44a531e..8f01eb3 100644 --- a/frontend/src/lib/postNewQueryState.ts +++ b/frontend/src/lib/postNewQueryState.ts @@ -344,7 +344,7 @@ const buildRow = (state: FullRowState): PostImportRow => { validationErrors: { }, provenance, tagSources, - status: 'ready', + status: 'pending', skipReason: state.skip ?? undefined, existingPostId: state.existing_post_id ?? undefined, resetSnapshot: { diff --git a/frontend/src/pages/posts/PostImportSourcePage.tsx b/frontend/src/pages/posts/PostImportSourcePage.tsx index f0b91d9..9465e25 100644 --- a/frontend/src/pages/posts/PostImportSourcePage.tsx +++ b/frontend/src/pages/posts/PostImportSourcePage.tsx @@ -75,7 +75,7 @@ const buildInitialRows = (source: string): PostImportRow[] => tagSources: { automatic: '', manual: '' }, - status: 'ready' as const, + status: 'pending' as const, resetSnapshot: { url: row.url, attributes: { @@ -122,6 +122,9 @@ const PostImportSourcePage: FC = ({ user }) => { .filter (value => value != null) .join (' ') + const showStorageError = (message: string) => + toast ({ description: message }) + useEffect (() => { cleanupExpiredPostImportSessions (message => toast ({ title: '保存済みデータを整理できませんでした', description: message })) @@ -176,28 +179,65 @@ const PostImportSourcePage: FC = ({ user }) => { { const nextRows = initialisePreviewRows (buildInitialRows (source)) const serialised = await serialisePostNewState (nextRows) - if (serialised == null) - return - - const sessionId = generatePostImportSessionId () + const sessionId = (() => { + try + { + return generatePostImportSessionId () + } + catch + { + return null + } + }) () const session = { source, rows: nextRows, repairMode: resultRepairMode (nextRows) } - const saved = savePostImportSession (sessionId, session) - const sessionLoaded = loadPostImportSession (sessionId) - if (!(serialised.complete) - && (!(saved)) - && !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows))) - return - if (saved && !(serialisedPostImportRowsEqual (sessionLoaded?.rows ?? [], nextRows))) - return + const saved = + sessionId == null + ? false + : savePostImportSession (sessionId, session, showStorageError) + const loadedSession = + sessionId == null + ? null + : 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 { @@ -249,13 +289,13 @@ const PostImportSourcePage: FC = ({ user }) => { ))} -
+
入力行数 {lineCount} / {MAX_ROWS}