#-- 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