URL 正規化 (#409) #410

マージ済み
みてるぞ が 4 個のコミットを feature/409 から main へマージ 2026-07-14 12:38:03 +09:00
4個のファイルの変更137行の追加3行の削除
コミット 9a6e6f6053 の変更だけを表示してゐます - すべてのコミットを表示
+2 -2
ファイルの表示
@@ -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
+77
ファイルの表示
@@ -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
+47
ファイルの表示
@@ -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
生成ファイル
+11 -1
ファイルの表示
@@ -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