radiant-downloads-extension 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ spec/spec_report.html
2
+ .DS_Store
data/README.markdown ADDED
@@ -0,0 +1,81 @@
1
+ # Downloads
2
+
3
+ This is a simple and fairly thin extension that makes it easy to protect file downloads using nginx's internal redirects. It works something like this:
4
+
5
+ * You upload a file using the admin interface and grant access to a couple of reader groups. The file is stored outside the /public folder and can't be reached with a web browser
6
+ * A thin public-facing controller takes download requests and checks them against group membership
7
+ * If you're not allowed, it redirects you to reader login or just tells you off
8
+ * If you are allowed, it returns an attachment-download response pointing to a fictional address in /secure_download but with the `X-Accel-Redirect` header set to the real address of your file
9
+ * Your nginx configuration intercepts the `X-Accel-Redirect` header, ignores the request address and returns the file
10
+ * Your web browser reads the request address and gets the right file name
11
+ * Your nginx configuration also makes sure that typing in the /secure_download address doesn't give file access
12
+
13
+ In other words, there is no way to get at the uploaded file without going through the authenticating controller. The original inspiration is [in Alexei Kovyrin's blog](http://blog.kovyrin.net/2006/11/01/nginx-x-accel-redirect-php-rails/).
14
+
15
+ It ought to be an easy matter to make this work with Apache and sendfile, but I haven't needed to.
16
+
17
+ As with other group-access-control, a download with no groups attached is considered available, but here we are more restrictive and only make it available to logged in readers. If you want to publish a document for the public, you'd be better advised to upload it as a paperclipped asset.
18
+
19
+ ## Status
20
+
21
+ Should be reliable. It's quite well-established code. The original version used file_column so I've spent some time bringing it across to paperclip and writing proper tests.
22
+
23
+ ## Requirements
24
+
25
+ This uses spanner's [reader](https://github.com/spanner/radiant-reader-extension) and [reader_group](https://github.com/spanner/radiant-reader_group-extension) extensions for access control. If you would like to change the basis for allowing downloads, the easiest thing is probably to override or chain `Download#available_to?`. But do tell us what you're trying to do. We like to be useful.
26
+
27
+ Also requires [paperclip](http://www.thoughtbot.com/projects/paperclip) gem, or the paperclipped extension (which currently vendors paperclip).
28
+
29
+ (We thought about just applying access control to paperclipped assets but the machinery there is too specialised for images: most of what goes in here will be pdfs and office documents. The separate store is tricky too.)
30
+
31
+ This should be straightforwardly `multi_site` compatible. If you use our [fork of multi_site](https://github.com/spanner/radiant-multi-site-extension/) then downloads (and readers and groups) will be site-scoped.
32
+
33
+ ## Configuration
34
+
35
+ ### In nginx
36
+
37
+ This is what I use:
38
+
39
+ location /secure_download/ {
40
+ internal;
41
+ root /your/site/directory/current/secure_downloads;
42
+ default_type application/pdf;
43
+ expires 1h;
44
+ add_header Cache-Control private;
45
+ break;
46
+ }
47
+
48
+ but I'm no nginx rypy. Suggestions would be very welcome.
49
+
50
+ (And naturally a security-minded person would only put this in the SSL-enabled version of the site, with the appropriate measures to direct people there).
51
+
52
+ ### In capistrano
53
+
54
+ Ideally we will be storing downloads in the shared directory rather than in /current, so that they persist through deployments. I can't see a good way to get at that directory without making unhelpful assumptions in the model, so instead I will assume that if you use capistrano, you're doing the right thing with symlinks on deployment:
55
+
56
+ after "deploy:setup" do
57
+ sudo "mkdir -p #{shared_path}/secure_downloads"
58
+ sudo "chown -R #{user}:#{group} #{shared_path}/secure_downloads"
59
+ end
60
+
61
+ after "deploy:update" do
62
+ run "ln -s #{shared_path}/secure_downloads #{current_release}/secure_downloads"
63
+ end
64
+
65
+ Note that secure_downloads is not inside the public site.
66
+
67
+ ## Usage
68
+
69
+ You'll see a 'downloads' tab in admin. Add files to the list there and you can link to them like this:
70
+
71
+
72
+
73
+ ## Bugs and comments
74
+
75
+ In [lighthouse](http://spanner.lighthouseapp.com/projects/26912-radiant-extensions), please, or for little things an email or github message is fine.
76
+
77
+ ## Author and copyright
78
+
79
+ * Copyright spanner ltd 2007-9.
80
+ * Released under the same terms as Rails and/or Radiant.
81
+ * Contact will at spanner.org
data/Rakefile ADDED
@@ -0,0 +1,137 @@
1
+ begin
2
+ require 'jeweler'
3
+ Jeweler::Tasks.new do |gem|
4
+ gem.name = "radiant-downloads-extension"
5
+ gem.summary = %Q{Controlled, secure file access for Radiant CMS with group-based access control.}
6
+ gem.description = %Q{Controlled, secure file access with group-based access control.}
7
+ gem.email = "will@spanner.org"
8
+ gem.homepage = "http://github.com/spanner/radiant-downloads-extension"
9
+ gem.authors = ["spanner"]
10
+ gem.add_dependency "radiant", ">= 0.9.0"
11
+ gem.add_dependency 'radiant-reader_group-extension'
12
+ end
13
+ rescue LoadError
14
+ puts "Jeweler (or a dependency) not available. This is only required if you plan to package downloads as a gem."
15
+ end
16
+
17
+ # In rails 1.2, plugins aren't available in the path until they're loaded.
18
+ # Check to see if the rspec plugin is installed first and require
19
+ # it if it is. If not, use the gem version.
20
+
21
+ # Determine where the RSpec plugin is by loading the boot
22
+ unless defined? RADIANT_ROOT
23
+ ENV["RAILS_ENV"] = "test"
24
+ case
25
+ when ENV["RADIANT_ENV_FILE"]
26
+ require File.dirname(ENV["RADIANT_ENV_FILE"]) + "/boot"
27
+ when File.dirname(__FILE__) =~ %r{vendor/radiant/vendor/extensions}
28
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../../../")}/config/boot"
29
+ else
30
+ require "#{File.expand_path(File.dirname(__FILE__) + "/../../../")}/config/boot"
31
+ end
32
+ end
33
+
34
+ require 'rake'
35
+ require 'rake/rdoctask'
36
+ require 'rake/testtask'
37
+
38
+ rspec_base = File.expand_path(RADIANT_ROOT + '/vendor/plugins/rspec/lib')
39
+ $LOAD_PATH.unshift(rspec_base) if File.exist?(rspec_base)
40
+ require 'spec/rake/spectask'
41
+ require 'cucumber'
42
+ require 'cucumber/rake/task'
43
+
44
+ # Cleanup the RADIANT_ROOT constant so specs will load the environment
45
+ Object.send(:remove_const, :RADIANT_ROOT)
46
+
47
+ extension_root = File.expand_path(File.dirname(__FILE__))
48
+
49
+ task :default => :spec
50
+ task :stats => "spec:statsetup"
51
+
52
+ desc "Run all specs in spec directory"
53
+ Spec::Rake::SpecTask.new(:spec) do |t|
54
+ t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""]
55
+ t.spec_files = FileList['spec/**/*_spec.rb']
56
+ end
57
+
58
+ task :features => 'spec:integration'
59
+
60
+ namespace :spec do
61
+ desc "Run all specs in spec directory with RCov"
62
+ Spec::Rake::SpecTask.new(:rcov) do |t|
63
+ t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""]
64
+ t.spec_files = FileList['spec/**/*_spec.rb']
65
+ t.rcov = true
66
+ t.rcov_opts = ['--exclude', 'spec', '--rails']
67
+ end
68
+
69
+ desc "Print Specdoc for all specs"
70
+ Spec::Rake::SpecTask.new(:doc) do |t|
71
+ t.spec_opts = ["--format", "specdoc", "--dry-run"]
72
+ t.spec_files = FileList['spec/**/*_spec.rb']
73
+ end
74
+
75
+ [:models, :controllers, :views, :helpers].each do |sub|
76
+ desc "Run the specs under spec/#{sub}"
77
+ Spec::Rake::SpecTask.new(sub) do |t|
78
+ t.spec_opts = ['--options', "\"#{extension_root}/spec/spec.opts\""]
79
+ t.spec_files = FileList["spec/#{sub}/**/*_spec.rb"]
80
+ end
81
+ end
82
+
83
+ desc "Run the Cucumber features"
84
+ Cucumber::Rake::Task.new(:integration) do |t|
85
+ t.fork = true
86
+ t.cucumber_opts = ['--format', (ENV['CUCUMBER_FORMAT'] || 'pretty')]
87
+ # t.feature_pattern = "#{extension_root}/features/**/*.feature"
88
+ t.profile = "default"
89
+ end
90
+
91
+ # Setup specs for stats
92
+ task :statsetup do
93
+ require 'code_statistics'
94
+ ::STATS_DIRECTORIES << %w(Model\ specs spec/models)
95
+ ::STATS_DIRECTORIES << %w(View\ specs spec/views)
96
+ ::STATS_DIRECTORIES << %w(Controller\ specs spec/controllers)
97
+ ::STATS_DIRECTORIES << %w(Helper\ specs spec/views)
98
+ ::CodeStatistics::TEST_TYPES << "Model specs"
99
+ ::CodeStatistics::TEST_TYPES << "View specs"
100
+ ::CodeStatistics::TEST_TYPES << "Controller specs"
101
+ ::CodeStatistics::TEST_TYPES << "Helper specs"
102
+ ::STATS_DIRECTORIES.delete_if {|a| a[0] =~ /test/}
103
+ end
104
+
105
+ namespace :db do
106
+ namespace :fixtures do
107
+ desc "Load fixtures (from spec/fixtures) into the current environment's database. Load specific fixtures using FIXTURES=x,y"
108
+ task :load => :environment do
109
+ require 'active_record/fixtures'
110
+ ActiveRecord::Base.establish_connection(RAILS_ENV.to_sym)
111
+ (ENV['FIXTURES'] ? ENV['FIXTURES'].split(/,/) : Dir.glob(File.join(RAILS_ROOT, 'spec', 'fixtures', '*.{yml,csv}'))).each do |fixture_file|
112
+ Fixtures.create_fixtures('spec/fixtures', File.basename(fixture_file, '.*'))
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ desc 'Generate documentation for the downloads extension.'
120
+ Rake::RDocTask.new(:rdoc) do |rdoc|
121
+ rdoc.rdoc_dir = 'rdoc'
122
+ rdoc.title = 'DownloadsExtension'
123
+ rdoc.options << '--line-numbers' << '--inline-source'
124
+ rdoc.rdoc_files.include('README')
125
+ rdoc.rdoc_files.include('lib/**/*.rb')
126
+ end
127
+
128
+ # For extensions that are in transition
129
+ desc 'Test the downloads extension.'
130
+ Rake::TestTask.new(:test) do |t|
131
+ t.libs << 'lib'
132
+ t.pattern = 'test/**/*_test.rb'
133
+ t.verbose = true
134
+ end
135
+
136
+ # Load any custom rakefiles for extension
137
+ Dir[File.dirname(__FILE__) + '/tasks/*.rake'].sort.each { |f| require f }
@@ -0,0 +1,3 @@
1
+ class Admin::DownloadsController < Admin::ResourceController
2
+
3
+ end
@@ -0,0 +1,21 @@
1
+ class DownloadsController < ReaderActionController
2
+
3
+ before_filter :require_reader, :only => [:show]
4
+
5
+ def show
6
+ @download = Download.find(params[:id])
7
+ if @download.available_to?(current_reader)
8
+ response.headers['X-Accel-Redirect'] = @download.document.url
9
+ response.headers["Content-Type"] = @download.document_content_type
10
+ response.headers['Content-Disposition'] = "attachment; filename=#{@download.document_file_name}"
11
+ response.headers['Content-Length'] = @download.document_file_size
12
+ render :nothing => true
13
+ else
14
+ flash[:error] = "Sorry: you don't have permission to download that file."
15
+ render :template => 'reader/permission_denied'
16
+ end
17
+ end
18
+
19
+ end
20
+
21
+
@@ -0,0 +1,33 @@
1
+ class Download < ActiveRecord::Base
2
+
3
+ is_site_scoped if defined? ActiveRecord::SiteNotFound
4
+ belongs_to :created_by, :class_name => 'User'
5
+ belongs_to :updated_by, :class_name => 'User'
6
+ has_and_belongs_to_many :groups
7
+ default_scope :order => 'updated_at DESC, created_at DESC'
8
+
9
+ has_attached_file :document,
10
+ :url => "/download_control/:id/:basename:no_original_style.:extension",
11
+ :path => ":rails_root/secure_downloads/:id/:basename:no_original_style.:extension"
12
+
13
+ validates_attachment_presence :document
14
+
15
+ def has_group?(group=nil)
16
+ return true if groups and group.nil?
17
+ return false if group.nil?
18
+ return groups.include?(group)
19
+ end
20
+
21
+ def available_to?(reader=nil)
22
+ permitted_groups = self.groups
23
+ return true if permitted_groups.empty?
24
+ return false if reader.nil?
25
+ return true if reader.is_admin?
26
+ return reader.in_any_of_these_groups?(permitted_groups)
27
+ end
28
+
29
+ def document_ok?
30
+ self.document.exists?
31
+ end
32
+
33
+ end
@@ -0,0 +1,47 @@
1
+ .form-area
2
+ = render_region :form_top
3
+ = hidden_field 'download', 'lock_version'
4
+
5
+ - render_region :form do |form|
6
+ - form.edit_title do
7
+ %p.title
8
+ = f.label :name
9
+ = f.text_field 'name', :maxlength => 100, :class => "textbox"
10
+
11
+ - form.edit_description do
12
+ %p.description
13
+ = f.label :description, "Description or introduction"
14
+ = f.text_area 'description', :size => '40x6', :style => 'width: 100%; height: 160px;', :class => "textarea"
15
+
16
+ - form.edit_document do
17
+ %p.document
18
+ = f.label :document, @download.document? ? "Replace document" : "Upload document"
19
+ = f.file_field :document
20
+ - if @download.document?
21
+ %small
22
+ Current file:
23
+ = link_to @download.document_file_name, download_url(@download)
24
+
25
+ - form.edit_access do
26
+ %p.access
27
+ = f.label :groups, "Available to"
28
+ - Group.find(:all).each do |group|
29
+ %span.checkbox
30
+ = check_box_tag "download[group_ids][]", group.id, @download.has_group?(group)
31
+ = group.name
32
+ %br
33
+ %span.formnote
34
+ If no group is ticked, any logged-in reader can download this file.
35
+
36
+ = javascript_tag "$('download_name').activate()"
37
+
38
+ - render_region :form_bottom do |form_bottom|
39
+ - form_bottom.edit_timestamp do
40
+ = updated_stamp @download
41
+ - form_bottom.edit_buttons do
42
+ %p.buttons
43
+ = save_model_button @download
44
+ = save_model_and_continue_editing_button @download
45
+ or
46
+ = link_to "Cancel", admin_downloads_url
47
+
@@ -0,0 +1,7 @@
1
+ - include_stylesheet('admin/downloads')
2
+ - render_region :main do |main|
3
+ - main.edit_header do
4
+ %h1 Edit Download
5
+ - main.edit_form do
6
+ - form_for :download, :url => admin_download_path(@download), :html => { :method => "put", :multipart => true } do |f|
7
+ = render :partial => 'form', :locals => { :f => f }
@@ -0,0 +1,32 @@
1
+ - include_stylesheet 'admin/downloads'
2
+
3
+ %h1 Authorised Downloads
4
+
5
+ %table#downloads.index{:cellpadding => "0", :cellspacing => "0", :border => "0"}
6
+ %thead
7
+ %tr
8
+ %th.download Title
9
+ %th.file File
10
+ %th.groups Visible to groups
11
+ %th.actions
12
+ %tbody
13
+ - if @downloads.empty?
14
+ %tr
15
+ %td.note{:colspan => "4"}
16
+ No downloads for you, sonny
17
+ - else
18
+ - @downloads.each do |dl|
19
+ %tr{:class => "node level-1"}
20
+ %td.download
21
+ = link_to dl.name, edit_admin_download_url(dl)
22
+ %td.file
23
+ = link_to dl.document_file_name, download_url(dl)
24
+ = number_to_human_size(dl.document_file_size)
25
+ %td.groups
26
+ = dl.groups.map {|g| link_to g.name, admin_group_url(g) }.join(', ')
27
+ %td.remove
28
+ = link_to( image('remove', :alt => 'Remove Download'), :action => 'destroy', :id => dl, :confirm => "are you sure you want to completely remove #{dl.name}?")
29
+
30
+
31
+ %p
32
+ = link_to(image('new-file', :alt => 'New download'), :action => 'new')
@@ -0,0 +1,7 @@
1
+ - include_stylesheet('admin/downloads')
2
+ - render_region :main do |main|
3
+ - main.edit_header do
4
+ %h1 New Download
5
+ - main.edit_form do
6
+ - form_for :download, :url => admin_downloads_path, :html => { :multipart => true } do |f|
7
+ = render :partial => "form", :locals => { :f => f }
@@ -0,0 +1,28 @@
1
+ class CreateDownloads < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :downloads do |t|
4
+ t.column :name, :string
5
+ t.column :description, :text
6
+ t.column :document_file_name, :string
7
+ t.column :document_content_type, :string
8
+ t.column :document_file_size, :integer
9
+ t.column :document_updated_at, :datetime
10
+ t.column :created_at, :datetime
11
+ t.column :updated_at, :datetime
12
+ t.column :created_by_id, :integer
13
+ t.column :updated_by_id, :integer
14
+ t.column :lock_version, :integer
15
+ t.column :site_id, :integer
16
+ end
17
+
18
+ create_table :downloads_groups, :id => false do |t|
19
+ t.column :download_id, :integer
20
+ t.column :group_id, :integer
21
+ end
22
+ end
23
+
24
+ def self.down
25
+ drop_table :downloads
26
+ drop_table :downloads_groups
27
+ end
28
+ end
@@ -0,0 +1,39 @@
1
+ # Uncomment this if you reference any of your controllers in activate
2
+ # require_dependency 'application'
3
+
4
+ class DownloadsExtension < Radiant::Extension
5
+ version "0.5"
6
+ description "Controlled file access using nginx's local redirects. Requires reader and reader_group extensions."
7
+ url "http://www.spanner.org/radiant/downloads"
8
+
9
+ define_routes do |map|
10
+ map.resources :downloads, :only => :show
11
+ map.namespace :admin, :path_prefix => 'admin/readers' do |admin|
12
+ admin.resources :downloads
13
+ end
14
+ end
15
+
16
+ extension_config do |config|
17
+ config.gem 'paperclip'
18
+ config.extension 'reader'
19
+ config.extension 'reader_group'
20
+ end
21
+
22
+ def activate
23
+ Group.send :include, DownloadGroup
24
+ Page.send :include, DownloadTags
25
+ UserActionObserver.instance.send :add_observer!, Download
26
+
27
+ if respond_to?(:tab)
28
+ tab("Content") do
29
+ add_item("Downloads", "/admin/readers/downloads")
30
+ end
31
+ else
32
+ admin.tabs.add "Downloads", "/admin/readers/downloads", :visibility => [:all]
33
+ end
34
+ end
35
+
36
+ def deactivate
37
+ end
38
+
39
+ end