diff --git a/script/migrations/convert-absolute-attachment-urls-to-relative.rb b/script/migrations/convert-absolute-attachment-urls-to-relative.rb new file mode 100644 index 000000000..009b6c4cb --- /dev/null +++ b/script/migrations/convert-absolute-attachment-urls-to-relative.rb @@ -0,0 +1,132 @@ +#!/usr/bin/env ruby + +# Convert absolute attachment URLs in rich text content to relative paths. +# This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...) +# making them portable across beta environments and host changes. +# +# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption +# +# Run locally: +# bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help" +# +# Safe to re-run: won't modify already-relative URLs + +class ConvertAbsoluteAttachmentUrlsToRelative + # Match absolute URLs pointing to Active Storage routes, keeping the account slug + ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\d+/rails/active_storage/[^"']+)} + + attr_reader :account, :dry_run + + def initialize(account_id: nil, dry_run: false) + @account = Account.find_by(external_account_id: account_id) + @dry_run = dry_run + end + + def run + puts "Converting absolute attachment URLs to relative paths" + puts dry_run ? "DRY RUN MODE - no changes will be saved" : "LIVE MODE - changes will be saved" + puts account ? "Only account: #{account.external_account_id} - #{account.name}" : "For **ALL ACCOUNTS**" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + # Suppress SQL logs + Rails.event.debug_mode = false + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + convert_urls + end + end + + puts "\n\n" + puts "Finished in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def convert_urls + scanned = 0 + fixed = 0 + urls_converted = 0 + + action_texts_scope.find_each do |rich_text| + scanned += 1 + + body = rich_text.body + + edited = false + conversions = 0 + + body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + if url.match?(ABSOLUTE_URL_PATTERN) + node["url"] = url.gsub(ABSOLUTE_URL_PATTERN, '\1') + edited = true + conversions += 1 + end + end + + if edited + record = rich_text.record + puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" + + unless dry_run + rich_text.update! body: body.fragment.to_html + end + + fixed += 1 + urls_converted += conversions + end + end + + puts "\n\nConversion complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " URLs converted: #{urls_converted}" + end + + def action_texts_scope + # Only examine rich texts that have embedded attachments + scope = ActionText::RichText.joins(:embeds_attachments) + scope = scope.where(account: account) if account + scope + end +end + +require "optparse" + +options = { account_id: nil, dry_run: true } + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("-a", "--account ACCOUNT_ID", "Restrict to a specific account (external_account_id)") do |id| + options[:account_id] = id + end + + opts.on("--[no-]dry-run", "Run in dry-run mode (default: --dry-run)") do |v| + options[:dry_run] = v + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +ConvertAbsoluteAttachmentUrlsToRelative.new(**options).run