rails: `ActiveStorage::Current.host` not set causing disk service to throw `URI::InvalidURIError`

Steps to reproduce

config/storage.yml:

test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
  public: true

config/environments/development.rb:

  # Store uploaded files on the local file system (see config/storage.yml for options).
  config.active_storage.service = :local

app/models/user.rb:

class User < ApplicationRecord
  validates :username, presence: true

  has_one_attached :avatar

Expected behavior

In rails console:

irb> ActiveStorage::Current.host
=> nil

irb> User.find_by_username('david').avatar.url
=> [should generate permanent url]

Actual behavior

In rails console:

irb> ActiveStorage::Current.host
=> nil

irb> User.find_by_username('david').avatar.url
URI::InvalidURIError (bad URI(is not URI?): nil)

System configuration

Rails version:

Rails 6.1.0

Ruby version:

ruby 2.7.2p137 (2020-10-01 revision 5445e04352) [x86_64-darwin19]

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Reactions: 38
  • Comments: 30 (14 by maintainers)

Most upvoted comments

There’s no documents about setting ActiveStorage::Current.host before generating public permanent url using the Disk service.

For those searching for this error from Google, note the proper usage in views is to use “url_for” such as:

url_for(profile.image)

and not

profile.image.url

Interestingly, Rails doesn’t see this in their test suite because they set this value during setup:

https://github.com/rails/rails/blob/9492339979e94570dee00d071be0ef255065837a/activestorage/test/test_helper.rb#L52-L54

For Rspec users, you can workaround this by setting it the same way in a spec helper:

RSpec.configure do |config|
  config.before(:each) do
    ActiveStorage::Current.host = "https://example.com"
  end
end

For those looking for a quick fix, I’ve been able to workaround this by monkey patching ActiveStorage::Blob#service_url

# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
module CoreExtensions
  module ActiveStorage
    module Blob
      module ServiceUrl
        def service_url(*)
          if Rails.configuration.active_storage.service == :local
            h = "http://#{Rails.application.routes.default_url_options[:host]}"
            ::ActiveStorage::Current.set(host: h) { super }
          else
            super
          end
        end
      end
    end
  end
end

# The service_url method does appear to work with the active storage "disk" service
# This monkey patch is applied in development and test only
if %w[development test].include?(Rails.env)
  ActiveSupport.on_load(:active_storage_blob) do
    prepend(CoreExtensions::ActiveStorage::Blob::ServiceUrl)
  end
end

For those using ActionController to handle requests, there is a Concern available that will set ActiveStorage::Current.host to request.base_url as a before_action:

https://github.com/rails/rails/blob/master/activestorage/app/controllers/concerns/active_storage/set_current.rb

But, this is only a partial solution as it still wouldn’t work outside of your controllers.

Since I’m using Grape for my request handling, my workaround right now is to just explicitly set ActiveStorage::Current.host before I use .url. Luckily, I only use the Disk service in testing and development.

It would be ideal if we could set this value in our config, similar to how we can set url options for ActionMailer:

config.action_mailer.default_url_options = { host: 'localhost:3000' }

Perhaps there is a better way?

Also seeing this issue. Is there any way to set this host globally e.g. via a configuration, as others have suggested? If that seems reasonable to the maintainers of Rails, I could also take a stab at implementing it myself.

2023 and this is still a problem. 😡

Presumably #39566 will resolve this by creating a system for URLs outside of the controller context. I’m not sure of a workaround for now, as we punted on our AS work once we ran into this issue.

Interestingly, Rails doesn’t see this in their test suite because they set this value during setup:

RSpec.configure do |config|I dadasdf
  config.before(:each) do
    ActiveStorage::Current.host = "https://example.com"
  end
end

^^^ This approach won’t work with my rSpec tests.

rails 6.1.3.1
Using rspec-support 3.10.2
Using rspec-core 3.10.1
Using rspec-expectations 3.10.1
Using rspec-mocks 3.10.2
Using rspec 3.10.0

I added your lines in my rails_helper.rb but it is still nil. I also placed ActiveStorage::Current.host = "http://example.com in the model itself, without success: WON’T WORK

class User
  ActiveStorage::Current.host = "http://localhost:3000"
  def main_image_thumb_url
    main_image.variant(thumb_options).url
  end
end

ONLY when I place that right before calling the .url method, then my tests pass:

WORKS

class User
  def main_image_thumb_url
    ActiveStorage::Current.host = "http://localhost:3000"
    main_image.variant(thumb_options).url
  end
end

What I’ve done now is placing include ActiveStorage::SetCurrent in my ApplicationController. But I am not sure if this how it should be…

Normally I expect that this should be set magically in the background.

After giving it some thought I think the correct solution would be just replacing ActiveStorage::Current which is a per-thread config, for a global config in the DiskService definition (it only applies here). Something like:

# config/storage.yml

local:
  service: Disk
  root: <%= Rails.root.join('storage') %>
  url_options:
    protocol: 'http'
    host: 'localhost'
    port: '3000'
# ...

thoughts?

I’ll leave here a reproduction script. @rafaelfranca If you agree this is something we should fix, I’m willing to help.

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "rails", "~> 7.0.0"
  gem "sqlite3"
end

require "active_record/railtie"
require "active_storage/engine"
require "tmpdir"

class TestApp < Rails::Application
  config.root = __dir__
  config.hosts << "example.org"
  config.eager_load = false
  config.session_store :cookie_store, key: "cookie_store_key"
  config.secret_key_base = "secret_key_base"

  config.logger = Logger.new($stdout)
  Rails.logger  = config.logger

  config.active_storage.service = :local
  config.active_storage.service_configurations = {
    local: {
      root: Dir.tmpdir,
      service: "Disk"
    }
  }

  routes.draw do
    get "/" => "test#index"
  end
end

class TestController < ActionController::Base
  include Rails.application.routes.url_helpers

  def index
    render plain: "Home"
  end
end

ENV["DATABASE_URL"] = "sqlite3::memory:"

Rails.application.initialize!

require "minitest/autorun"

class BugTest < Minitest::Test
  include Rack::Test::Methods

  def test_doesnt_clear_active_storage_current
    ActiveStorage::Current.url_options = { host: 'localhost', port: '3000' }
    get "/" 
    assert_equal({ host: 'localhost', port: '3000' }, ActiveStorage::Current.url_options)
  end

  private

  def app
    Rails.application
  end
end

I fixed my rspec by adding

include ActiveStorage::SetCurrent

to my controller. Non of the other suggesions did anything

# lib/active_storage/service/disk_with_host_service.rb

require "active_storage/service/disk_service"

class ActiveStorage::Service::DiskWithHostService < ActiveStorage::Service::DiskService
  def url_options
    Rails.application.default_url_options # or something else for greater flexibility
  end
end

This actually works and lets my RSpec test suite pass.

@rafaelfranca - this really should not be necessary. There is absolutely no documentation on this hidden “feature” in ActiveStorage. Could you please prescribe how one is supposed to set ActiveStorage::Current.url_options without resorting to the above? I’m happy to even update the Rails Guide for Active Storage or issue a Pull Request for the default Disk Service file that incorporates the above.

Note: none of the solutions below worked in my Rails 7.0.5 application. In my opinion, these solutions are better and more intuitive than creating a custom Disk Service so why wouldn’t they work in the test environment?

In test.rb environment file

  # Store uploaded files on the local file system in a temporary directory.
  config.active_storage.service = :test
  config.active_storage.url_options = { host: 'www.example.com' }

In rails_helper.rb - RSpec configuration file:

RSpec.configure do |config|
  config.before(:each) do
    ActiveStorage::Current.url_options = {host: 'https://example.com'}
  end
end

For those searching for this error from Google, note the proper usage in views is to use “url_for” such as:

url_for(profile.image)

and not

profile.image.url

This fixed it for me! Thank you so much!

Another clean solution is to implement a custom Active Storage service, extending ActiveStorage::Service::DiskService, and overriding #url_options. For instance:

# lib/active_storage/service/disk_with_host_service.rb

require "active_storage/service/disk_service"

class ActiveStorage::Service::DiskWithHostService < ActiveStorage::Service::DiskService
  def url_options
    Rails.application.default_url_options # or something else for greater flexibility
  end
end
# config/storage.yml

local:
  service: DiskWithHost
  root: <%= Rails.root.join("storage") %>

I believe it is usually okay to do this in test or development environments.

@intrip Ah yeah, I totally missed that. Good find!