Rails 3.2 f.file_field causes routing error

2019-05-11 07:48发布

问题:

Tested on rails 3.2.12 and 3.2.11. In another rails 3.2.11 project I do not have this issue with f.file_field, but in current one I do and can't find a reason for this strange behaviour, so here is my question.

I have a weird problem with update action. Here are relevant parts of code:

routes:

get "signup" => "users#new", :as => "signup"
get "profile" => "users#profile", :as => "profile"
resources :users do
  member do
    get :activate
  end
end

controller:

def update
  @user = User.find(params[:id])
  if @user.update_attributes(params[:user])
    redirect_to user_path(@user), :notice => t('users_controller.update.updated')
  else
    render :edit
  end
end

form in haml (simplified but it has the same behaviour):

= form_for @user do |f|
  .field
    = f.label :first_name 
    %br
    = f.text_field :first_name, :size => 40
  .actions
    = f.submit

So, after I press Update everything works as expected and user's attributes are being updated. However when I add a file field like this:

= form_for @user do |f|
  .field
    = f.label :first_name 
    %br
    = f.text_field :first_name, :size => 40
  .field
    = f.label :avatar
    %br
    = f.file_field :avatar
  .actions
    = f.submit

and I press Update then I get routing error:

No route matches [PUT] "/1"

I don't understand why it tries to reach /1 path with PUT method. On the page showing that routing error I can see /users/1 in the browser's address bar.

here is generated html for the form:

  <form accept-charset="UTF-8" action="/users/1" class="edit_user" enctype="multipart/form-data" id="edit_user_1" method="post"><div style="margin:0;padding:0;display:inline"><input name="utf8" type="hidden" value="&#x2713;" /><input name="_method" type="hidden" value="put" /><input name="authenticity_token" type="hidden" value="che8VLfDxDAoenma+TXwsA+0IQ7+/jbCIK+Q2xwr8uc=" /></div>
    <div class='field'>
      <label for="user_first_name">First name</label>
      <br>
      <input id="user_first_name" name="user[first_name]" size="40" type="text" value="Anton" />
    </div>
    <div class='field'>
      <label for="user_avatar">Avatar</label>
      <br>
      <input id="user_avatar" name="user[avatar]" type="file" />
    </div>
    <div class='actions'>
      <input name="commit" type="submit" value="Update User" />
    </div>
  </form>

So, here comes the most interesting thing. When I change my form to this:

= form_for @user do |f|
  .field
    = f.label :first_name 
    %br
    = f.text_field :first_name, :size => 40
  .field
    = f.label :avatar
    %br
    %input{:id => "user_avatar", :name => "user[avatar]", :type => "file"}
  .actions
    = f.submit

then generated html is right the same as in previous case (the only difference I can see is that for the file field attributes single quotes instead of double quotes are used):

  <form accept-charset="UTF-8" action="/users/1" class="edit_user" id="edit_user_1" method="post"><div style="margin:0;padding:0;display:inline"><input name="utf8" type="hidden" value="&#x2713;" /><input name="_method" type="hidden" value="put" /><input name="authenticity_token" type="hidden" value="che8VLfDxDAoenma+TXwsA+0IQ7+/jbCIK+Q2xwr8uc=" /></div>
    <div class='field'>
      <label for="user_first_name">First name</label>
      <br>
      <input id="user_first_name" name="user[first_name]" size="40" type="text" value="Anton" />
    </div>
    <div class='field'>
      <label for="user_avatar">Avatar</label>
      <br>
      <input id='user_avatar' name='user[avatar]' type='file'>
    </div>
    <div class='actions'>
      <input name="commit" type="submit" value="Update User" />
    </div>
  </form>

But after submitting this form there is no routing error and everything works as it should.

UPDATE

Actually it doesn't work as it should to. I just looked at the params hash and saw that :avatar key is present, but I missed that in the latter case there is no enctype="multipart/form-data" attribute in the form open tag in html, so file wouldn't be uploaded. Adding enctype=multipart/form-data attribute causes routing error to happen again.

I've found that with put ":id" => "users#update" route added when it tries to redirect_to user_path(@user) after submitting multipart form (for sure with this route there is no routing error for PUT), then there is also routing error No route matches [GET] "/users/users/1".

Here is full routes.rb:

Myapp::Application.routes.draw do

  match "oauth/callback" => "oauths#callback"
  match "oauth/callback/:provider" => "oauths#callback"
  match "oauth/:provider" => "oauths#oauth", :as => :auth_at_provider

  resources :countries
  resources :categories
  resources :images
  resources :collections
  resources :items

  put ":id" => "users#update"
  get "signup" => "users#new", :as => "signup"
  get "profile" => "users#profile", :as => "profile"
  resources :users do
    member do
      get :activate
    end
  end

  get "signout" => "sessions#destroy", :as => "signout"
  get "signin" => "sessions#new", :as => "signin"
  resources :sessions

  get "site/index"

  root :to => "site#index"

end

and rake routes

  oauth_callback        /oauth/callback(.:format)           oauths#callback
                        /oauth/callback/:provider(.:format) oauths#callback
auth_at_provider        /oauth/:provider(.:format)          oauths#oauth
       countries GET    /countries(.:format)                countries#index
                 POST   /countries(.:format)                countries#create
     new_country GET    /countries/new(.:format)            countries#new
    edit_country GET    /countries/:id/edit(.:format)       countries#edit
         country GET    /countries/:id(.:format)            countries#show
                 PUT    /countries/:id(.:format)            countries#update
                 DELETE /countries/:id(.:format)            countries#destroy
      categories GET    /categories(.:format)               categories#index
                 POST   /categories(.:format)               categories#create
    new_category GET    /categories/new(.:format)           categories#new
   edit_category GET    /categories/:id/edit(.:format)      categories#edit
        category GET    /categories/:id(.:format)           categories#show
                 PUT    /categories/:id(.:format)           categories#update
                 DELETE /categories/:id(.:format)           categories#destroy
          images GET    /images(.:format)                   images#index
                 POST   /images(.:format)                   images#create
       new_image GET    /images/new(.:format)               images#new
      edit_image GET    /images/:id/edit(.:format)          images#edit
           image GET    /images/:id(.:format)               images#show
                 PUT    /images/:id(.:format)               images#update
                 DELETE /images/:id(.:format)               images#destroy
     collections GET    /collections(.:format)              collections#index
                 POST   /collections(.:format)              collections#create
  new_collection GET    /collections/new(.:format)          collections#new
 edit_collection GET    /collections/:id/edit(.:format)     collections#edit
      collection GET    /collections/:id(.:format)          collections#show
                 PUT    /collections/:id(.:format)          collections#update
                 DELETE /collections/:id(.:format)          collections#destroy
           items GET    /items(.:format)                    items#index
                 POST   /items(.:format)                    items#create
        new_item GET    /items/new(.:format)                items#new
       edit_item GET    /items/:id/edit(.:format)           items#edit
            item GET    /items/:id(.:format)                items#show
                 PUT    /items/:id(.:format)                items#update
                 DELETE /items/:id(.:format)                items#destroy
                 PUT    /:id(.:format)                      users#update
          signup GET    /signup(.:format)                   users#new
         profile GET    /profile(.:format)                  users#profile
   activate_user GET    /users/:id/activate(.:format)       users#activate
           users GET    /users(.:format)                    users#index
                 POST   /users(.:format)                    users#create
        new_user GET    /users/new(.:format)                users#new
       edit_user GET    /users/:id/edit(.:format)           users#edit
            user GET    /users/:id(.:format)                users#show
                 PUT    /users/:id(.:format)                users#update
                 DELETE /users/:id(.:format)                users#destroy
         signout GET    /signout(.:format)                  sessions#destroy
          signin GET    /signin(.:format)                   sessions#new
        sessions GET    /sessions(.:format)                 sessions#index
                 POST   /sessions(.:format)                 sessions#create
     new_session GET    /sessions/new(.:format)             sessions#new
    edit_session GET    /sessions/:id/edit(.:format)        sessions#edit
         session GET    /sessions/:id(.:format)             sessions#show
                 PUT    /sessions/:id(.:format)             sessions#update
                 DELETE /sessions/:id(.:format)             sessions#destroy
      site_index GET    /site/index(.:format)               site#index
            root        /

Anybody have any idea?

UPDATE2

Revealing the problem is with multipart forms helped to find this post about the same problem - Routing Error with Post/Put requests (Passenger Headers) but unfortunately there's no solution...

UPDATE3

I've found something interesting. There is a method in /path_to_gemset_here/gem/journey-1.0.4/lib/journey/router.rb:

def find_routes env
  req = request_class.new env

  routes = filter_routes(req.path_info) + custom_routes.find_all { |r|
    r.path.match(req.path_info)
  }

  routes.sort_by(&:precedence).find_all { |r|
    r.constraints.all? { |k,v| v === req.send(k) } &&
      r.verb === req.request_method
  }.reject { |r| req.ip && !(r.ip === req.ip) }.map { |r|
    match_data  = r.path.match(req.path_info)
    match_names = match_data.names.map { |n| n.to_sym }
    match_values = match_data.captures.map { |v| v && Utils.unescape_uri(v) }
    info = Hash[match_names.zip(match_values).find_all { |_,y| y }]

    [match_data, r.defaults.merge(info), r]
  }
end

I checked env for both non-multipart and multipart requests and found this:

non-multipart:

"REQUEST_URI"=>"/users/1",
"SCRIPT_NAME"=>"",
"PATH_INFO"=>"/users/1"

multipart:

"REQUEST_URI"=>"/users/1",
"SCRIPT_NAME"=>"/users",
"PATH_INFO"=>"/1",
"SCRIPT_FILENAME"=>"/path_to_project_folder_here/public/users", - there is no such variable in a non-multipart request

So here is the problem. As I can see in the method's definition:

match_data  = r.path.match(req.path_info)

PATH_INFO is used to find a route to handle request, but in the latter case it is completely wrong due to something divides REQUEST_URI into two parts. Unfortunately currently I have no time to finish my investigation today, hope I'll be able to do it tomorrow.

If anyone will have enough curiosity to find an origin of the problem faster than me - you are welcome :)

UPDATE4 (edited)

So, here is a continuation of the investigation.

method: parse_native_request in file: /path_to_gemset_here/gems/passenger-3.0.17/lib/phusion_passenger/abstract_request_handler.rb

variable headers_data after this call:

headers_data = channel.read_scalar(buffer, MAX_HEADER_SIZE)

contains:

"SERVER_SOFTWARE\x00Apache/2.2.22 (Ubuntu)\x00
SERVER_PROTOCOL\x00HTTP/1.1\x00
SERVER_NAME\x00myapp.loc\x00
SERVER_ADMIN\x00[no address given]\x00
SERVER_ADDR\x00127.0.0.1\x00
SERVER_PORT\x0080\x00
REMOTE_ADDR\x00127.0.0.1\x00
REMOTE_PORT\x0033199\x00
REQUEST_METHOD\x00POST\x00
QUERY_STRING\x00\x00
CONTENT_TYPE\x00multipart/form-data; boundary=----WebKitFormBoundary8HlzQxocoOROMfRV\x00
DOCUMENT_ROOT\x00/path_to_project_folder_here/public\x00
REQUEST_URI\x00/users/1\x00
SCRIPT_NAME\x00\x00
PATH_INFO\x00/users/1\x00
HTTP_HOST\x00myapp.loc\x00
HTTP_CONNECTION\x00keep-alive\x00
HTTP_CONTENT_LENGTH\x00748\x00
HTTP_CACHE_CONTROL\x00max-age=0\x00
HTTP_ACCEPT\x00text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\x00
HTTP_ORIGIN\x00http://myapp.loc\x00
HTTP_USER_AGENT\x00Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.97 Safari/537.22\x00
HTTP_CONTENT_TYPE\x00multipart/form-data; boundary=----WebKitFormBoundary8HlzQxocoOROMfRV\x00
HTTP_REFERER\x00http://myapp.loc/profile\x00
HTTP_ACCEPT_ENCODING\x00gzip,deflate,sdch\x00
HTTP_ACCEPT_LANGUAGE\x00en-US,en;q=0.8\x00
HTTP_ACCEPT_CHARSET\x00ISO-8859-1,utf-8;q=0.7,*;q=0.3\x00
HTTP_COOKIE\x00_myapp_session=BAh7CEkiDHVzZXJfaWQGOgZFRmkGSSIPc2Vzc2lvbl9pZAY7AEZJIiVhMjU2ZjU5N2VmMTE0YTJiOGEwNGJiYzUyYjM2NDg0OQY7AFRJIhBfY3NyZl90b2tlbgY7AEZJIjFjaGU4VkxmRHhEQW9lbm1hK1RYd3NBKzBJUTcrL2piQ0lLK1EyeHdyOHVjPQY7AEY%3D--a6e5daff1334c083e54b2bcafba43b32e546af9c\x00
UNIQUE_ID\x00UTXfEX8AAQEAACWVEMoAAAAB\x00
GATEWAY_INTERFACE\x00CGI/1.1\x00

>>>> here seems to start a kind of redirect <<<<

SERVER_PROTOCOL\x00HTTP/1.1\x00
REQUEST_METHOD\x00POST\x00
QUERY_STRING\x00\x00
REQUEST_URI\x00/users/1\x00
SCRIPT_NAME\x00/users\x00
PATH_INFO\x00/1\x00
PATH_TRANSLATED\x00/path_to_project_folder_here/public/1\x00
HTTP_HOST\x00myapp.loc\x00
HTTP_CONNECTION\x00keep-alive\x00
CONTENT_LENGTH\x00748\x00HTTP_CACHE_CONTROL\x00max-age=0\x00
HTTP_ACCEPT\x00text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\x00
HTTP_ORIGIN\x00http://myapp.loc\x00
HTTP_USER_AGENT\x00Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.97 Safari/537.22\x00CONTENT_TYPE\x00multipart/form-data; boundary=----WebKitFormBoundary8HlzQxocoOROMfRV\x00
HTTP_REFERER\x00http://myapp.loc/profile\x00
HTTP_ACCEPT_ENCODING\x00gzip,deflate,sdch\x00
HTTP_ACCEPT_LANGUAGE\x00en-US,en;q=0.8\x00
HTTP_ACCEPT_CHARSET\x00ISO-8859-1,utf-8;q=0.7,*;q=0.3\x00
HTTP_COOKIE\x00_myapp_session=BAh7CEkiDHVzZXJfaWQGOgZFRmkGSSIPc2Vzc2lvbl9pZAY7AEZJIiVhMjU2ZjU5N2VmMTE0YTJiOGEwNGJiYzUyYjM2NDg0OQY7AFRJIhBfY3NyZl90b2tlbgY7AEZJIjFjaGU4VkxmRHhEQW9lbm1hK1RYd3NBKzBJUTcrL2piQ0lLK1EyeHdyOHVjPQY7AEY%3D--a6e5daff1334c083e54b2bcafba43b32e546af9c\x00
PATH\x00/usr/local/bin:/usr/bin:/bin\x00
SERVER_SIGNATURE\x00<address>Apache/2.2.22 (Ubuntu) Server at myapp.loc Port 80</address>\n\x00
SERVER_SOFTWARE\x00Apache/2.2.22 (Ubuntu)\x00
SERVER_NAME\x00myapp.loc\x00
SERVER_ADDR\x00127.0.0.1\x00
SERVER_PORT\x0080\x00
REMOTE_ADDR\x00127.0.0.1\x00
DOCUMENT_ROOT\x00/path_to_project_folder_here/public\x00
SERVER_ADMIN\x00[no address given]\x00
SCRIPT_FILENAME\x00/path_to_project_folder_here/public/users\x00
REMOTE_PORT\x0033199\x00
PATH_TRANSLATED\x00/bin/runAV\x00
REDIRECT_STATUS\x00302\x00
PASSENGER_CONNECT_PASSWORD\x00EElt7wIBLlliWGCYJJoezPvecsB2brraBWdiIbD4nul\x00_\x00_\x00"

After that follows this call:

headers = split_by_null_into_hash(headers_data)

and headers contains:

{"SERVER_SOFTWARE"=>"Apache/2.2.22 (Ubuntu)", 
"SERVER_PROTOCOL"=>"HTTP/1.1", 
"SERVER_NAME"=>"myapp.loc", 
"SERVER_ADMIN"=>"[no address given]", 
"SERVER_ADDR"=>"127.0.0.1", 
"SERVER_PORT"=>"80", 
"REMOTE_ADDR"=>"127.0.0.1", 
"REMOTE_PORT"=>"33243", 
"REQUEST_METHOD"=>"POST", 
"QUERY_STRING"=>"", 
"CONTENT_TYPE"=>"multipart/form-data; boundary=----WebKitFormBoundary8HlzQxocoOROMfRV", 
"DOCUMENT_ROOT"=>"/path_to_project_folder_here/public", 
"REQUEST_URI"=>"/users/1", 
"SCRIPT_NAME"=>"/users", 
"PATH_INFO"=>"/1", 
"HTTP_HOST"=>"myapp.loc", 
"HTTP_CONNECTION"=>"keep-alive", 
"HTTP_CONTENT_LENGTH"=>"748", 
"HTTP_CACHE_CONTROL"=>"max-age=0", 
"HTTP_ACCEPT"=>"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 
"HTTP_ORIGIN"=>"http://myapp.loc", 
"HTTP_USER_AGENT"=>"Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.97 Safari/537.22", 
"HTTP_CONTENT_TYPE"=>"multipart/form-data; boundary=----WebKitFormBoundary8HlzQxocoOROMfRV", 
"HTTP_REFERER"=>"http://myapp.loc/profile", 
"HTTP_ACCEPT_ENCODING"=>"gzip,deflate,sdch", 
"HTTP_ACCEPT_LANGUAGE"=>"en-US,en;q=0.8", 
"HTTP_ACCEPT_CHARSET"=>"ISO-8859-1,utf-8;q=0.7,*;q=0.3", 
"HTTP_COOKIE"=>"_myapp_session=BAh7CEkiDHVzZXJfaWQGOgZFRmkGSSIPc2Vzc2lvbl9pZAY7AEZJIiVhMjU2ZjU5N2VmMTE0YTJiOGEwNGJiYzUyYjM2NDg0OQY7AFRJIhBfY3NyZl90b2tlbgY7AEZJIjFjaGU4VkxmRHhEQW9lbm1hK1RYd3NBKzBJUTcrL2piQ0lLK1EyeHdyOHVjPQY7AEY%3D--a6e5daff1334c083e54b2bcafba43b32e546af9c", 
"UNIQUE_ID"=>"UTXjXn8AAQEAACceEdgAAAAA", 
"GATEWAY_INTERFACE"=>"CGI/1.1", 
"PATH_TRANSLATED"=>"/bin/runAV", 
"CONTENT_LENGTH"=>"748", 
"PATH"=>"/usr/local/bin:/usr/bin:/bin", 
"SERVER_SIGNATURE"=>"<address>Apache/2.2.22 (Ubuntu) Server at myapp.loc Port 80</address>\n", 
"SCRIPT_FILENAME"=>"/path_to_project_folder_here/public/users", 
"REDIRECT_STATUS"=>"302", 
"PASSENGER_CONNECT_PASSWORD"=>"GgEqWssAcbBETWnFI7xzBfWRGibgB34OhfFSUVyOhPn", "_"=>"_"}

So the problem is obviously in the way in which headers are being packed into hash - there are two values for PATH_INFO (and for other headers too) and the latter one (incorrect) rewrites the first one (indeed the problem is in the reason why these headers are being sent but I don't know how to handle this). Packing into hash is happening in split_by_null_into_hash(headers_data) method. Now going there.

file: /path_to_gemset_here/gems/passenger-3.0.17/lib/phusion_passenger/utils.rb

Module Utils contains this code:

if defined?(PhusionPassenger::NativeSupport)
# Split the given string into an hash. Keys and values are obtained by splitting the
  # string using the null character as the delimitor.
  def split_by_null_into_hash(data)
    return PhusionPassenger::NativeSupport.split_by_null_into_hash(data)
  end
else
  NULL = "\0".freeze

  def split_by_null_into_hash(data)
    args = data.split(NULL, -1)
    args.pop
    return Hash[*args]
  end
end

In my case the if-part of condition is being executed, so now the problem goes to

PhusionPassenger::NativeSupport.split_by_null_into_hash(data)

and that seems to take us to file: /path_to_gemset_here/gems/passenger-3.0.17/ext/ruby/passenger_native_support.c

to be continued...

UPDATE5

Actually I decided not to deal with that C-file debugging hell as I believe that this file is being compiled during passenger installation and to debug it I would need to reinstall and reinstall passenger again and again. So I decided to switch to using the else-part of the condition as it seems to achieve exactly the same goal, but obviously a bit slower than precompiled C-code. But in my case it doesn't really matter. So I overrode method's definition by including a file to /path_to_project_folder_here/lib folder with this code:

module PhusionPassenger
  module Utils

    protected

    NULL = "\0".freeze

    def split_by_null_into_hash(data)
      args = data.split(NULL, -1)
      args.pop
      return Hash[*args]
    end

  end
end

I can't change Hash[*args] behaviour (more precisely saying I can by overriding ::[] method but I don't want to for sure) so I'll change the code a bit:

module PhusionPassenger
  module Utils

    protected

    NULL = "\0".freeze

    def split_by_null_into_hash(data)
      args = data.split(NULL, -1)
      args.pop
      headers_hash = Hash.new
      args.each_slice(2).to_a.each do |pair|
        headers_hash[pair.first] = pair.last unless headers_hash.keys.include? pair.first
      end
      return headers_hash
    end

  end
end

And Bingo! Now it works.

However I'm not sure that I didn't break any other functionality by doing this so I can't advice to anyone to use this approach. I'll be using it until I face any problem related to this modification. If it will be the case then I'll try to find another way to solve the problem.

And main question still remains about why those wrong headers are being sent.

回答1:

Create passenger_extension.rb in the lib folder with this code:

Passenger 3

module PhusionPassenger
  module Utils

    protected

    NULL = "\0".freeze

    def split_by_null_into_hash(data)
      args = data.split(NULL, -1)
      args.pop
      headers_hash = Hash.new
      args.each_slice(2).to_a.each do |pair|
        headers_hash[pair.first] = pair.last unless headers_hash.keys.include? pair.first
      end
      return headers_hash
    end

  end
end

Passenger 5

module PhusionPassenger
  module Utils

    # Utility functions that can potentially be accelerated by native_support functions.
    module NativeSupportUtils
      extend self

      NULL = "\0".freeze

      class ProcessTimes < Struct.new(:utime, :stime)
      end

      def split_by_null_into_hash(data)
        args = data.split(NULL, -1)
        args.pop
        headers_hash = Hash.new
        args.each_slice(2).to_a.each do |pair|
          headers_hash[pair.first] = pair.last unless headers_hash.keys.include? pair.first
        end
        return headers_hash
      end

      def process_times
        times = Process.times
        return ProcessTimes.new((times.utime * 1_000_000).to_i,
          (times.stime * 1_000_000).to_i)
      end
    end

  end # module Utils
end # module PhusionPassenger

And then in 'config/application.rb' do:

class Application < Rails::Application
  ...
  config.autoload_paths += %W(#{config.root}/lib)
  require 'passenger_extension'
end

And then restart a webserver.

NOTICE: I'm not sure that this doesn't break any other functionality so use it on your own risk and please let me know if you find any harm from this approach.