radiant-downloads-extension 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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