このコミットが含まれているのは:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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,29 +179,66 @@ 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 =
|
||||||
navigate (appendPostNewSessionId (serialised.path, sessionId))
|
saved
|
||||||
}
|
&& serialisedPostImportRowsEqual (
|
||||||
catch
|
loadedSession?.rows ?? [],
|
||||||
|
nextRows)
|
||||||
|
const canNavigate =
|
||||||
|
serialised?.complete === true
|
||||||
|
|| sessionRoundTripSucceeded
|
||||||
|
if (!(canNavigate))
|
||||||
{
|
{
|
||||||
|
showStorageError ('ブラウザへ保存できませんでした.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (error)
|
||||||
|
{
|
||||||
|
console.error (error)
|
||||||
|
showStorageError ('ブラウザへ保存できませんでした.')
|
||||||
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
setLoading (false)
|
setLoading (false)
|
||||||
@@ -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}>
|
||||||
次へ
|
次へ
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする