restful_authenticationでユーザー別のTodoリストを管理する

前回からの続き。現状のTodoでは、ログインしたユーザーは共通のTodoを利用する仕様になってしまっている。これをユーザー別のTodoに変更したい。restful_authenticationは、そのユーザーが認証された状態かどうかをチェックして、そのユーザーが誰であるかをcurrent_userで確認できるようにしてくれている。あとはTodoをユーザー別に管理できるように対応すれば良さそう。

ユーザー別の管理

  • まず思いついたのが、モデルにhas_manyとbelongs_toを設定すること。
# ---------- app/models/user.rb ----------
require 'digest/sha1'
class User < ActiveRecord::Base
  has_many :todos
...(中略)...
# ---------- app/models/todo.rb ----------
class Todo < ActiveRecord::Base
  belongs_to :user
end
  • todosテーブルには、関連先を保持するフィールドuser_idも必要になる。
# ---------- app/models/20080726081256_create_todos.rb ----------
class CreateTodos < ActiveRecord::Migration
  def self.up
    create_table :todos do |t|
      t.string  :body
      t.date    :due
      t.boolean :done
      t.integer :user_id

      t.timestamps
    end
  end
...(中略)...
  • マイグレーションのリセットを実行して、設定をデータベースに反映させた。(リセットしたので保存されているデータはすべて破棄される)
$ rake db:migrate:reset
  • ルート設定もhas_manyで設定した。
# ---------- config/routes.rb ----------
ActionController::Routing::Routes.draw do |map|
  map.resources :users, 
                :new=>{:input_email_address=>:get, :deactivate=>:put}, 
                :has_many=>:todos

  map.resource :session

  #map.resources :todos

  map.activate '/activate/:activation_code', :controller => 'users', :action => 'activate'
...(中略)...
  • これでtodoに関するルートは以下のように変更された。
$ rake routes
...(中略)...
                            user_todos GET    /users/:user_id/todos                  {:controller=>"todos", :action=>"index"}
                  formatted_user_todos GET    /users/:user_id/todos.:format          {:controller=>"todos", :action=>"index"}
                                       POST   /users/:user_id/todos                  {:controller=>"todos", :action=>"create"}
                                       POST   /users/:user_id/todos.:format          {:controller=>"todos", :action=>"create"}
                         new_user_todo GET    /users/:user_id/todos/new              {:controller=>"todos", :action=>"new"}
               formatted_new_user_todo GET    /users/:user_id/todos/new.:format      {:controller=>"todos", :action=>"new"}
                        edit_user_todo GET    /users/:user_id/todos/:id/edit         {:controller=>"todos", :action=>"edit"}
              formatted_edit_user_todo GET    /users/:user_id/todos/:id/edit.:format {:controller=>"todos", :action=>"edit"}
                             user_todo GET    /users/:user_id/todos/:id              {:controller=>"todos", :action=>"show"}
                   formatted_user_todo GET    /users/:user_id/todos/:id.:format      {:controller=>"todos", :action=>"show"}
                                       PUT    /users/:user_id/todos/:id              {:controller=>"todos", :action=>"update"}
                                       PUT    /users/:user_id/todos/:id.:format      {:controller=>"todos", :action=>"update"}
                                       DELETE /users/:user_id/todos/:id              {:controller=>"todos", :action=>"destroy"}
                                       DELETE /users/:user_id/todos/:id.:format      {:controller=>"todos", :action=>"destroy"}

...(中略)...
  • 上記ルートに合わせて、コントローラーとビューを修正した。ひたすら、機械的に。
# ---------- app/controllers/todos_controller.rb ----------
class TodosController < ApplicationController
  before_filter :login_required

  # GET /todos
  # GET /todos.xml
  def index
    @todos = current_user.todos

    respond_to do |format|
      format.html # index.html.erb
      format.xml  { render :xml => @todos }
    end
  end

  # GET /todos/1
  # GET /todos/1.xml
  def show
    @todo = current_user.todos.find(params[:id])

    respond_to do |format|
      format.html # show.html.erb
      format.xml  { render :xml => @todo }
    end
  end

  # GET /todos/new
  # GET /todos/new.xml
  def new
    @todo = Todo.new

    respond_to do |format|
      format.html # new.html.erb
      format.xml  { render :xml => @todo }
    end
  end

  # GET /todos/1/edit
  def edit
    @todo = current_user.todos.find(params[:id])
  end

  # POST /todos
  # POST /todos.xml
  def create
    @todo = Todo.new(params[:todo])

    respond_to do |format|
      if current_user.todos << @todo
        flash[:notice] = 'Todo was successfully created.'
        format.html { redirect_to([current_user, @todo]) }
        format.xml  { render :xml => @todo, :status => :created, :location => [current_user, @todo] }
      else
        format.html { render :action => "new" }
        format.xml  { render :xml => @todo.errors, :status => :unprocessable_entity }
      end
    end
  end

  # PUT /todos/1
  # PUT /todos/1.xml
  def update
    @todo = current_user.todos.find(params[:id])

    respond_to do |format|
      if @todo.update_attributes(params[:todo].merge(:user_id=>current_user.id))
        flash[:notice] = 'Todo was successfully updated.'
        format.html { redirect_to([current_user, @todo]) }
        format.xml  { head :ok }
      else
        format.html { render :action => "edit" }
        format.xml  { render :xml => @todo.errors, :status => :unprocessable_entity }
      end
    end
  end

  # DELETE /todos/1
  # DELETE /todos/1.xml
  def destroy
    @todo = current_user.todos.find(params[:id])
    @todo.destroy

    respond_to do |format|
      format.html { redirect_to(user_todos_url) }
      format.xml  { head :ok }
    end
  end
end
<%# ---------- app/views/welcome/index.html.erb ---------- %>
<p><%= current_user.login %> さん、Todoへようこそ!</p>

<%= link_to 'Todoリストへ', user_todos_path(current_user) %>
<%# ---------- app/views/todos/index.html.erb ---------- %>
<h1>Listing todos</h1>

<table>
  <tr>
    <th>Body</th>
    <th>Due</th>
    <th>Done</th>
  </tr>

<% for todo in @todos %>
  <tr>
    <td><%=h todo.body %></td>
    <td><%=h todo.due %></td>
    <td><%=h todo.done %></td>
    <td><%= link_to 'Show', [current_user, todo] %></td>
    <td><%= link_to 'Edit', edit_user_todo_path(current_user, todo) %></td>
    <td><%= link_to 'Destroy', [current_user, todo], :confirm => 'Are you sure?', :method => :delete %></td>
  </tr>
<% end %>
</table>

<br />

<%= link_to 'New todo', new_user_todo_path %>
<%# ---------- app/views/todos/new.html.erb ---------- %>
<h1>New todo</h1>

<% form_for([current_user, @todo]) do |f| %>
  <%= render :partial=>f %>
  <p>
    <%= f.submit "Create" %>
  </p>
<% end %>

<%= link_to 'Back', user_todos_path %>
<%# ---------- app/views/todos/edit.html.erb ---------- %>
<h1>Editing todo</h1>

<% form_for([current_user, @todo]) do |f| %>
  <%= render :partial=>f %>
  <p>
    <%= f.submit "Update" %>
  </p>
<% end %>

<%= link_to 'Show', [current_user, @todo] %> |
<%= link_to 'Back', user_todos_path %>
<%# ---------- app/views/todos/show.html.erb ---------- %>
<p>
  <b>Body:</b>
  <%=h @todo.body %>
</p>

<p>
  <b>Due:</b>
  <%=h @todo.due %>
</p>

<p>
  <b>Done:</b>
  <%=h @todo.done %>
</p>


<%= link_to 'Edit', edit_user_todo_path(current_user, @todo) %> |
<%= link_to 'Back', user_todos_path %>

動作確認

以上で、ひとまず修正完了。ユーザー登録をして、Todoにアクセスしてみる。(zariganiさんと、yamadaさんの二人を登録して試してみた。)

  • zariganiさんでログインして、Todoを一つ登録した。


  • 今度はyamadaさんでログインして、Todoのindexページを開いても、zariganiさんのTodoは見えない。


問題発生

一見、うまく動いているように見えるけど、ユーザーは何をするか分からない。ありそうなこととして...

  • zariganiさんがログインして、yamadaさんのTodoへアクセスしようとする可能性もある。
  • あり得ないuser_idを入力する可能性もある。

現状では上記のような場合でも、URLは無視して、常にログインしているユーザーのTodoに限定して表示する。これではユーザーに誤解を与える可能性がある。

  • 例えばyamadaさんがログインしている場合、以下のURLを入力してもyamadaさんのTodoのみが表示される結果となる。
http://localhost:3000/users/100/todos
  • しかし、yamadaさんは、まるでuser_idが100のユーザーのTodoを表示していると錯覚してしまうかもしれない。(URLと表示される結果が連動していない...。)

警告する

ログインしているユーザーと、URLのuser_idが違っている場合は、user_todos_path(current_user)にリダイレクトして、警告するようにしてみた。

  • restful_authenticationのbefore_filter :login_requiredは、ログインを要求するだけで、ユーザーが誰であるかのチェックはない。
  • user_checkedメソッドを追加して、ログインしているユーザーと、URLのアクセス先のユーザーが同じかどうか確認するようにしてみた。
# ---------- app/controllers/todos_controller.rb ----------
class TodosController < ApplicationController
  before_filter :login_required
  before_filter :user_checked
...(中略)...

private

  def user_checked
    unless params[:user_id].to_i == current_user.id
      redirect_to user_todos_path(current_user)
      flash[:alert] = "access to '#{request.path}' failed"
    end
  end
end


これで、ユーザー別のTodoになった!