What's a good approach to clean up (DRY) this controller? - ruby-on-rails

I have an action being performed on every create, update and update_status methods on my controller, But I feel that I am repeating myself, and would really appreciate some help on a better approach to write this.
I've abstracted the update logic to a service, but the parameters are different, on every method of my controller.
def create
#story = Story.new(story_params)
#story.creator = current_user
if #story.save
next_state = StoryStateService.new(#story, current_user, nil).call
if next_state
#story.update_column(:status_id, next_state)
end
redirect_to stories_path
else
render 'stories/new'
end
end
def update
#story = Story.find(params[:id])
if #story.update(story_params)
next_state = StoryStateService.new(#story, current_user, nil).call
if next_state
#story.update_column(:status_id, next_state)
end
redirect_to stories_path
else
render 'edit'
end
end
def update_story_status_event
story = Story.find(params['story_id'])
sub_action = params['sub_action']
next_state = StoryStateService.new(story, current_user, sub_action).call
if next_state
story.update_column(:status_id, next_state)
end
redirect_to stories_path
end
As you can see, I have
next_state = StoryStateService.new(story, current_user, sub_action).call
if next_state
story.update_column(:status_id, next_state)
end
being repeated on three methods, but on regular create and update, I dont need to send a sub_action param (string).

I would just extract the advance of the story, anything more than that would be overkill to me, given the simplicity of everything else.
def create
#story = Story.new(story_params)
#story.creator = current_user
if #story.save
advance_story #story
redirect_to stories_path
else
render 'stories/new'
end
end
def update
#story = Story.find(params[:id])
if #story.update(story_params)
advance_story #story
redirect_to stories_path
else
render 'edit'
end
end
def update_story_status_event
story = Story.find(params['story_id'])
sub_action = params['sub_action']
advance_story story, sub_action
redirect_to stories_path
end
private
def advance_story(story, sub_action = nil)
next_state = StoryStateService.new(story, current_user, sub_action).call
if next_state
story.update_column(:status_id, next_state)
end
end

I am not sure what StoryStateService is doing, but if it is dealing with updating a number of models, then you can also make a module or class and put it in the lib directory.
The other option is (this may be more preferable for Story.find()) is to use before_action callback. (https://apidock.com/rails/v4.0.2/AbstractController/Callbacks/ClassMethods/before_action)
e.g.
As you are using Story.find() in update and update_story_status_event you can do the following:
before_action :find_story, :only => [:update, :update_story_status_event]
def find_story
#story = Story.find(params[:id])
rescue ActiveRecord::RecordNotFound
# handle error here
end

Related

How to protect against tampering with the URL in Rails

My Rails student planner application has a few issues regarding URL tampering. I believe they probably all share a similar solution but I'm having difficulty.
When viewing an assignment (students/:id/assignments/:id), changing the assignment’s ID in the URL to the ID of an assignment belonging to another student sometimes leads to a "no method error" in my assignments#show page, other times it will show the other student's assignment, when ideally I'd like to just redirect back to their home page.
Similarly, this happens with the assignment's edit page (students/:id/assignments/:id/edit), course (students/:id/courses/:id) and course's edit page (students/:id/courses/:id/edit). Sometimes I'll get an "ArgumentError in Assignments#edit" when viewing an assignment's edit page.
I believe these should be able to be remedied in my controllers, so I've included my assignments_controller and courses_controller.
Assignments_controller:
class AssignmentsController < ApplicationController
before_action :require_logged_in
before_action :set_student
def new
if #student && #student.id == current_student.id
#assignment = Assignment.new
#courses = Course.where(student_id: current_student.id)
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users assignments.'
end
end
def create
#assignment = Assignment.new(assignment_params)
#assignment.student_id = current_student.id if current_student
#courses = Course.where(student_id: current_student.id)
if #assignment.save
redirect_to student_assignments_path(#student)
else
render :new
end
end
def index
if #student && #student.id == current_student.id
#assignments = Assignment.where(student_id: current_student.id)
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users assignments.'
end
end
def show
#student = Student.find_by(id: params[:student_id])
if #student && #student.id == current_student.id
##assignment = student.assignments.find_by(id: params[:id])
#assignment = Assignment.find_by(id: params[:id])
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users assignments.'
end
end
def edit
if #student && #student.id == current_student.id
#assignment = Assignment.find_by(id: params[:id])
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users assignments.'
end
end
def update
student = Student.find_by(id: params[:student_id])
#assignment = Assignment.find_by(id: params[:id])
#assignment.update(params.require(:assignment).permit(:title, :due_date))
redirect_to student_assignment_path(student, #assignment)
end
def destroy
#student = Student.find_by(id: params[:student_id])
#assignment = Assignment.find_by(id: params[:id]).destroy
redirect_to student_path(#student), notice: 'Assignment was successfully completed.'
end
private
def assignment_params
params.require(:assignment).permit(:title, :due_date, :course_id, :student_id)
end
def set_student
#student = Student.find_by(id: params[:student_id])
end
end
Courses_controller:
class CoursesController < ApplicationController
before_action :require_logged_in
before_action :set_student
def new
if #student && #student.id == current_student.id
#course = Course.new
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users courses.'
end
end
def create
if #student && #student.id == current_student.id
#course = Course.create(course_params)
#course.student_id = params[:student_id]
if #course.save
redirect_to student_courses_path(#student)
else
render :new
end
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users courses.'
end
end
def index
if #student && #student.id == current_student.id
#courses = Course.where(student_id: current_student.id)
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users courses.'
end
end
def show
#student = Student.find_by(id: params[:student_id])
if #student && #student.id == current_student.id
#course = #student.courses.find_by(id: params[:id])
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users courses.'
end
end
def edit
if #student && #student.id == current_student.id
#course = Course.find_by(id: params[:id])
else
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users courses.'
end
end
def update
student = Student.find_by(id: params[:student_id])
#course = Course.find_by(id: params[:id])
#course.update(params.require(:course).permit(:course_name))
redirect_to student_course_path(student, #course)
end
def destroy
#student = Student.find_by(id: params[:student_id])
#course = Course.find_by(id: params[:id]).destroy
redirect_to student_path(#student), notice: 'Course was successfully deleted.'
end
private
def course_params
params.require(:course).permit(:course_name)
end
def set_student
#student = Student.find_by(id: params[:student_id])
end
end
This line is the source of all the problems:
#assignment = Assignment.find_by(id: params[:id])
That's a huge mistake. I'd argue that you never, ever use the top-level model to fetch records that must be secured. The failure state of this code is a user sees everything. This is a problem that can't be fixed by patching over it with an access-control list. Those may not apply correctly every time, someone could find a loophole.
Instead you do this:
#assignment = #student.assignments.find_by(id: params[:id])
Worst-case scenario is you get a not-found error. It's impossible for someone to bypass this by hacking around with the URL. The failure state here is the record is not found.
If you want your URLs resistant to tampering you'll also want to use non-sequential identifiers. On MySQL it's often best to create a secondary column specifically for this purpose, like called param or slug or ident, whatever you prefer, and populate that with something random and harmless like:
before_validation :assign_slug
def assign_slug
self.slug ||= SecureRandom.uuid
end
Where that's indexed in your schema for quick retrieval. Where you have a student relationship:
add_index :assignments, [ :student_id, :slug ]
Postgres allows using UUID primary keys which might be verbose, but don't allow people to tinker and experiment to expose information. You really can't "guess" a randomized UUID value.
This might help you:
In your CoursesController and AssignmentsController, add a before_action in your controllers that will limit student's access.
#xxx_controller.rb
class XxxController < ApplicationController
before_action :require_logged_in
before_action :set_student
before_action :check_owner, only: [:show, :edit, :update, :destroy]
Then define the method in your ApplicationController:
#application_controller.rb
def check_owner
if #student.blank? || #student.id != current_student.id
redirect_to student_path(current_student), error: 'Sorry, you can\'t view another Users assignments.'
end
end
I guess access restriction is what you are looking for. CanCanCan will help you to set proper access to pages.
Also please replace find_by(id: params[:id]) with find(id) because it is more readable and more efficient.
So you have authentication set up allowing access only to logged in users.
Now you need an authorization process to allow access to certain actions/resources depending on the user id or role.
One popular option is Pundit and the readme page should get you started.

DRY voting methods

I have a prompt asking to write my up_vote and down_vote methods in only two lines using 'redirect_to' and the 'update_vote!' method as presented below. Implementing redirect_to is easy enough, but I'm not quite sure how to write my up/down_vote methods concisely using the existing 'update_vote!' method. Any help is appreciated.
class VotesController < ApplicationController
before_action :load_post_and_vote
def up_vote
if #vote
#vote.update_attribute(:value, 1)
else
#vote = current_user.votes.create(value: 1, post: #post)
end
# http://apidoc.com/rails/ActionController/Base/redirect_to
redirect_to :back
end
def down_vote
if #vote
#vote.update_attribute(:value, -1)
else
#vote = current_user.votes.create(value: -1, post: #post)
end
# http://apidoc.com/rails/ActionController/Base/redirect_to
redirect_to :back
end
private
def load_post_and_vote
#post = Post.find(params[:post_id])
#vote = #post.votes.where(user_id: current_user.id).first
end
def update_vote!(new_value)
if #vote
authorize #vote, :update?
#vote.update_attribute(:value, new_value)
else
#vote = current_user.votes.build(value: new_value, post: #post)
authorize #vote, :create
#vote.save
end
end
end
You should invoke the update_vote! method. How about:
def up_vote
update_vote!(1)
# http://apidoc.com/rails/ActionController/Base/redirect_to
redirect_to :back
end
def down_vote
update_vote!(-1)
# http://apidoc.com/rails/ActionController/Base/redirect_to
redirect_to :back
end
Also your method can be re-written as :
def update_vote!(new_value)
Vote.find_or_create_by post: #post do |v|
authorize v, :update?
v.user_id = current_user.id
v.value = new_value
v.save!
end
end
Read find_or_create_by.
seems that answer was easy enough =]
def up_vote
update_vote(1)
redirect_to :back
end
def down_vote
update_vote(-1)
redirect_to :back
end

Trouble Creating and showing Users in Rails blog

I keep getting a variety of error while trying to create and show errors in a simple Rails blog I'm trying to create.Let me know if you see anything obvious or if you need me to post more code as I've tried a number of things but to no avail. Thanks
The browser is giving me this error
Couldn't find User without an ID
in my "logged_in?" method which shows
def logged_in?
#current_user ||= User.find(session[:user_id])
end
Sessions Controller
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
session[:user_id] = user.id
flash[:success] = "You are logged in"
redirect_to root_path
else
render action: 'new'
flash[:error] = "There was a problem logging in. Please check your email and password"
end
end
end
def index
#users = User.all
end
def show
end
def new
#user = User.new
end
def edit
end
def create
#user = User.new(user_params)
if #user.save
session[:user_id] = #user.id
flash[:notice] = "You have registered, please login"
redirect_to login_path
else
render :new
end
end
def update
respond_to do |format|
if #user.update(user_params)
format.html { redirect_to #user, notice: 'User was successfully updated.' }
format.json { head :no_content }
else
format.html { render action: 'edit' }
format.json { render json: #user.errors, status: :unprocessable_entity }
end
end
end
def destroy
#user.destroy
respond_to do |format|
format.html { redirect_to users_url }
format.json { head :no_content }
end
end
private
def set_user
#user = User.find(params[:id])
end
def user_params
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation)
end
end
Articles Controller
class ArticlesController < ApplicationController
http_basic_authenticate_with name: "dhh", password: "secret", except: [:index, :show]
def new
#article = Article.new
end
def index
#article = Article.all
end
def create
#article = Article.new(article_params)
if #article.save
redirect_to #article
else
render 'new'
end
end
def edit
#article = Article.find(params[:id])
end
def update
#article = Article.find(params[:id])
if #article.update(article_params)
redirect_to #article
else
render 'edit'
end
end
def show
#article = Article.find(params[:id])
end
def destroy
#article = Article.find(params[:id])
#article.destroy
redirect_to articles_path
end
private
def article_params
params.require(:article).permit(:title, :text, :image)
end
end
Application Helper
module ApplicationHelper
def logged_in?
#current_user ||= User.find(session[:user_id])
end
end
The problem you're facing is that session[:user_id] is nil. Usually a method which sets current user is called current_user. The logged_in? is not a good name for a method setting an user instance, because one would expect that a method ending with a question mark would return a true or false. And not an user instance.
Also, setting the current user is usually done with a before_filter. Additionally, you want to skip such before filter for action where you're setting the current user (i.e the current_user doesn't exist yet)
Finally, I would rather fail gracefully, if user is not found. You can achieve this by changing your code to User.find_by_id(session[:user_id])
While the user is not loggued, session[:user_id] is nil, and so User.find(session[:user_id]) generates the error. The method should be like this:
def logged_in?
#current_user ||= User.find(session[:user_id]) if session[:user_id].present?
end
Why would the logged_in? helper method try to assign a value to #current_user? I think that is a bad logic, it should just return a boolean result without modifying such a central instance. This is a proper way to do that:
def logged_in?
#current_user.nil? ? false : true
end
The responsibility of setting the #current_user falls to a method that you should place in application_controller.rb and make it a before_action so that it's executed before any controller action is triggered, that is:
# app/controllers/applicaton_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery
before_action :authenticate_user
# Your actions here
..
..
#
private
def authenticate_user
#current_user ||= User.find(session[:user_id]) if session[:user_id].present?
end
end

Rails: optimizing a controller action (security checking)

I have an action in a controller that looks like this:
def show
#project = current_customer.projects.where(id: params[:project_id]).first
if #project
#feature = #project.features.where(id: params[:feature_id]).first
if #feature
#conversation = #feature.conversations.where(id: params[:id]).first
unless #conversation
head 401
end
else
head 401
end
else
head 401
end
end
The problem is the repetition of head 401. Is there a better way to write this action ?
I would write it like this
def show
#project = current_customer.projects.where(id: params[:project_id]).first
#feature = #project.features.where(id: params[:feature_id]).first if #project
#conversation = #feature.conversations.where(id: params[:id]).first if #feature
# error managment
head 401 unless #conversation
end
Maybe you can refactor in your project model with something like this
Model Project
...
def get_conversation
feature = features.where(id: params[:feature_id]).first
conversation = feature.conversations.where(id: params[:id]).first if feature
end
And in your Controller
Controller ProjectController
def show
#project = current_customer.projects.where(id: params[:project_id]).first
#conversation = #project.get_conversation
head 401 unless #conversation
end

How can I use acts_as_commentable for two models

I want to use acts_as_commentable for two models (blog and post). I generated the comment model and added all my fields.
I created a comments_controller and in the create action I have to find blogs and posts, so for that to work I am doing something like this :-
def create
if controller_name == "blogs"
#blog = Blog.find(params[:comment][:blog_id])
#blog_comment = #blog.comments.new(:comment => params[:comment][:comment], :user_id => current_user.id)
if #blog_comment.save
flash[:success] = "Thanks for commenting"
redirect_to :back or root_path
else
flash[:error] = "Comment can't be blank!"
redirect_to :back or root_path
end
end
if controller_name == "topics"
#post = Post.find(params[:comment][:post_id])
#post_comment = #post.comments.new(:comment => params[:comment][:comment], :user_id => current_user.id)
if #post_comment.save
flash[:success] = "Thanks for commenting"
redirect_to :back or root_path
else
flash[:error] = "Comment can't be blank!"
redirect_to :back or root_path
end
end
I know it is pretty ugly but I don't know how to go ahead with this one, can anyone help me out?
I encountered the similar problem on one of my apps.
Here is what I've done:
class CommentsController < ApplicationController
before_filter :get_commentable
def create
#comment = #commentable.comments.build(params[:comment])
if #comment.save
...
else
...
end
end
private
def get_commentable
params.each do |k, v|
return #commentable = $1.classify.constantize.find(v) if k =~ /(.+)_id$/
end
end
end

Resources