広場投稿追加画面の刷新 (#399) #413
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<string, string[]> | 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 = (
|
||||
|
||||
@@ -34,7 +34,7 @@ export type PostImportRow = {
|
||||
importErrors?: Record<string, string[]>
|
||||
provenance: Record<string, PostImportOrigin>
|
||||
tagSources?: Record<PostImportOrigin, string>
|
||||
status: 'ready' | 'warning' | 'error'
|
||||
status: 'pending' | 'ready' | 'warning' | 'error'
|
||||
skipReason?: PostImportSkipReason
|
||||
existingPostId?: number
|
||||
existingPost?: PostImportExistingPost
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ user }) => {
|
||||
</li>))}
|
||||
</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">
|
||||
入力行数 {lineCount} / {MAX_ROWS}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="shrink-0"
|
||||
className="pointer-events-auto shrink-0"
|
||||
onClick={preview}
|
||||
disabled={loading}>
|
||||
次へ
|
||||
|
||||
新しい課題から参照
ユーザをブロックする