From 235890e666be6303e9267ac238c0d8802c0e8bbe Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 7 Jan 2026 18:42:06 +0100 Subject: [PATCH 1/4] Use relative URLs for avatars and rich text attachments These URLs can be problematic or inconsistent, as we might end up with rich text content with beta URLs for attachments being used in production, or viceversa. It'll also be problematic when importing or exporting data between a self-hosted instance and the SaaS version. --- app/helpers/avatars_helper.rb | 2 +- config/initializers/active_storage.rb | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 7c064a650..8ae4346ac 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -42,6 +42,6 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options + image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options end end diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 3edd75cc8..586c292a1 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -8,6 +8,17 @@ ActiveSupport.on_load(:active_storage_blob) do end end +ActiveSupport.on_load(:action_text_content) do + # Install our extensions after ActionText::Engine's + ActiveSupport.on_load(:active_storage_blob) do + # Ensure all s have a "url" attribute that's a relative + # path (for portability across host name changes, beta environments, etc). + def to_rich_text_attributes(*) + super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true) + end + end +end + # Don't configure replica connections for ActiveStorage::Record. # When ActiveStorage uses `connects_to`, it creates a separate connection pool # from ApplicationRecord. This causes after_commit callbacks to fire in From 527ffc42b98ffebeaad741999af758a01ed49594 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 7 Jan 2026 19:15:36 +0100 Subject: [PATCH 2/4] Use relative URLs where possible across all the app Skipping API responses, where we need absolute URLs, and those that are intended for sharing or external use: - account/join_codes/show.html.erb - Join code URL for sharing - boards/edit/_publication.html.erb - Publication URL for sharing - public/* views - Public page URLs and og:url meta tags - pwa/manifest.json.erb - PWA manifest needs absolute URLs For this, we had to replace `url_for` used with Active Storage variants and previews with the specific path helper (for Active Storage representations). --- app/controllers/users/avatars_controller.rb | 2 +- .../active_storage/blobs/web/_representation.html.erb | 8 ++++---- app/views/events/event/attachments/_attachment.html.erb | 4 ++-- app/views/my/menus/_accounts.html.erb | 2 +- app/views/my/menus/_settings.html.erb | 2 +- app/views/prompts/cards/_card.html.erb | 2 +- app/views/sessions/menus/show.html.erb | 2 +- app/views/users/show.html.erb | 2 +- app/views/webhooks/event.html.erb | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 420dbe5b6..55d8f06dd 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -8,7 +8,7 @@ class Users::AvatarsController < ApplicationController if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? - redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline") + redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline") elsif stale? @user, cache_control: cache_control render_initials end diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb index ca934f469..82a1a2d96 100644 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -12,14 +12,14 @@ height: height %> <% elsif blob.audio? %> <% elsif blob.variable? %> - <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> - <%= image_tag url_for(blob.variant(variant)), width: width, height: height %> + <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> + <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> - <%= image_tag url_for(blob.preview(variant)), width: width, height: height %> + <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %> <% else %> <%= blob.filename.extension&.downcase.presence || "unknown" %> <% end %> diff --git a/app/views/events/event/attachments/_attachment.html.erb b/app/views/events/event/attachments/_attachment.html.erb index c1eb254d0..fdb3b871b 100644 --- a/app/views/events/event/attachments/_attachment.html.erb +++ b/app/views/events/event/attachments/_attachment.html.erb @@ -3,9 +3,9 @@ <% height = attachment.metadata["height"] %> <% if attachment.previewable? %> - <%= image_tag url_for(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %> + <%= image_tag rails_representation_path(attachment.preview(variant)), class: "attachment attachment--image", width: width, height: height %> <% elsif attachment.variable? %> - <%= image_tag url_for(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %> + <%= image_tag rails_representation_path(attachment.variant(variant)), class: "attachment attachment--image", width: width, height: height %> <% else %>
<%= attachment.filename.extension&.downcase.presence || "unknown" %> diff --git a/app/views/my/menus/_accounts.html.erb b/app/views/my/menus/_accounts.html.erb index 2fdf9d8a5..6a25fb233 100644 --- a/app/views/my/menus/_accounts.html.erb +++ b/app/views/my/menus/_accounts.html.erb @@ -3,7 +3,7 @@ <%= collapsible_nav_section "Accounts" do %> <%# Bust cache 1 Dec 2025 %> <% accounts.each do |account| %> - <%= filter_place_menu_item landing_url(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %> + <%= filter_place_menu_item landing_path(script_name: account.slug), account.name, "marker", current: account == Current.account, turbo: false %> <% end %> <% end %> <% end %> diff --git a/app/views/my/menus/_settings.html.erb b/app/views/my/menus/_settings.html.erb index 150b6794a..d1251b7c0 100644 --- a/app/views/my/menus/_settings.html.erb +++ b/app/views/my/menus/_settings.html.erb @@ -6,7 +6,7 @@ <%= tag.li class: "popup__item", data: { filter_target: "item", navigable_list_target: "item" } do %> <%= icon_tag "logout", class: "popup__icon" %> - <%= button_to session_url(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "popup__btn btn", data: { turbo: false } do %> Sign out <% end %> <% end %> diff --git a/app/views/prompts/cards/_card.html.erb b/app/views/prompts/cards/_card.html.erb index 2308bc0c7..1dd4494c4 100644 --- a/app/views/prompts/cards/_card.html.erb +++ b/app/views/prompts/cards/_card.html.erb @@ -3,6 +3,6 @@ #<%= card.number %> <%= card.title %> diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 9c6867740..09383b72a 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -13,7 +13,7 @@ <% @accounts.each do |account| %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7cf5bdf7c..771a8e924 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> Sign out of Fizzy on this device <% end %> <% end %> diff --git a/app/views/webhooks/event.html.erb b/app/views/webhooks/event.html.erb index f2920e8d0..7766020a1 100644 --- a/app/views/webhooks/event.html.erb +++ b/app/views/webhooks/event.html.erb @@ -1,4 +1,4 @@ <%= @event.description_for(Current.user).to_plain_text %> <% if @event.eventable %> -<%= link_to "↗︎", polymorphic_url(@event.eventable) %> +<%= link_to "↗︎", polymorphic_url(@event.eventable, only_path: true) %> <% end %> From bf6fe75b7c5a2098b591e8563aa36c5ebd637880 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Mon, 12 Jan 2026 19:49:06 +0100 Subject: [PATCH 3/4] Add a script to migrate existing rich texts to relative URLs --- ...rt-absolute-attachment-urls-to-relative.rb | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 script/migrations/convert-absolute-attachment-urls-to-relative.rb 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 From bf224b19f52185f28fad9732a7e8838e13f19a64 Mon Sep 17 00:00:00 2001 From: Rosa Gutierrez Date: Wed, 14 Jan 2026 10:47:52 +0100 Subject: [PATCH 4/4] Add rollback script to convert relative URLs back to absolute --- ...rt-relative-attachment-urls-to-absolute.rb | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 script/migrations/convert-relative-attachment-urls-to-absolute.rb diff --git a/script/migrations/convert-relative-attachment-urls-to-absolute.rb b/script/migrations/convert-relative-attachment-urls-to-absolute.rb new file mode 100644 index 000000000..4b201f34f --- /dev/null +++ b/script/migrations/convert-relative-attachment-urls-to-absolute.rb @@ -0,0 +1,127 @@ +#!/usr/bin/env ruby + +# Rollback script: Convert relative attachment URLs back to absolute URLs. +# Use this if you need to rollback the relative URL changes and want to +# convert rich texts created during the rollout back to absolute URLs. +# +# Run locally: +# bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help" + +class ConvertRelativeAttachmentUrlsToAbsolute + # Match relative URLs pointing to Active Storage routes (with account slug) + RELATIVE_URL_PATTERN = %r{\A(/\d+/rails/active_storage/[^"']+)\z} + + attr_reader :host, :since + + def initialize(host:, since:) + @host = host + @since = since + end + + def run + puts "Converting relative attachment URLs to absolute URLs" + puts "Host: #{host}" + puts "Processing rich texts created since: #{since}" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + 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?(RELATIVE_URL_PATTERN) + node["url"] = "#{host}#{url}" + 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)" + + rich_text.update! body: body.fragment.to_html + + 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 + ActionText::RichText.joins(:embeds_attachments).where("action_text_rich_texts.created_at >= ?", since) + end +end + +require "optparse" +require "time" + +options = {} + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("--host HOST", "Host to prepend (e.g., https://app.fizzy.do)") do |host| + options[:host] = host + end + + opts.on("--since TIME", "Process rich texts created since this time (ISO 8601 format)") do |time| + options[:since] = Time.parse(time) + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +if options[:host].nil? || options[:since].nil? + puts "Error: --host and --since are required" + puts "Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z" + exit 1 +end + +ConvertRelativeAttachmentUrlsToAbsolute.new(**options).run