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