I'm using Shrine with Rails, with the download_endpoint plugin. I have this working with route-based authentication via Devise, but I'd like to go one step further and add account-based authentication so that users can only access their own images.
Rails.application.routes.draw do
get "/img/*rest", to: "download#confirm_response"
end
This uses the URL /img/xxx – but I'm trying to use something like /img/account_id/xxx, so this is my updated routes.rb:
Rails.application.routes.draw do
get "/img/:account_id/*rest", to: "download#confirm_response"
end
Here is my updated Controller, I'm not sure exactly what to amend in my set_rack_response method in order to correctly pull in my account_id. I have the rack_response plugin loaded, is it possible to set this up?
class DownloadController < ApplicationController
def confirm_response
#account = Account.find(params[:account_id])
set_rack_response #account.to_rack_response
end
private
def set_rack_response((status, headers, body))
self.status = status
self.headers.merge!(headers)
self.response_body = body
end
end
#Janko EDIT:
I've updated my routes and controller, I have 2 models that have image files (photos and logos) that I would love to use download_endpoint for both.
I feel like I'm missing where to add :account_id to the download_url itself.
Would love for the URL to be /img/:account_id/xxx but what do you think would work best given this setup?
# Account.rb
class Account < ApplicationRecord
has_many :photos
has_many :logos
end
# Photo.rb
class Photo < ApplicationRecord
belongs_to :account
include PhotoUploader::Attachment(:photo_file)
end
# Logo.rb
class Logo < ApplicationRecord
belongs_to :account
include LogoUploader::Attachment(:logo_file)
end
# Updated download_controller.rb
class DownloadController < ApplicationController
def confirm_response
#account = Account.find(params[:account_id])
#photo = #account.photos.find(params[:photo_id])
set_rack_response #photo.to_rack_response
end
private
def set_rack_response((status, headers, body))
self.status = status
self.headers.merge!(headers)
self.response_body = body
end
end
# Updated config/routes.rb
Rails.application.routes.draw do
get "/img/:account_id/:photo_id/*rest", to: "download#confirm_response"
end
# Updated config/initializers/shrine.rb
Shrine.plugin :download_endpoint, prefix: "img", host: [:cloudfront]
Shrine.plugin :rack_response
# Updated views/photos/show.html.erb
<%= image_tag(#photo.photo_file.download_url) %>
Related
I'm building an API with the kollegorna's tutorial.
I used ActiveHashRelation for displays serialiazed arrays properly.
# app/controllers/api/v1/posts_controller.rb
class Api::V1::PostsController < Api::V1::BaseController
include ActiveHashRelation
def index
posts = apply_filters(Post::Post.all, params)
render json: posts, each_serializer: Api::V1::PostSerializer
end
end
The problem is that I have a model Post::Post and the module Post. It's like it can not found the correct model.
# app/models/post/post.rb
class Post::Post < ActiveRecord::Base
validates_presence_of :body
end
# app/models/post.rb
module Post
def self.table_name_prefix
'post_'
end
end
I think it's because of ActiveHashrelation because when I write posts = Post::Post.all it's working. But I can't filter the array.
Custom routes for the same controller. I have many semi-static pages in my app (actually stored in my database with a group and page name field), they are grouped by product and then subject, for example
Cars: tires, Wheels, Radio, Windshield
Homes: Doors, Windows, Roof
Products and Services: data services
I would prefer not to make a new controller for each group. However, I am trying to get different URL paths that are descriptive. For example:
domain.com/cars/tires_and_spokes
domain.com/cars/wheels
domain.com/homes/doors_and_knobs
domain.com/homes/windows
domain.com/products_and_services/data_service
currently, all I have is
domain.com/pages/cars_tires_and_spokes
etc.
but I prefer the former.
Routes:
pages_from_DB =[
{group:"cars", name:"tires and spokes"}
{group:"cars", name:"wheels"}
{group:"homes", name:"tires and spokes"}
{group:"homes", name:"windows"}
]
pages = %w[
cars_tires_and_spokes
cars_wheels
homes_doors_and_knobs
homes_windows
products_and_services_data_service
]
pages.each do |page|
get page, controller: "pages", action: page
end
Controller:
class PagesController < ApplicationController
pages_from_DB =[
{group:"cars", name:"tires and spokes"}
{group:"cars", name:"wheels"}
{group:"homes", name:"tires and spokes"}
{group:"homes", name:"windows"}
]
pages = %w[
cars_tires_and_spokes
cars_wheels
homes_doors_and_knobs
homes_windows
products_and_services_data_service
]
pages.each do |page|
define_method(page) do
end
end
end
Looks like you've missed the point of nested resources:
#config/routes.rb
resources :groups, path: "", only: [] do
resources :pages, path: "" #-> url.com/:group_id/:id
end
This will direct any user to the pages controller, to which they're able to pull both the Group and ID from their respective models:
#app/controllers/pages_controller.rb
class PagesController < ApplicationController
def show
#group = Group.find params[:group_id]
#page = #group.pages.find params[:id]
end
end
--
This should be accompanied by the following models:
#app/models/group.rb
class Group < ActiveRecord::Base
has_many :pages
end
#app/models/page.rb
class Page < ActiveRecord::Base
belongs_to :group
end
If you wanted to treat the routes with a slug (instead of id), you'll want to look at friendly_id:
#Gemfile
gem "friendly_id"
$ rails generate friendly_id
$ rails generate scaffold group title:string slug:string:uniq
$ rails generate scaffold page title:string slug:string:uniq
$ rake db:migrate
#app/models/group.rb
class Group < ActiveRecord::Base
has_many :pages
extend FriendlyId
friendly_id :title, use: [:slugged, :finders]
end
#app/models/page.rb
class Page < ActiveRecord::Base
belongs_to :group
extend FriendlyId
friendly_id :title, use: [:slugged, :finders]
end
This will allow you to use:
<%= link_to group_pages_path(#group, #page) %>
# -> url.com/group-name/page-title
Update
The above code was based on the idea that you would be able to put your pages into the database (as you should). If you don't want to do that, there is a wildcard route you may be able to use:
#config/routes.rb
get "*group/:page", to: "pages#show"
If the pages were "semi-static" (still don't know what that means), you'd then be able to render the various views as required:
#app/controllers/pages_controller.rb
class PagesController < ApplicationController
def show
group = params[:group]
render "#{params[:group]}/#{params[:page]"
end
end
The above would give you the following link:
url.com/path/to/your/group/this-is-the-page-id
Depending on your group / sub-group structure, it should give you the ability to call the various views. I don't agree with it but it's apparently what you wanted.
--
Custom Middleware
We also created custom middleware which has some of the functionality for this:
#config/routes.rb
get "*group/:page", to: PageDispatcher.new
#app/controllers/pages_controller.rb
class PagesController < ApplicationController
cattr_accessor :pages #-> PagesController.pages
##pages = %w[
cars_tires_and_spokes
cars_wheels
homes_doors_and_knobs
homes_windows
products_and_services_data_service
]
end
#lib/page_dispatcher.rb
class PageDispatcher
#Init
def initialize(router)
#router = router
end
#Env
def call(env)
group = env["action_dispatch.request.path_parameters"][:group]
page = env["action_dispatch.request.path_parameters"][:page]
if PagesController.pages.include? page
strategy(slug).call(#router, env)
else
raise ActiveRecord::RecordNotFound
end
end
##########################################
private
#Strategy
def strategy(url)
Render.new(url)
end
####################
#Render
class Render
def initialize(url)
#url = url
end
def call(router, env)
controller = PagesController
action = "show"
controller.action(action).call(env)
end
end
####################
end
I have these models:
class User < ActiveRecord::Base
has_one :setting, as: :resource, dependent: :destroy
has_many :services
end
class Setting < ActiveRecord::Base
belongs_to :resource, polymorphic: true
end
class Service < ActiveRecord::Base
belongs_to :user
end
and I have this code snippet from a controller to access them, which works when manually testing (this is a before_action method):
before_action :setup
def get_vars
#setting = Setting.find_by_booking_url #url
#user = #setting.resource
#services = #user.services
end
def setup
#url = params[:url]
#setting = Setting.find_by_url #url
#user = #setting.resource
end
but when using automated testing with these fixtures:
# fixtures/users.yml
james:
email: 'james#example.com'
first_name: 'James'
last_name: 'Soften'
encrypted_password: <%= default_password_digest %>
# fixtures/settings.yml
scott_settings:
resource: james (User)
time_zone: 'Pacific Time (US & Canada)'
url: 'urlsegment'
and this controller test code:
setup do
#user = users(:james)
end
def test_booking_page_success
get :new, url: #user.setting.url
assert_response :success
end
it fails with error:
undefined method `services` for nil:NilClass
which indicates that I cannot access "resource" to get the User from the Setting object.
I checked the variables using byebug (in the middle of running tests) and the Setting object (#user.setting) has a resource_id and resource_name, so I was wondering why I cannot access the user from the Setting object using "resource" attribute when running the tests.
Any ideas?
Thanks!
EDIT: added more controller code
EDIT: added URL field in settings fixture
Try the following before test_booking_page_success
def test_url
assert_not_empty #user.setting.url
end
You are probably passing an empty 'url' parameter... if the fixtures shown are complete and you are not generating url from something else.
Upd The only scenario I can imagine: is that booking_url in scott_settings is set to something other than 'urlsegment' in your fixtures. So it returns other settings with nil resource...
Given the following:
class WebsitesController < ApplicationController
# POST /websites/save
# POST /websites/save.json
def save
Website.exists?(name: params[:website][:name]) ? update : create
end
# POST /websites
# POST /websites.json
def create
#server = Server.find_or_create_by_name(params[:server_id])
#website = #server.websites.new(params[:website])
#etc... #website.save
end
# PUT /websites/1
# PUT /websites/1.json
def update
#website = Website.find_by_name(params[:website][:name])
#etc... #website.update_attributes
end
end
The client does not have any IDs of these models
The request that gets sent only has the names, but not the ids.
And the following models
class Website < ActiveRecord::Base
serialize :website_errors
attr_accessible :plugins_attributes
has_many :plugins
accepts_nested_attributes_for :plugins
end
class Plugin < ActiveRecord::Base
belongs_to :website
end
When I make a POST request to /websites/save.json, the Website gets updated correctly if it exists, but the Plugins that belong to it always get recreated causing duplicate content in the Database. Why does this happen? I redirect to the update action which calls update_attributes so how can it be that it does not update it? I take it that it's because no ID is given with the request.
Can I make the Controller listen to plugin_name instead of plugin_id?
Modify your controller to have this:
def update
#website = Website.find_by_name(params[:website][:name])
if #website.update(params)
redirect_to website_path(#website)
else
render :edit
end
end
Also, if you're using strong_parameters, you'll need this at the bottom of your controller:
params.require(:website).
permit(
:name,
...,
plugins_attributes: [
:name,
...,
]
)
end
Within Authlogic, is there a way that I can add conditions to the authentication method? I know by using the find_by_login_method I can specify another method to use, but when I use this I need to pass another parameter since the find_by_login_method method only passes the parameter that is deemed the 'login_field'.
What I need to do is check something that is an association of the authentic model.. Here is the method I want to use
# make sure that the user has access to the subdomain that they are
# attempting to login to, subdomains are company names
def self.find_by_email_and_company(email, company)
user = User.find_by_email(email)
companies = []
user.brands.each do |b|
companies << b.company.id
end
user && companies.include?(company)
end
But this fails due to the fact that only one parameter is sent to the find_by_email_and_company method.
The company is actually the subdomain, so in order to get it here I am just placing it in a hidden field in the form (only way I could think to get it to the model)
Is there a method I can override somehow..?
Using the answer below I came up with the following that worked:
User Model (User.rb)
def self.find_by_email_within_company(email)
# find the user
user = self.find_by_email(email)
# no need to continue if the email address is invalid
return false if user.nil?
# collect the subdomains the provided user has access to
company_subdomains = user.brands.map(&:company).map(&:subdomain)
# verify that the user has access to the current subdomain
company_subdomains.include?(Thread.current[:current_subdomain]) && user
end
Application Controller
before_filter :set_subdomain
private
def set_subdomain
# helper that retreives the current subdomain
get_company
Thread.current[:current_subdomain] = #company.subdomain
end
User Session Model (UserSession.rb)
find_by_login_method :find_by_email_within_company
I have read a few things about using Thread.current, and conflicting namespaces.. This is a great solution that worked for me but would love to hear any other suggestions before the bounty expires, otherwise, +100 to Jens Fahnenbruck :)
Authlogic provides API for dealing with sub domain based authentication.
class User < ActiveRecord::Base
has_many :brands
has_many :companies, :through => :brands
acts_as_authentic
end
class Brand < ActiveRecord::Base
belongs_to :user
belongs_to :company
end
class Company < ActiveRecord::Base
has_many :brands
has_many :users, :through => :brands
authenticates_many :user_sessions, :scope_cookies => true
end
Session controller:
class UserSessionsController < ApplicationController
def create
#company = Company.find(params[:user_session][:company])
#user_session = #company.user_sessions.new(params[:user_session])
if #user_session.save
else
end
end
end
On the other hand
Here is a way to solve the problem using your current approach(I would use the first approach):
Set custom data - to the key email of the hash used to create the UserSession object.
AuthLogic will pass this value to find_by_login method. In the find_by_login method access the needed values.
Assumption:
The sub domain id is set in a field called company in the form.
class UserSessionsController < ApplicationController
def create
attrs = params[:user_session].dup #make a copy
attrs[:email] = params[:user_session] # set custom data to :email key
#user_session = UserSession.new(attrs)
if #user_session.save
else
end
end
end
Model code
Your code for finding the user with the given email and subdomain can be simplified and optimized as follows:
class User < ActiveRecord::Base
def find_by_email params={}
# If invoked in the normal fashion then ..
return User.first(:conditions => {:email => params}) unless params.is_a?(Hash)
User.first(:joins => [:brands => :company}],
:conditions => ["users.email = ? AND companies.id = ?",
params[:email], params[:company]])
end
end
Edit 1
Once the user is authenticated, system should provide access to authorized data.
If you maintain data for all the domains in the same table, then you have to scope the data by subdomain and authenticated user.
Lets say you have Post model with company_id and user_id columns. When a user logs in you want to show user's posts for the sub domain. This is one way to scope user's data for the subdomain:
Posts.find_by_company_id_and_user_id(current_company, current_user)
Posts.for_company_and_user(current_company, current_user) # named scope
If you do not scope the data, you will have potential security holes in your system.
In your lib folder add a file with the follwing content:
class Class
def thread_local_accessor name, options = {}
m = Module.new
m.module_eval do
class_variable_set :"###{name}", Hash.new {|h,k| h[k] = options[:default] }
end
m.module_eval %{
FINALIZER = lambda {|id| ###{name}.delete id }
def #{name}
###{name}[Thread.current.object_id]
end
def #{name}=(val)
ObjectSpace.define_finalizer Thread.current, FINALIZER unless ###{name}.has_key? Thread.current.object_id
###{name}[Thread.current.object_id] = val
end
}
class_eval do
include m
extend m
end
end
end
I found this here
Then add code in the controller like this:
class ApplicationController < ActionController
before_filter :set_subdomain
private
def set_subdomain
User.subdomain = request.subdomains[0]
end
end
And now you can do the following in your user model (assuming your company model has a method called subdomain:
class User < ActiveRecord::Base
thread_local_accessor :subdomain, :default => nil
def self.find_by_email_within_company(email)
self.find_by_email(email)
company_subdomains = user.brands.map(&:company).map(&:subdomain)
company_subdomains.include?(self.subdomain) && user
end
end
And FYI:
companies = user.brands.map(&:company).map(&:subdomain)
is the same as
companies = []
user.brands.each do |b|
companies << b.company.subdomain
end
With rails 3 you can use this workaround:
class UserSessionsController < ApplicationController
...
def create
#company = <# YourMethodToGetIt #>
session_hash = params[:user_session].dup
session_hash[:username] = { :login => params[:user_session][:username], :company => #company }
#user_session = UserSession.new(session_hash)
if #user_session.save
flash[:notice] = "Login successful!"
redirect_back_or_default dashboard_url
else
#user_session.username = params[:user_session][:username]
render :action => :new
end
...
end
Then
class UserSession < Authlogic::Session::Base
find_by_login_method :find_by_custom_login
end
and
class User < ActiveRecord::Base
...
def self.find_by_custom_login(hash)
if hash.is_a? Hash
return find_by_username_and_company_id(hash[:login], hash[:company].id) ||
find_by_email_and_company_id(hash[:login], hash[:company].id)
else
raise Exception.new "Error. find_by_custom_login MUST be called with {:login => 'username', :company => <Company.object>}"
end
end
...
end
Which is quite plain and "correct". I take me a lot of time to find out, but it works fine!