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 +2 -0
- data/README.markdown +81 -0
- data/Rakefile +137 -0
- data/app/controllers/admin/downloads_controller.rb +3 -0
- data/app/controllers/downloads_controller.rb +21 -0
- data/app/models/download.rb +33 -0
- data/app/views/admin/downloads/_form.html.haml +47 -0
- data/app/views/admin/downloads/edit.html.haml +7 -0
- data/app/views/admin/downloads/index.html.haml +32 -0
- data/app/views/admin/downloads/new.html.haml +7 -0
- data/db/migrate/001_create_downloads.rb +28 -0
- data/downloads_extension.rb +39 -0
- data/lib/download_group.rb +7 -0
- data/lib/download_tags.rb +165 -0
- data/lib/download_ui.rb +38 -0
- data/lib/tasks/downloads_extension_tasks.rake +28 -0
- data/public/stylesheets/admin/downloads.css +25 -0
- data/spec/controllers/downloads_controller_spec.rb +73 -0
- data/spec/datasets/download_groups_dataset.rb +42 -0
- data/spec/datasets/download_readers_dataset.rb +49 -0
- data/spec/datasets/download_sites_dataset.rb +9 -0
- data/spec/datasets/downloads_dataset.rb +28 -0
- data/spec/files/test.pdf +681 -0
- data/spec/files/test.txt +1 -0
- data/spec/models/download_spec.rb +41 -0
- data/spec/models/group_spec.rb +20 -0
- data/spec/spec.opts +6 -0
- data/spec/spec_helper.rb +36 -0
- metadata +130 -0
data/.gitignore
ADDED
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,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
|