From 9a6e6f60531dcc6d8b6edd1597224e0ccf902687 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Mon, 13 Jul 2026 12:44:33 +0900 Subject: [PATCH] #409 --- backend/app/models/post.rb | 4 +- .../app/models/post_url_sanitisation_rule.rb | 77 +++++++++++++++++++ ...0000_create_post_url_sanitisation_rules.rb | 47 +++++++++++ backend/db/schema.rb | 12 ++- 4 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 backend/app/models/post_url_sanitisation_rule.rb create mode 100644 backend/db/migrate/20260713000000_create_post_url_sanitisation_rules.rb diff --git a/backend/app/models/post.rb b/backend/app/models/post.rb index cdfbaef..3bc74d2 100644 --- a/backend/app/models/post.rb +++ b/backend/app/models/post.rb @@ -57,7 +57,7 @@ class Post < ApplicationRecord attribute :version_no, :integer, default: 1 - before_validation :normalise_url + before_validation :normalise_url, if: :will_save_change_to_url? validates :url, presence: true, uniqueness: true validates :video_ms, numericality: { only_integer: true, greater_than: 0 }, allow_nil: true @@ -180,7 +180,7 @@ class Post < ApplicationRecord u.host = u.host.downcase if u.host u.path = u.path.sub(/\/\Z/, '') if u.path.present? - self.url = u.to_s + self.url = PostUrlSanitisationRule.sanitise(u.to_s) rescue URI::InvalidURIError ; end diff --git a/backend/app/models/post_url_sanitisation_rule.rb b/backend/app/models/post_url_sanitisation_rule.rb new file mode 100644 index 0000000..fa6e36b --- /dev/null +++ b/backend/app/models/post_url_sanitisation_rule.rb @@ -0,0 +1,77 @@ +class PostUrlSanitisationRule < ApplicationRecord + include Discard::Model + + class UrlConflictError < StandardError + attr_reader :conflicts + + def initialize(conflicts) + @conflicts = conflicts + urls = conflicts.map { _1.fetch(:url) }.uniq.join(', ') + super("post URL sanitisation conflicts detected for #{ urls }") + end + end + + self.primary_key = :priority + + default_scope -> { kept } + + validates :source_pattern, presence: true, uniqueness: true + + validate :source_pattern_must_be_regexp + + class << self + def sanitise(url) = + rules.reduce(url.dup) { |value, (pattern, replacement)| value.sub(pattern, replacement) } + + def apply! + rewrites = Post.order(:id).pluck(:id, :url).map do |post_id, original_url| + { post_id:, + original_url:, + sanitised_url: sanitise(original_url) } + end + + conflicts = rewrites + .group_by { _1.fetch(:sanitised_url) } + .values + .filter { _1.size > 1 } + .flatten + .map { { url: _1.fetch(:sanitised_url), + post_id: _1.fetch(:post_id), + original_url: _1.fetch(:original_url) } } + + raise UrlConflictError.new(conflicts) if conflicts.present? + + changed = rewrites.filter { _1.fetch(:original_url) != _1.fetch(:sanitised_url) } + return if changed.empty? + + token = SecureRandom.hex(6) + + Post.transaction do + changed.each do |row| + Post.where(id: row.fetch(:post_id)) + .update_all(url: temporary_url_for(row.fetch(:post_id), token)) + end + + changed.each do |row| + Post.where(id: row.fetch(:post_id)) + .update_all(url: row.fetch(:sanitised_url)) + end + end + end + + private + + def rules = kept.order(:priority).map { |r| [Regexp.new(r.source_pattern), r.replacement] } + + def temporary_url_for(post_id, token) = + "https://post-url-sanitising.invalid/#{ token }/#{ post_id }" + end + + private + + def source_pattern_must_be_regexp + Regexp.new(source_pattern) + rescue RegexpError + errors.add :source_pattern, '変な正規表現だね〜(笑)' + end +end diff --git a/backend/db/migrate/20260713000000_create_post_url_sanitisation_rules.rb b/backend/db/migrate/20260713000000_create_post_url_sanitisation_rules.rb new file mode 100644 index 0000000..f6a63d5 --- /dev/null +++ b/backend/db/migrate/20260713000000_create_post_url_sanitisation_rules.rb @@ -0,0 +1,47 @@ +class CreatePostUrlSanitisationRules < ActiveRecord::Migration[8.0] + def up + create_table :post_url_sanitisation_rules, + id: :integer, + primary_key: :priority do |t| + t.string :source_pattern, null: false + t.string :replacement, null: false + t.timestamps + t.datetime :discarded_at + t.index :source_pattern, unique: true + t.index :discarded_at + end + + now = ActiveRecord::Base.connection.quote(Time.current) + execute <<~SQL + INSERT INTO + post_url_sanitisation_rules(priority, source_pattern, replacement, created_at, updated_at) + VALUES + (10, '\\Ahttps?://youtu\\\\.be/([^/?#]+)(?:[?#].*)?\\z', + 'https://www.youtube.com/watch?v=\\\\1', + #{ now }, #{ now }) + , (20, '\\Ahttps?://(?:www\\\\.|m\\\\.)?youtube\\\\.com/live/([^/?#]+)(?:[?#].*)?\\z', + 'https://www.youtube.com/watch?v=\\\\1', + #{ now }, #{ now }) + , (30, '\\Ahttps?://(?:www\\\\.|m\\\\.)?youtube\\\\.com/shorts/([^/?#]+)(?:[?#].*)?\\z', + 'https://www.youtube.com/watch?v=\\\\1', + #{ now }, #{ now }) + , (40, '\\Ahttps?://(?:www\\\\.|m\\\\.)?youtube\\\\.com/embed/([^/?#]+)(?:[?#].*)?\\z', + 'https://www.youtube.com/watch?v=\\\\1', + #{ now }, #{ now }) + , (50, '\\Ahttps?://(?:www\\\\.|m\\\\.)?youtube\\\\.com/watch\\\\?(?:[^#&]+&)*v=([^&#]+)(?:[&#].*)?\\z', + 'https://www.youtube.com/watch?v=\\\\1', + #{ now }, #{ now }) + , (60, '\\Ahttps?://nico\\\\.ms/([^/?#]+)(?:[?#].*)?\\z', + 'https://www.nicovideo.jp/watch/\\\\1', + #{ now }, #{ now }) + , (70, '\\Ahttps?://(?:www\\\\.)?nicovideo\\\\.jp/watch/([^?#/]+)(?:[?#].*)?\\z', + 'https://www.nicovideo.jp/watch/\\\\1', + #{ now }, #{ now }) + ; + SQL + end + + def down + drop_table :post_url_sanitisation_rules + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index eb54633..c515aee 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_13_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -331,6 +331,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do t.index ["tag_id"], name: "index_post_tags_on_tag_id" end + create_table "post_url_sanitisation_rules", primary_key: "priority", id: :integer, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "source_pattern", null: false + t.string "replacement", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.datetime "discarded_at" + t.index ["discarded_at"], name: "index_post_url_sanitisation_rules_on_discarded_at" + t.index ["source_pattern"], name: "index_post_url_sanitisation_rules_on_source_pattern", unique: true + end + create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.integer "version_no", null: false