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/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/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 %>
- <%= link_to "##{card.number} #{card.title}", card_url(card) %>
+ <%= link_to "##{card.number} #{card.title}", card_path(card) %>
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 %>
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
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
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