#-- Is this configured in Rails somewhere?? ++ DOC_ROOT = "public" class FilePermissions # Pattern used to determine quickly if we're interested in a file or not. # The default should match nothing. It must match the fully-expanded path. @@pattern = /^$/ # Directories we're interested in (fully-expanded). @@dirs = {} # Regexen matching files we're interested in. @@regexen = {} # Return internal Regexp that matches files whose permissions we are # controlling. def self.pattern # :nodoc: @@pattern end # Return internal hash of directories whose files we're controlling. def self.dirs # :nodoc: @@dirs end # Return internal hash of regexen matching files we're controlling. def self.regexen # :nodoc: @@regexen end # Register a set of files for permission control. It's pretty simple, just # give it a directory (relative to RAILS_ROOT) or regular expression, and a # callback block that determines whether the user is allowed to view a # matching file: # # # Only let Johnny see his images. # FilePermissions.register('/public/johnny') do |controller, file| # session[:user] == 'johnny' # end # # # Make sure the originals are only visible to their owner. # FilePermissions.register(/original\/(\d+)\.jpg$/) do |controller, file, match| # (img = Image.find(match[1])) && # controller.session[:user] == img.owner # end # # It can also take mixed arrays of strings and regular expressions: # # FilePermissions.register([ # '/directory/one', # '/directory/two', # /pattern_one/, # /pattern_two/ # ]) do |controller, file| # ... # end # # Note: regular expressions must match fully-qualified path name, so be # careful of anchoring it to the beginning of the string -- it might not do # what you expect. In other words, this will not work: # # /^\/public\/images\/user/ # # But these will: # # '/public/images/user' # /^#{RAILS_ROOT}\/public\/images\/user/ # # Note: you may apparently omit +match+ without causing exceptions: # # FilePermissions.register(/pattern/) { |controller, file| ... } # # Note: in the callback block you do have access to the controller, even # protected methods like +render+ (through some Ruby magic), but you are # emphatically _not_ in the context of the controller, so instance variables # are off-limits, and all the standard controller methods must be accessed # via +controller+. It's a pain, I know, but there's no way around it. # # For example: # # FilePermissions.register(/pattern/) do |controller, file| # img = Image.find_by_file(file) # # # These are wrong: # if img.owner != @user # render(:inline => "You lose!") # end # # # These are correct: # if img.owner != controller.instance_variable_get('@user') # controller.render(:inline => 'Ahhh, the sweet smell of victory...') # end # end # # By default it renders the file using render_file(file) if the # callback block returns +true+, and render("/403.html", :status => # 403) if it returns +false+. However you can override either by # rendering or redirecting before returning (in which case it doesn't matter # what value you return). # # FilePermissions.register(/pattern/) do |controller, file| # if user_has_permission? # render_file(file, :private => true, etc...) # else # redirect_to :action => 'access_denied' # end # end def self.register(arg, &block) raise ArgumentError, 'Expected a callback block.' if !block_given? arg = [arg] if !arg.is_a?(Array) arg.each do |subarg| if subarg.is_a?(String) @@dirs[File.expand_path(RAILS_ROOT + subarg)] = block elsif subarg.is_a?(Regexp) @@regexen[subarg] = block else raise ArgumentError, 'First argument should be String, Regexp or Array of either of the two.' \ end end rebuild_pattern end # Reverse the effects of FilePermissions.register. def self.unregister(arg) arg = [arg] if !arg.is_a?(Array) arg.each do |subarg| if subarg.is_a?(String) @@dirs.delete(File.expand(RAILS_ROOT + subarg)) elsif subarg.is_a?(Regexp) @@regexen.delete(subarg) else raise ArgumentError, 'First argument should be String, Regexp or Array of either of the two.' \ end end rebuild_pattern end # Clear all registered directories and patterns. def self.clear @@dirs = {} @@regexen = {} @@pattern = /^$/ end private # Rebuild @@pattern used to determine if a file is under our control. def self.rebuild_pattern # :nodoc: @@pattern = Regexp.new(( (@@dirs.keys.map {|s| '^'+Regexp.escape(s)}) + @@regexen.keys ).join('|')) end end ################################################################################ module ActionController # :nodoc: class Base # Register this as a +before_filter+ in your ApplicationController in order # to enable FilePermissions checking. # # We're letting you to do this instead of installing one ourselves so that # you have control over the order of the filter chain. It also makes it # all marginally more transparent to the casual reader. # # (In fact, if you register files with FilePermissions but *do not* # register this +before_filter+, Rails doesn't actually know how to serve # most file types, so it'll try to serve them as HTML or text and fail.) def file_permissions file = File.expand_path("#{RAILS_ROOT}/public/#{request.request_uri}") if FilePermissions.pattern.match(file) # Remove parameters (why would there be any??) file = file.sub(/\?.*/, "") # Did this file trigger a dir callback? FilePermissions.dirs.each_pair do |dir, block| if file[0,dir.length] == dir if block.call(KludgeWrapper.new(self), file) render_file(file) if !performed? else render("/403.html", :status => 403) if !performed? end end end # Did this file trigger a regex callback? FilePermissions.regexen.each_pair do |re, block| if match = re.match(file) if block.call(KludgeWrapper.new(self), file, match) render_file(file) if !performed? else render(:file => "#{RAILS_ROOT}/public/403.html", :status => 403) \ if !performed? end end end end end # I don't know what the f--- is up with this, but for some reason, if I # just pass the controller (+self+) to the block, the block is unable to # call methods on it (e.g. +render+). But I *can* run # send(method) on it. So the obvious solution is to wrap self in # an object that translates obj.render into # controller.send("render"). Clear as mud. Principal of least # surprise my fragant ass. class KludgeWrapper # :nodoc: all def initialize(controller) @controller = controller end def method_missing(method, *args) @controller.send(method, *args) end end # Render a file straight-up, no templates, no compiling, nothing. # You have a number of caching options: # # :last_modified Override modification date of file. # :etag Override "ETag" -- see below. # :must_revalidate Tell client it MUST revalidate after cache expires. # :private Tell client not to use cache shared by mutliple users. # :max_age Let client cache file for this long (in seconds). # All others are treated as Cache-Control headers. # # Note on +etag+: By default we use the file's age in seconds. The way it # works is we send this etag to the client along with the file the first # time we serve it. Then, once the cache has expired (via max-age # for example), the browser sends the etag back to us. If the etag is # unchanged we tell the client it's still okay to use the cache (resetting # the age of the client's cache if max-age given). # # If you want the client to revalidate every time, no matter what -- the # maximum security we can offer -- use must_revalidate, # private and max_age = 0. # # Examples: # # # No cache control whatsoever. # render_file("public.jpg") # # # Let client cache the image publicly but have it check every hour # # in case we've changed our minds. # render_file("might_become_private.jpg", # :max_age => 1.hour, # :must_revalidate => true # ) # # # We know this is Johnny's photo and that's not going to change, so # # just tell the client to cache it in a private cache, no need to # # check back with us. # render_file("johnny's_photo.jpg", # :private => true # ) # # # Permissions on this file could change at any time for any user, so # # check back with us every single time. # render_file("hot_potato.pdf", # :must_revalidate => true, # :private => true, # :max_age => 0 # ) # # Disclaimer: As a server, as soon as we send off the file, technically # speaking, access to that file is completely out of our hands. We are # relying on browsers voluntarily obeying our requests to cache things # carefully and to check with us when we say. Malicious "browsers" can # potentially do anything they like with that file. Always use watermarks # or equivalent to safeguard important images or documents. # def render_file(file, args={}) # Get rid of the pathological case of file not existing right away. if !File.file?(file) response.content_type = "text/html" response.headers['Status'] = "404 Not Found" response.body = File.new("#{RAILS_ROOT}/public/404.html").read else args = args.clone # Cache validation method one: client returns 'last-modified' date to us. mod_since = request.headers['HTTP_IF_MODIFIED_SINCE'] if !mod_out = args[:last_modified] mod_out = File.stat(file).mtime end args.delete(:last_modified) # print ">>>>>>>>>>>>>> mod: #{mod_in} => #{modout}\n" # Cache validation method two: client returns 'ETag' to us. etags_in = request.headers['HTTP_IF_NONE_MATCH'] if !etag_out = args[:etag] mod_out = File.stat(file).mtime if !mod_out etag_out = mod_out.to_i.to_s end args.delete(:etag) # print ">>>>>>>>>>>>>> etag: #{etags_in} => #{etag_out}\n" # Build Cache-Control header. cache = {} args.each_pair do |var, val| case var when :max_age : cache['max-age'] = val.to_i else cache[var.to_s.gsub(/_/, "-")] = val end end cache_out = [] cache.each_pair do |var, val| if val == true cache_out.push(var) elsif val cache_out.push("#{var}=#{val}") end end cache_out = cache_out.sort.join(", ") # print ">>>>>>>>>>>>>> cache: #{cache_out}\n" # Check if client's cache is valid. This was stolen almost verbatim from # Zed's Mongrel::DirHandler.send_file. cache_okay = case when mod_since && !mod_in = Time.httpdate(mod_since) rescue nil : false when mod_since && mod_in > Time.now : false when mod_since && mod_out > mod_in : false when etags_in && etags_in == '*' : false when etags_in && !etags_in.strip.split(/\s*,\s*/).include?(etag_out) : false else mod_since || etags_in end # Not sure which if any of these are ignored for 304 responses... response.headers['ETag'] = etag_out if etag_out response.headers['Last-Modified'] = mod_out.httpdate if mod_out response.headers['Cache-Control'] = cache_out if cache_out if cache_okay response.headers['Status'] = "304 Not Modified" response.body = "" # print ">>>>>>>>>>>>>> Using cache for #{file}\n" else response.headers['Status'] = "200 OK" response.content_type = FilePermissions.mime_type(file) response.body = File.new(file, "rb").read # print ">>>>>>>>>>>>>> Sending #{file}\n" end end @performed_render = true end end end