広場投稿追加画面の刷新 (#399) (#413)

Reviewed-on: #413
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
このコミットはPull リクエスト #413 でマージされました.
このコミットが含まれているのは:
2026-07-19 00:03:10 +09:00
committed by みてるぞ
コミット f1181e8510
99個のファイルの変更10242行の追加1126行の削除
+216 -31
ファイルの表示
@@ -1,5 +1,6 @@
class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
MAX_BULK_REQUEST_BYTES = 40 * 1024 * 1024
class VideoMsParseError < ArgumentError
;
@@ -114,6 +115,77 @@ class PostsController < ApplicationController
render json: PostRepr.base(post, current_user)
end
def metadata
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return render_bad_request('URL は必須です.') if params[:url].blank?
normal_url = PostUrlNormaliser.normalise(params[:url].to_s)
return render_validation_error(fields: { url: ['URL が不正です.'] }) if normal_url.blank?
Preview::UrlSafety.validate(normal_url)
existing_post = Post.with_attached_thumbnail.find_by(url: normal_url)
if existing_post.present?
return render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { },
base_warnings: [],
existing_post_id: existing_post.id,
existing_post: compact_post(existing_post.id) }
end
metadata = PostMetadataFetcher.fetch(normal_url)
field_warnings = { }
field_warnings[:title] = ['タイトルを取得できませんでした.'] if metadata[:title].blank?
if metadata[:thumbnail_base].blank?
field_warnings[:thumbnail_base] = ['サムネールを取得できませんでした.']
end
render json: {
url: normal_url,
title: metadata[:title],
thumbnail_base: metadata[:thumbnail_base],
tags: metadata[:tags],
display_tags: metadata[:display_tags],
original_created_from: metadata[:original_created_from],
original_created_before: metadata[:original_created_before],
duration: metadata[:duration],
video_ms: metadata[:video_ms],
field_warnings: field_warnings,
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
rescue ArgumentError => e
render_bad_request e.message
rescue Preview::UrlSafety::UnsafeUrl => e
render_validation_error fields: { url: [e.message] }
rescue Preview::HttpFetcher::FetchFailed,
Preview::HttpFetcher::FetchTimeout,
Preview::HttpFetcher::ResponseTooLarge
render json: {
url: normal_url,
title: nil,
thumbnail_base: nil,
tags: nil,
display_tags: [],
original_created_from: nil,
original_created_before: nil,
duration: nil,
video_ms: nil,
field_warnings: { url: ['自動取得に失敗しました.'] },
base_warnings: [],
existing_post_id: nil,
existing_post: nil }
end
def show
post =
Post
@@ -142,48 +214,51 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
# TODO: サイトに応じて thumbnail_base 設定
title = params[:title].presence
url = params[:url]
thumbnail = params[:thumbnail]
tag_names = params[:tags].to_s.split
original_created_from = params[:original_created_from]
original_created_before = params[:original_created_before]
parent_post_ids = parse_parent_post_ids
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:)
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
ApplicationRecord.transaction do
post.save!
Tag.normalise_tags!(tag_names, deny_deprecated: true, with_sections: true) =>
{ tags:, sections: }
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags).reject(&:deprecated?)
post.video_ms = normalise_video_ms(tags)
validate_video_sections!(post.video_ms, sections)
post.save!
sync_post_tags!(post, tags, sections)
sync_parent_posts!(post, parent_post_ids)
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
preflight = PostCreatePreflight.new(
attributes: post_create_attributes,
thumbnail: params[:thumbnail],
host: request.base_url).run
return render json: dry_run_json(preflight) if bool?(:dry)
if preflight[:existing_post_id].present?
post = Post.new(url: preflight[:url])
post.errors.add :url, :taken
return render_post_form_record_invalid post
end
post = PostCreator.new(actor: current_user,
attributes: post_create_attributes.merge(
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:direct_tag_specs,
:default_tag_specs,
:snapshot_tag_specs,
:post_tag_specs,
:tag_sections,
:normalised_parent_post_ids).symbolize_keys).merge(
thumbnail: params[:thumbnail])).create!
post.reload
render json: PostRepr.base(post), status: :created
rescue PostCreatePreflight::ValidationFailed => e
render_validation_error fields: e.fields, base: e.base_errors
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
rescue Tag::DeprecatedTagNormalisationError
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
rescue Tag::SectionLiteralParseError
render_validation_error fields: { tags: ['タグ区間の記法が不正です.'] }
rescue VideoMsParseError
rescue PostCreator::VideoMsParseError
render_validation_error fields: { video_ms: ['動画時間の記法が不正です.'] }
rescue Post::RemoteThumbnailFetchFailed
render_validation_error fields: { thumbnail_base: ['サムネイル画像の取得に失敗しました.'] }
rescue MiniMagick::Error
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
rescue ArgumentError => e
@@ -192,6 +267,25 @@ class PostsController < ApplicationController
render_post_form_record_invalid e.record
end
def bulk
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
return head :unsupported_media_type unless request.content_mime_type == Mime[:multipart_form]
return head :payload_too_large if request.content_length.to_i > MAX_BULK_REQUEST_BYTES
posts = parse_bulk_posts_manifest
thumbnails = parse_bulk_thumbnails(posts.length)
result = PostBulkCreator.new(
actor: current_user,
posts:,
thumbnails:,
host: request.base_url).run
render json: result
rescue JSON::ParserError
render_bad_request 'posts manifest の JSON が不正です.'
rescue ArgumentError => e
render_validation_error base: [e.message]
end
def viewed
return head :unauthorized unless current_user
@@ -486,6 +580,79 @@ class PostsController < ApplicationController
}.uniq
end
def post_create_attributes
{ title: params[:title],
url: params[:url],
thumbnail_base: params[:thumbnail_base],
tags: params[:tags],
original_created_from: params[:original_created_from],
original_created_before: params[:original_created_before],
parent_post_ids: parse_parent_post_ids,
video_ms: params[:video_ms],
duration: params[:duration] }
end
def parse_bulk_posts_manifest
manifest = params[:posts]
raise ArgumentError, 'posts は必須です.' if manifest.blank?
raise ArgumentError, 'posts は JSON 文字列で指定してください.' unless manifest.is_a?(String)
posts = JSON.parse(manifest)
raise ArgumentError, 'posts は配列で指定してください.' unless posts.is_a?(Array)
raise ArgumentError, '投稿件数は 1 件以上必要です.' if posts.empty?
raise ArgumentError, '投稿件数が多すぎます.' if posts.length > 100
raise ArgumentError, 'posts 要素の形式が不正です.' unless posts.all? { _1.is_a?(Hash) }
posts
end
def parse_bulk_thumbnails post_count
thumbnails = { }
raw = params[:thumbnails]
return thumbnails if raw.blank?
raise ArgumentError, 'thumbnail key が不正です.' unless raw.respond_to?(:to_unsafe_h)
raw.to_unsafe_h.each do |key, value|
raise ArgumentError, 'thumbnail key が不正です.' unless key.to_s.match?(/\A\d+\z/)
index = Integer(key, 10)
raise ArgumentError, 'thumbnail index が範囲外です.' if index.negative? || index >= post_count
raise ArgumentError, 'thumbnail index が重複しています.' if thumbnails.key?(index)
unless value.is_a?(ActionDispatch::Http::UploadedFile)
raise ArgumentError, 'thumbnail upload が不正です.'
end
thumbnails[index] = value
end
thumbnails
end
def compact_post post_id
return nil if post_id.blank?
post = Post.with_attached_thumbnail.find_by(id: post_id)
PostCompactRepr.base(post, host: request.base_url)
end
def dry_run_json preflight
preflight.slice(
:url,
:title,
:thumbnail_base,
:tags,
:display_tags,
:parent_post_ids,
:original_created_from,
:original_created_before,
:duration,
:video_ms,
:field_warnings,
:base_warnings,
:existing_post_id,
:existing_post)
end
def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.'
@@ -565,6 +732,7 @@ class PostsController < ApplicationController
def post_incoming_snapshot title:, original_created_from:, original_created_before:,
tag_names:, video_ms_param:, duration_param:, parent_post_ids:
validate_original_created_values!(original_created_from, original_created_before)
Tag.normalise_tags!(tag_names, with_tagme: false, deny_deprecated: true,
with_sections: true) =>
{ tags:, sections: }
@@ -602,6 +770,23 @@ class PostsController < ApplicationController
value.to_s
end
def validate_original_created_values! original_created_from, original_created_before
candidate = Post.new(
url: 'https://example.invalid/original-created-validation',
original_created_from:,
original_created_before:)
candidate.valid?
fields = [:original_created_from, :original_created_before, :original_created_at]
relevant_errors = candidate.errors.select { fields.include?(_1.attribute) }
return if relevant_errors.empty?
invalid_post = Post.new
relevant_errors.each { |error|
invalid_post.errors.add(error.attribute, error.message)
}
raise ActiveRecord::RecordInvalid, invalid_post
end
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ section[1] ? Post.ms_to_time(section[1]) : '' }]"
end