├── .gitignore ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── assets └── javascripts │ └── redmine_s3.js ├── config └── s3.yml.example ├── init.rb ├── lib ├── redmine_s3.rb ├── redmine_s3 │ ├── application_helper_patch.rb │ ├── attachment_patch.rb │ ├── attachments_controller_patch.rb │ ├── connection.rb │ └── thumbnail_patch.rb ├── redmine_s3_hooks.rb └── tasks │ └── files_to_s3.rake └── test └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swp 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | gem 'aws-sdk', '~> 1' 2 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | specs: 3 | aws-sdk (1.63.0) 4 | aws-sdk-v1 (= 1.63.0) 5 | aws-sdk-v1 (1.63.0) 6 | json (~> 1.4) 7 | nokogiri (>= 1.4.4) 8 | json (1.8.2) 9 | mini_portile (0.6.2) 10 | nokogiri (1.6.6.2) 11 | mini_portile (~> 0.6.0) 12 | 13 | PLATFORMS 14 | ruby 15 | 16 | DEPENDENCIES 17 | aws-sdk (~> 1) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # S3 plugin for Redmine 2 | 3 | ## Description 4 | This [Redmine](http://www.redmine.org) plugin makes file attachments be stored on [Amazon S3](http://aws.amazon.com/s3) rather than on the local filesystem. This is a fork for [original gem](http://github.com/tigrish/redmine_s3) and difference is that this one supports Redmine 2 5 | 6 | ## Installation 7 | 1. Make sure Redmine is installed and cd into it's root directory 8 | 2. `git clone git://github.com/ka8725/redmine_s3.git plugins/redmine_s3` 9 | 3. `cp plugins/redmine_s3/config/s3.yml.example config/s3.yml` 10 | 4. Edit config/s3.yml with your favourite editor 11 | 5. `bundle install --without development test` for installing this plugin dependencies (if you already did it, doing a `bundle install` again whould do no harm) 12 | 6. Restart mongrel/upload to production/whatever 13 | 7. *Optional*: Run `rake redmine_s3:files_to_s3` to upload files in your files folder to s3 14 | 8. `rm -Rf plugins/redmine_s3/.git` 15 | 16 | ## Options Overview 17 | * The bucket specified in s3.yml will be created automatically when the plugin is loaded (this is generally when the server starts). 18 | * *Deprecated* (no longer supported, specify endpoint option instead) If you have created a CNAME entry for your bucket set the cname_bucket option to true in s3.yml and your files will be served from that domain. 19 | * After files are uploaded they are made public, unless private is set to true. 20 | * Public and private files can use HTTPS urls using the secure option 21 | * Files can use private signed urls using the private option 22 | * Private file urls can expire a set time after the links were generated using the expires option 23 | * If you're using a Amazon S3 clone, then you can do the download relay by using the proxy option. 24 | 25 | ## Options Detail 26 | * access_key_id: string key (required) 27 | * secret_access_key: string key (required) 28 | * bucket: string bucket name (required) 29 | * folder: string folder name inside bucket (for example: 'attachments') 30 | * endpoint: string endpoint instead of s3.amazonaws.com 31 | * port: integer port number 32 | * ssl: boolean true/false 33 | * secure: boolean true/false 34 | * private: boolean true/false 35 | * expires: integer number of seconds for private links to expire after being generated 36 | * proxy: boolean true/false 37 | * thumb_folder: string folder where attachment thumbnails are stored; defaults to 'tmp' 38 | * Defaults to private: false, secure: false, proxy: false, default endpoint, default port, default ssl and default expires 39 | 40 | 41 | ## License 42 | 43 | This plugin is released under the [MIT License](http://www.opensource.org/licenses/MIT). 44 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'redmine_plugin_support' 3 | 4 | Dir[File.expand_path(File.dirname(__FILE__)) + "/lib/tasks/**/*.rake"].sort.each { |ext| load ext } 5 | 6 | RedminePluginSupport::Base.setup do |plugin| 7 | plugin.project_name = 'redmine_s3' 8 | plugin.default_task = [:test] 9 | plugin.tasks = [:doc, :release, :clean, :test] 10 | # TODO: gem not getting this automaticly 11 | plugin.redmine_root = File.expand_path(File.dirname(__FILE__) + '/../../../') 12 | end 13 | 14 | begin 15 | require 'jeweler' 16 | Jeweler::Tasks.new do |s| 17 | s.name = "redmine_s3" 18 | s.summary = "Plugin to have Redmine store uploads on S3" 19 | s.email = "edavis@littlestreamsoftware.com" 20 | s.homepage = "http://projects.tigrish.com/projects/redmine-s3" 21 | s.description = "Plugin to have Redmine store uploads on S3" 22 | s.authors = ["Christopher Dell"] 23 | s.rubyforge_project = "TODO" # TODO 24 | s.files = FileList[ 25 | "[A-Z]*", 26 | "init.rb", 27 | "rails/init.rb", 28 | "{bin,generators,lib,test,app,assets,config,lang}/**/*", 29 | 'lib/jeweler/templates/.gitignore' 30 | ] 31 | end 32 | Jeweler::GemcutterTasks.new 33 | Jeweler::RubyforgeTasks.new do |rubyforge| 34 | rubyforge.doc_task = "rdoc" 35 | end 36 | rescue LoadError 37 | puts "Jeweler, or one of its dependencies, is not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com" 38 | end 39 | 40 | 41 | # Adding Rails's database rake tasks, we need the db:test:reset one in 42 | # order to clear the test database. 43 | # TODO: extract to the redmine_plugin_support gem 44 | namespace :db do 45 | task :load_config => :rails_env do 46 | require 'active_record' 47 | ActiveRecord::Base.configurations = Rails::Configuration.new.database_configuration 48 | end 49 | 50 | namespace :create do 51 | desc 'Create all the local databases defined in config/database.yml' 52 | task :all => :load_config do 53 | ActiveRecord::Base.configurations.each_value do |config| 54 | # Skip entries that don't have a database key, such as the first entry here: 55 | # 56 | # defaults: &defaults 57 | # adapter: mysql 58 | # username: root 59 | # password: 60 | # host: localhost 61 | # 62 | # development: 63 | # database: blog_development 64 | # <<: *defaults 65 | next unless config['database'] 66 | # Only connect to local databases 67 | local_database?(config) { create_database(config) } 68 | end 69 | end 70 | end 71 | 72 | desc 'Create the database defined in config/database.yml for the current RAILS_ENV' 73 | task :create => :load_config do 74 | create_database(ActiveRecord::Base.configurations[RAILS_ENV]) 75 | end 76 | 77 | def create_database(config) 78 | begin 79 | if config['adapter'] =~ /sqlite/ 80 | if File.exist?(config['database']) 81 | $stderr.puts "#{config['database']} already exists" 82 | else 83 | begin 84 | # Create the SQLite database 85 | ActiveRecord::Base.establish_connection(config) 86 | ActiveRecord::Base.connection 87 | rescue 88 | $stderr.puts $!, *($!.backtrace) 89 | $stderr.puts "Couldn't create database for #{config.inspect}" 90 | end 91 | end 92 | return # Skip the else clause of begin/rescue 93 | else 94 | ActiveRecord::Base.establish_connection(config) 95 | ActiveRecord::Base.connection 96 | end 97 | rescue 98 | case config['adapter'] 99 | when 'mysql' 100 | @charset = ENV['CHARSET'] || 'utf8' 101 | @collation = ENV['COLLATION'] || 'utf8_unicode_ci' 102 | begin 103 | ActiveRecord::Base.establish_connection(config.merge('database' => nil)) 104 | ActiveRecord::Base.connection.create_database(config['database'], :charset => (config['charset'] || @charset), :collation => (config['collation'] || @collation)) 105 | ActiveRecord::Base.establish_connection(config) 106 | rescue 107 | $stderr.puts "Couldn't create database for #{config.inspect}, charset: #{config['charset'] || @charset}, collation: #{config['collation'] || @collation} (if you set the charset manually, make sure you have a matching collation)" 108 | end 109 | when 'postgresql' 110 | @encoding = config[:encoding] || ENV['CHARSET'] || 'utf8' 111 | begin 112 | ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public')) 113 | ActiveRecord::Base.connection.create_database(config['database'], config.merge('encoding' => @encoding)) 114 | ActiveRecord::Base.establish_connection(config) 115 | rescue 116 | $stderr.puts $!, *($!.backtrace) 117 | $stderr.puts "Couldn't create database for #{config.inspect}" 118 | end 119 | end 120 | else 121 | $stderr.puts "#{config['database']} already exists" 122 | end 123 | end 124 | 125 | namespace :drop do 126 | desc 'Drops all the local databases defined in config/database.yml' 127 | task :all => :load_config do 128 | ActiveRecord::Base.configurations.each_value do |config| 129 | # Skip entries that don't have a database key 130 | next unless config['database'] 131 | # Only connect to local databases 132 | local_database?(config) { drop_database(config) } 133 | end 134 | end 135 | end 136 | 137 | desc 'Drops the database for the current RAILS_ENV' 138 | task :drop => :load_config do 139 | config = ActiveRecord::Base.configurations[RAILS_ENV || 'development'] 140 | begin 141 | drop_database(config) 142 | rescue Exception => e 143 | puts "Couldn't drop #{config['database']} : #{e.inspect}" 144 | end 145 | end 146 | 147 | def local_database?(config, &block) 148 | if %w( 127.0.0.1 localhost ).include?(config['host']) || config['host'].blank? 149 | yield 150 | else 151 | puts "This task only modifies local databases. #{config['database']} is on a remote host." 152 | end 153 | end 154 | 155 | 156 | desc "Migrate the database through scripts in db/migrate and update db/schema.rb by invoking db:schema:dump. Target specific version with VERSION=x. Turn off output with VERBOSE=false." 157 | task :migrate => :environment do 158 | ActiveRecord::Migration.verbose = ENV["VERBOSE"] ? ENV["VERBOSE"] == "true" : true 159 | ActiveRecord::Migrator.migrate("db/migrate/", ENV["VERSION"] ? ENV["VERSION"].to_i : nil) 160 | Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby 161 | end 162 | 163 | namespace :migrate do 164 | desc 'Rollbacks the database one migration and re migrate up. If you want to rollback more than one step, define STEP=x. Target specific version with VERSION=x.' 165 | task :redo => :environment do 166 | if ENV["VERSION"] 167 | Rake::Task["db:migrate:down"].invoke 168 | Rake::Task["db:migrate:up"].invoke 169 | else 170 | Rake::Task["db:rollback"].invoke 171 | Rake::Task["db:migrate"].invoke 172 | end 173 | end 174 | 175 | desc 'Resets your database using your migrations for the current environment' 176 | task :reset => ["db:drop", "db:create", "db:migrate"] 177 | 178 | desc 'Runs the "up" for a given migration VERSION.' 179 | task :up => :environment do 180 | version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil 181 | raise "VERSION is required" unless version 182 | ActiveRecord::Migrator.run(:up, "db/migrate/", version) 183 | Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby 184 | end 185 | 186 | desc 'Runs the "down" for a given migration VERSION.' 187 | task :down => :environment do 188 | version = ENV["VERSION"] ? ENV["VERSION"].to_i : nil 189 | raise "VERSION is required" unless version 190 | ActiveRecord::Migrator.run(:down, "db/migrate/", version) 191 | Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby 192 | end 193 | end 194 | 195 | desc 'Rolls the schema back to the previous version. Specify the number of steps with STEP=n' 196 | task :rollback => :environment do 197 | step = ENV['STEP'] ? ENV['STEP'].to_i : 1 198 | ActiveRecord::Migrator.rollback('db/migrate/', step) 199 | Rake::Task["db:schema:dump"].invoke if ActiveRecord::Base.schema_format == :ruby 200 | end 201 | 202 | desc 'Drops and recreates the database from db/schema.rb for the current environment and loads the seeds.' 203 | task :reset => [ 'db:drop', 'db:setup' ] 204 | 205 | desc "Retrieves the charset for the current environment's database" 206 | task :charset => :environment do 207 | config = ActiveRecord::Base.configurations[RAILS_ENV || 'development'] 208 | case config['adapter'] 209 | when 'mysql' 210 | ActiveRecord::Base.establish_connection(config) 211 | puts ActiveRecord::Base.connection.charset 212 | when 'postgresql' 213 | ActiveRecord::Base.establish_connection(config) 214 | puts ActiveRecord::Base.connection.encoding 215 | else 216 | puts 'sorry, your database adapter is not supported yet, feel free to submit a patch' 217 | end 218 | end 219 | 220 | desc "Retrieves the collation for the current environment's database" 221 | task :collation => :environment do 222 | config = ActiveRecord::Base.configurations[RAILS_ENV || 'development'] 223 | case config['adapter'] 224 | when 'mysql' 225 | ActiveRecord::Base.establish_connection(config) 226 | puts ActiveRecord::Base.connection.collation 227 | else 228 | puts 'sorry, your database adapter is not supported yet, feel free to submit a patch' 229 | end 230 | end 231 | 232 | desc "Retrieves the current schema version number" 233 | task :version => :environment do 234 | puts "Current version: #{ActiveRecord::Migrator.current_version}" 235 | end 236 | 237 | desc "Raises an error if there are pending migrations" 238 | task :abort_if_pending_migrations => :environment do 239 | if defined? ActiveRecord 240 | pending_migrations = ActiveRecord::Migrator.new(:up, 'db/migrate').pending_migrations 241 | 242 | if pending_migrations.any? 243 | puts "You have #{pending_migrations.size} pending migrations:" 244 | pending_migrations.each do |pending_migration| 245 | puts ' %4d %s' % [pending_migration.version, pending_migration.name] 246 | end 247 | abort %{Run "rake db:migrate" to update your database then try again.} 248 | end 249 | end 250 | end 251 | 252 | desc 'Create the database, load the schema, and initialize with the seed data' 253 | task :setup => [ 'db:create', 'db:schema:load', 'db:seed' ] 254 | 255 | desc 'Load the seed data from db/seeds.rb' 256 | task :seed => :environment do 257 | seed_file = File.join(Rails.root, 'db', 'seeds.rb') 258 | load(seed_file) if File.exist?(seed_file) 259 | end 260 | 261 | namespace :fixtures do 262 | desc "Load fixtures into the current environment's database. Load specific fixtures using FIXTURES=x,y. Load from subdirectory in test/fixtures using FIXTURES_DIR=z. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." 263 | task :load => :environment do 264 | require 'active_record/fixtures' 265 | ActiveRecord::Base.establish_connection(Rails.env) 266 | base_dir = ENV['FIXTURES_PATH'] ? File.join(Rails.root, ENV['FIXTURES_PATH']) : File.join(Rails.root, 'test', 'fixtures') 267 | fixtures_dir = ENV['FIXTURES_DIR'] ? File.join(base_dir, ENV['FIXTURES_DIR']) : base_dir 268 | 269 | (ENV['FIXTURES'] ? ENV['FIXTURES'].split(/,/).map {|f| File.join(fixtures_dir, f) } : Dir.glob(File.join(fixtures_dir, '*.{yml,csv}'))).each do |fixture_file| 270 | Fixtures.create_fixtures(File.dirname(fixture_file), File.basename(fixture_file, '.*')) 271 | end 272 | end 273 | 274 | desc "Search for a fixture given a LABEL or ID. Specify an alternative path (eg. spec/fixtures) using FIXTURES_PATH=spec/fixtures." 275 | task :identify => :environment do 276 | require "active_record/fixtures" 277 | 278 | label, id = ENV["LABEL"], ENV["ID"] 279 | raise "LABEL or ID required" if label.blank? && id.blank? 280 | 281 | puts %Q(The fixture ID for "#{label}" is #{Fixtures.identify(label)}.) if label 282 | 283 | base_dir = ENV['FIXTURES_PATH'] ? File.join(Rails.root, ENV['FIXTURES_PATH']) : File.join(Rails.root, 'test', 'fixtures') 284 | Dir["#{base_dir}/**/*.yml"].each do |file| 285 | if data = YAML::load(ERB.new(IO.read(file)).result) 286 | data.keys.each do |key| 287 | key_id = Fixtures.identify(key) 288 | 289 | if key == label || key_id == id.to_i 290 | puts "#{file}: #{key} (#{key_id})" 291 | end 292 | end 293 | end 294 | end 295 | end 296 | end 297 | 298 | namespace :schema do 299 | desc "Create a db/schema.rb file that can be portably used against any DB supported by AR" 300 | task :dump => :environment do 301 | require 'active_record/schema_dumper' 302 | File.open(ENV['SCHEMA'] || "#{RAILS_ROOT}/db/schema.rb", "w") do |file| 303 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, file) 304 | end 305 | Rake::Task["db:schema:dump"].reenable 306 | end 307 | 308 | desc "Load a schema.rb file into the database" 309 | task :load => :environment do 310 | file = ENV['SCHEMA'] || "#{RAILS_ROOT}/db/schema.rb" 311 | if File.exists?(file) 312 | load(file) 313 | else 314 | abort %{#{file} doesn't exist yet. Run "rake db:migrate" to create it then try again. If you do not intend to use a database, you should instead alter #{RAILS_ROOT}/config/environment.rb to prevent active_record from loading: config.frameworks -= [ :active_record ]} 315 | end 316 | end 317 | end 318 | 319 | namespace :structure do 320 | desc "Dump the database structure to a SQL file" 321 | task :dump => :environment do 322 | abcs = ActiveRecord::Base.configurations 323 | case abcs[RAILS_ENV]["adapter"] 324 | when "mysql", "oci", "oracle" 325 | ActiveRecord::Base.establish_connection(abcs[RAILS_ENV]) 326 | File.open("#{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql", "w+") { |f| f << ActiveRecord::Base.connection.structure_dump } 327 | when "postgresql" 328 | ENV['PGHOST'] = abcs[RAILS_ENV]["host"] if abcs[RAILS_ENV]["host"] 329 | ENV['PGPORT'] = abcs[RAILS_ENV]["port"].to_s if abcs[RAILS_ENV]["port"] 330 | ENV['PGPASSWORD'] = abcs[RAILS_ENV]["password"].to_s if abcs[RAILS_ENV]["password"] 331 | search_path = abcs[RAILS_ENV]["schema_search_path"] 332 | search_path = "--schema=#{search_path}" if search_path 333 | `pg_dump -i -U "#{abcs[RAILS_ENV]["username"]}" -s -x -O -f db/#{RAILS_ENV}_structure.sql #{search_path} #{abcs[RAILS_ENV]["database"]}` 334 | raise "Error dumping database" if $?.exitstatus == 1 335 | when "sqlite", "sqlite3" 336 | dbfile = abcs[RAILS_ENV]["database"] || abcs[RAILS_ENV]["dbfile"] 337 | `#{abcs[RAILS_ENV]["adapter"]} #{dbfile} .schema > db/#{RAILS_ENV}_structure.sql` 338 | when "sqlserver" 339 | `scptxfr /s #{abcs[RAILS_ENV]["host"]} /d #{abcs[RAILS_ENV]["database"]} /I /f db\\#{RAILS_ENV}_structure.sql /q /A /r` 340 | `scptxfr /s #{abcs[RAILS_ENV]["host"]} /d #{abcs[RAILS_ENV]["database"]} /I /F db\ /q /A /r` 341 | when "firebird" 342 | set_firebird_env(abcs[RAILS_ENV]) 343 | db_string = firebird_db_string(abcs[RAILS_ENV]) 344 | sh "isql -a #{db_string} > #{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql" 345 | else 346 | raise "Task not supported by '#{abcs["test"]["adapter"]}'" 347 | end 348 | 349 | if ActiveRecord::Base.connection.supports_migrations? 350 | File.open("#{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql", "a") { |f| f << ActiveRecord::Base.connection.dump_schema_information } 351 | end 352 | end 353 | end 354 | 355 | namespace :test do 356 | desc "Recreate the test database from the current schema.rb" 357 | task :load => 'db:test:purge' do 358 | ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test']) 359 | ActiveRecord::Schema.verbose = false 360 | Rake::Task["db:schema:load"].invoke 361 | end 362 | 363 | desc "Recreate the test database from the current environment's database schema" 364 | task :clone => %w(db:schema:dump db:test:load) 365 | 366 | desc "Recreate the test databases from the development structure" 367 | task :clone_structure => [ "db:structure:dump", "db:test:purge" ] do 368 | abcs = ActiveRecord::Base.configurations 369 | case abcs["test"]["adapter"] 370 | when "mysql" 371 | ActiveRecord::Base.establish_connection(:test) 372 | ActiveRecord::Base.connection.execute('SET foreign_key_checks = 0') 373 | IO.readlines("#{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql").join.split("\n\n").each do |table| 374 | ActiveRecord::Base.connection.execute(table) 375 | end 376 | when "postgresql" 377 | ENV['PGHOST'] = abcs["test"]["host"] if abcs["test"]["host"] 378 | ENV['PGPORT'] = abcs["test"]["port"].to_s if abcs["test"]["port"] 379 | ENV['PGPASSWORD'] = abcs["test"]["password"].to_s if abcs["test"]["password"] 380 | `psql -U "#{abcs["test"]["username"]}" -f #{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql #{abcs["test"]["database"]}` 381 | when "sqlite", "sqlite3" 382 | dbfile = abcs["test"]["database"] || abcs["test"]["dbfile"] 383 | `#{abcs["test"]["adapter"]} #{dbfile} < #{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql` 384 | when "sqlserver" 385 | `osql -E -S #{abcs["test"]["host"]} -d #{abcs["test"]["database"]} -i db\\#{RAILS_ENV}_structure.sql` 386 | when "oci", "oracle" 387 | ActiveRecord::Base.establish_connection(:test) 388 | IO.readlines("#{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql").join.split(";\n\n").each do |ddl| 389 | ActiveRecord::Base.connection.execute(ddl) 390 | end 391 | when "firebird" 392 | set_firebird_env(abcs["test"]) 393 | db_string = firebird_db_string(abcs["test"]) 394 | sh "isql -i #{RAILS_ROOT}/db/#{RAILS_ENV}_structure.sql #{db_string}" 395 | else 396 | raise "Task not supported by '#{abcs["test"]["adapter"]}'" 397 | end 398 | end 399 | 400 | desc "Empty the test database" 401 | task :purge => :environment do 402 | abcs = ActiveRecord::Base.configurations 403 | case abcs["test"]["adapter"] 404 | when "mysql" 405 | ActiveRecord::Base.establish_connection(:test) 406 | ActiveRecord::Base.connection.recreate_database(abcs["test"]["database"], abcs["test"]) 407 | when "postgresql" 408 | ActiveRecord::Base.clear_active_connections! 409 | drop_database(abcs['test']) 410 | create_database(abcs['test']) 411 | when "sqlite","sqlite3" 412 | dbfile = abcs["test"]["database"] || abcs["test"]["dbfile"] 413 | File.delete(dbfile) if File.exist?(dbfile) 414 | when "sqlserver" 415 | dropfkscript = "#{abcs["test"]["host"]}.#{abcs["test"]["database"]}.DP1".gsub(/\\/,'-') 416 | `osql -E -S #{abcs["test"]["host"]} -d #{abcs["test"]["database"]} -i db\\#{dropfkscript}` 417 | `osql -E -S #{abcs["test"]["host"]} -d #{abcs["test"]["database"]} -i db\\#{RAILS_ENV}_structure.sql` 418 | when "oci", "oracle" 419 | ActiveRecord::Base.establish_connection(:test) 420 | ActiveRecord::Base.connection.structure_drop.split(";\n\n").each do |ddl| 421 | ActiveRecord::Base.connection.execute(ddl) 422 | end 423 | when "firebird" 424 | ActiveRecord::Base.establish_connection(:test) 425 | ActiveRecord::Base.connection.recreate_database! 426 | else 427 | raise "Task not supported by '#{abcs["test"]["adapter"]}'" 428 | end 429 | end 430 | 431 | desc 'Check for pending migrations and load the test schema' 432 | task :prepare => 'db:abort_if_pending_migrations' do 433 | if defined?(ActiveRecord) && !ActiveRecord::Base.configurations.blank? 434 | Rake::Task[{ :sql => "db:test:clone_structure", :ruby => "db:test:load" }[ActiveRecord::Base.schema_format]].invoke 435 | end 436 | end 437 | end 438 | 439 | namespace :sessions do 440 | desc "Creates a sessions migration for use with ActiveRecord::SessionStore" 441 | task :create => :environment do 442 | raise "Task unavailable to this database (no migration support)" unless ActiveRecord::Base.connection.supports_migrations? 443 | require 'rails_generator' 444 | require 'rails_generator/scripts/generate' 445 | Rails::Generator::Scripts::Generate.new.run(["session_migration", ENV["MIGRATION"] || "CreateSessions"]) 446 | end 447 | 448 | desc "Clear the sessions table" 449 | task :clear => :environment do 450 | ActiveRecord::Base.connection.execute "DELETE FROM #{session_table_name}" 451 | end 452 | end 453 | end 454 | 455 | def drop_database(config) 456 | case config['adapter'] 457 | when 'mysql' 458 | ActiveRecord::Base.establish_connection(config) 459 | ActiveRecord::Base.connection.drop_database config['database'] 460 | when /^sqlite/ 461 | FileUtils.rm(File.join(RAILS_ROOT, config['database'])) 462 | when 'postgresql' 463 | ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres', 'schema_search_path' => 'public')) 464 | ActiveRecord::Base.connection.drop_database config['database'] 465 | end 466 | end 467 | 468 | def session_table_name 469 | ActiveRecord::Base.pluralize_table_names ? :sessions : :session 470 | end 471 | 472 | def set_firebird_env(config) 473 | ENV["ISC_USER"] = config["username"].to_s if config["username"] 474 | ENV["ISC_PASSWORD"] = config["password"].to_s if config["password"] 475 | end 476 | 477 | def firebird_db_string(config) 478 | FireRuby::Database.db_string_for(config.symbolize_keys) 479 | end 480 | -------------------------------------------------------------------------------- /assets/javascripts/redmine_s3.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This script handles thumbnail image missing errors 3 | */ 4 | 5 | $(function() { 6 | $('.attachments .thumbnails img').one('error', function() { 7 | $.ajax({ 8 | dataType: 'JSON', 9 | url: $(this).attr('data-thumbnail') + '?update_thumb=true', 10 | context: this 11 | }).done(function(data, status, response) { 12 | $(this).attr('src', response.responseJSON.src); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /config/s3.yml.example: -------------------------------------------------------------------------------- 1 | production: 2 | access_key_id: 3 | secret_access_key: 4 | bucket: 5 | folder: 6 | endpoint: 7 | secure: 8 | private: 9 | expires: 10 | proxy: 11 | thumb_folder: 12 | 13 | development: 14 | access_key_id: 15 | secret_access_key: 16 | bucket: 17 | folder: 18 | endpoint: 19 | secure: 20 | private: 21 | expires: 22 | proxy: 23 | thumb_folder: 24 | 25 | # Uncomment this to run plugin tests with standart redmine script 26 | # test: 27 | # access_key_id: 28 | # secret_access_key: 29 | # bucket: 30 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'redmine_s3' 2 | 3 | require_dependency 'redmine_s3_hooks' 4 | 5 | Redmine::Plugin.register :redmine_s3 do 6 | name 'S3' 7 | author 'Chris Dell' 8 | description 'Use Amazon S3 as a storage engine for attachments' 9 | version '0.0.3' 10 | end 11 | -------------------------------------------------------------------------------- /lib/redmine_s3.rb: -------------------------------------------------------------------------------- 1 | require 'redmine_s3/attachment_patch' 2 | require 'redmine_s3/attachments_controller_patch' 3 | require 'redmine_s3/application_helper_patch' 4 | require 'redmine_s3/thumbnail_patch' 5 | require 'redmine_s3/connection' 6 | 7 | AttachmentsController.send(:include, RedmineS3::AttachmentsControllerPatch) 8 | Attachment.send(:include, RedmineS3::AttachmentPatch) 9 | ApplicationHelper.send(:include, RedmineS3::ApplicationHelperPatch) 10 | RedmineS3::Connection.create_bucket 11 | -------------------------------------------------------------------------------- /lib/redmine_s3/application_helper_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineS3 2 | module ApplicationHelperPatch 3 | def self.included(base) # :nodoc: 4 | base.send(:include, InstanceMethods) 5 | 6 | base.class_eval do 7 | unloadable # Send unloadable so it will not be unloaded in development 8 | 9 | alias_method_chain :thumbnail_tag, :s3_patch 10 | end 11 | end 12 | 13 | module InstanceMethods 14 | def thumbnail_tag_with_s3_patch(attachment) 15 | link_to image_tag(attachment.thumbnail_s3, data: {thumbnail: thumbnail_path(attachment)}), 16 | RedmineS3::Connection.object_url(attachment.disk_filename_s3), 17 | title: attachment.filename 18 | end 19 | end 20 | end 21 | end -------------------------------------------------------------------------------- /lib/redmine_s3/attachment_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineS3 2 | module AttachmentPatch 3 | def self.included(base) # :nodoc: 4 | base.extend(ClassMethods) 5 | base.send(:include, InstanceMethods) 6 | 7 | # Same as typing in the class 8 | base.class_eval do 9 | unloadable # Send unloadable so it will not be unloaded in development 10 | attr_accessor :s3_access_key_id, :s3_secret_acces_key, :s3_bucket, :s3_bucket 11 | after_validation :put_to_s3 12 | after_create :generate_thumbnail_s3 13 | before_destroy :delete_from_s3 14 | end 15 | end 16 | 17 | module ClassMethods 18 | end 19 | 20 | module InstanceMethods 21 | def put_to_s3 22 | if @temp_file && (@temp_file.size > 0) && errors.blank? 23 | self.disk_directory = disk_directory || target_directory 24 | self.disk_filename = Attachment.disk_filename(filename, disk_directory) if disk_filename.blank? 25 | logger.debug("Uploading to #{disk_filename}") 26 | RedmineS3::Connection.put(disk_filename_s3, filename, @temp_file, self.content_type) 27 | self.digest = Time.now.to_i.to_s 28 | end 29 | @temp_file = nil # so that the model's original after_save block skips writing to the fs 30 | end 31 | 32 | def delete_from_s3 33 | logger.debug("Deleting #{disk_filename_s3}") 34 | RedmineS3::Connection.delete(disk_filename_s3) 35 | end 36 | 37 | # Prevent file uploading to the file system to avoid change file name 38 | def files_to_final_location; end 39 | 40 | # Returns the full path the attachment thumbnail, or nil 41 | # if the thumbnail cannot be generated. 42 | def thumbnail_s3(options = {}) 43 | return unless thumbnailable? 44 | size = options[:size].to_i 45 | if size > 0 46 | # Limit the number of thumbnails per image 47 | size = (size / 50) * 50 48 | # Maximum thumbnail size 49 | size = 800 if size > 800 50 | else 51 | size = Setting.thumbnails_size.to_i 52 | end 53 | size = 100 unless size > 0 54 | target = "#{id}_#{digest}_#{size}.thumb" 55 | update_thumb = options[:update_thumb] || false 56 | begin 57 | RedmineS3::ThumbnailPatch.generate_s3_thumb(self.disk_filename_s3, target, size, update_thumb) 58 | rescue => e 59 | logger.error "An error occured while generating thumbnail for #{disk_filename_s3} to #{target}\nException was: #{e.message}" if logger 60 | return 61 | end 62 | end 63 | 64 | def disk_filename_s3 65 | path = disk_filename 66 | path = File.join(disk_directory, path) unless disk_directory.blank? 67 | path 68 | end 69 | 70 | def generate_thumbnail_s3 71 | thumbnail_s3(update_thumb: true) 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/redmine_s3/attachments_controller_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineS3 2 | module AttachmentsControllerPatch 3 | def self.included(base) # :nodoc: 4 | base.extend(ClassMethods) 5 | base.send(:include, InstanceMethods) 6 | 7 | # Same as typing in the class 8 | base.class_eval do 9 | unloadable # Send unloadable so it will not be unloaded in development 10 | before_filter :find_attachment_s3, :only => [:show] 11 | before_filter :download_attachment_s3, :only => [:download] 12 | before_filter :find_thumbnail_attachment_s3, :only => [:thumbnail] 13 | before_filter :find_editable_attachments_s3, :only => [:edit, :update] 14 | skip_before_filter :file_readable 15 | end 16 | end 17 | 18 | module ClassMethods 19 | end 20 | 21 | module InstanceMethods 22 | def find_attachment_s3 23 | if @attachment.is_diff? 24 | @diff = RedmineS3::Connection.get(@attachment.disk_filename_s3) 25 | @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline' 26 | @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type) 27 | # Save diff type as user preference 28 | if User.current.logged? && @diff_type != User.current.pref[:diff_type] 29 | User.current.pref[:diff_type] = @diff_type 30 | User.current.preference.save 31 | end 32 | render :action => 'diff' 33 | elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte 34 | @content = RedmineS3::Connection.get(@attachment.disk_filename_s3) 35 | render :action => 'file' 36 | else 37 | download_attachment_s3 38 | end 39 | end 40 | 41 | def download_attachment_s3 42 | if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project) 43 | @attachment.increment_download 44 | end 45 | if RedmineS3::Connection.proxy? 46 | send_data RedmineS3::Connection.get(@attachment.disk_filename_s3), 47 | :filename => filename_for_content_disposition(@attachment.filename), 48 | :type => detect_content_type(@attachment), 49 | :disposition => (@attachment.image? ? 'inline' : 'attachment') 50 | else 51 | redirect_to(RedmineS3::Connection.object_url(@attachment.disk_filename_s3)) 52 | end 53 | end 54 | 55 | def find_editable_attachments_s3 56 | if @attachments 57 | @attachments.each { |a| a.increment_download } 58 | end 59 | if RedmineS3::Connection.proxy? 60 | @attachments.each do |attachment| 61 | send_data RedmineS3::Connection.get(attachment.disk_filename_s3), 62 | :filename => filename_for_content_disposition(attachment.filename), 63 | :type => detect_content_type(attachment), 64 | :disposition => (attachment.image? ? 'inline' : 'attachment') 65 | end 66 | end 67 | end 68 | 69 | def find_thumbnail_attachment_s3 70 | update_thumb = 'true' == params[:update_thumb] 71 | url = @attachment.thumbnail_s3(update_thumb: update_thumb) 72 | return render json: {src: url} if update_thumb 73 | return if url.nil? 74 | if RedmineS3::Connection.proxy? 75 | send_data RedmineS3::Connection.get(url, ''), 76 | :filename => filename_for_content_disposition(@attachment.filename), 77 | :type => detect_content_type(@attachment), 78 | :disposition => (@attachment.image? ? 'inline' : 'attachment') 79 | else 80 | redirect_to(url) 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/redmine_s3/connection.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk' 2 | 3 | AWS.config(:ssl_verify_peer => false) 4 | 5 | module RedmineS3 6 | class Connection 7 | @@conn = nil 8 | @@s3_options = { 9 | :access_key_id => nil, 10 | :secret_access_key => nil, 11 | :bucket => nil, 12 | :folder => '', 13 | :endpoint => nil, 14 | :port => nil, 15 | :ssl => nil, 16 | :private => false, 17 | :expires => nil, 18 | :secure => false, 19 | :proxy => false, 20 | :thumb_folder => 'tmp' 21 | } 22 | 23 | class << self 24 | def load_options 25 | file = ERB.new( File.read(File.join(Rails.root, 'config', 's3.yml')) ).result 26 | YAML::load( file )[Rails.env].each do |key, value| 27 | @@s3_options[key.to_sym] = value 28 | end 29 | end 30 | 31 | def establish_connection 32 | load_options unless @@s3_options[:access_key_id] && @@s3_options[:secret_access_key] 33 | options = { 34 | :access_key_id => @@s3_options[:access_key_id], 35 | :secret_access_key => @@s3_options[:secret_access_key] 36 | } 37 | options[:s3_endpoint] = self.endpoint unless self.endpoint.nil? 38 | options[:s3_port] = self.port unless self.port.nil? 39 | options[:use_ssl] = self.ssl unless self.ssl.nil? 40 | @conn = AWS::S3.new(options) 41 | end 42 | 43 | def conn 44 | @@conn || establish_connection 45 | end 46 | 47 | def bucket 48 | load_options unless @@s3_options[:bucket] 49 | @@s3_options[:bucket] 50 | end 51 | 52 | def create_bucket 53 | bucket = self.conn.buckets[self.bucket] 54 | self.conn.buckets.create(self.bucket) unless bucket.exists? 55 | end 56 | 57 | def folder 58 | str = @@s3_options[:folder] 59 | if str.present? 60 | str.match(/\S+\//) ? str : "#{str}/" 61 | else 62 | '' 63 | end 64 | end 65 | 66 | def endpoint 67 | @@s3_options[:endpoint] 68 | end 69 | 70 | def port 71 | @@s3_options[:port] 72 | end 73 | 74 | def ssl 75 | @@s3_options[:ssl] 76 | end 77 | 78 | def expires 79 | @@s3_options[:expires] 80 | end 81 | 82 | def private? 83 | @@s3_options[:private] 84 | end 85 | 86 | def secure? 87 | @@s3_options[:secure] 88 | end 89 | 90 | def proxy? 91 | @@s3_options[:proxy] 92 | end 93 | 94 | def thumb_folder 95 | str = @@s3_options[:thumb_folder] 96 | if str.present? 97 | str.match(/\S+\//) ? str : "#{str}/" 98 | else 99 | 'tmp/' 100 | end 101 | end 102 | 103 | def object(filename, target_folder = self.folder) 104 | bucket = self.conn.buckets[self.bucket] 105 | bucket.objects[target_folder + filename] 106 | end 107 | 108 | def put(disk_filename, original_filename, data, content_type='application/octet-stream', target_folder = self.folder) 109 | object = self.object(disk_filename, target_folder) 110 | options = {} 111 | options[:acl] = :public_read unless self.private? 112 | options[:content_type] = content_type if content_type 113 | options[:content_disposition] = "inline; filename=#{ERB::Util.url_encode(original_filename)}" 114 | object.write(data, options) 115 | end 116 | 117 | def delete(filename, target_folder = self.folder) 118 | object = self.object(filename, target_folder) 119 | object.delete 120 | end 121 | 122 | def object_url(filename, target_folder = self.folder) 123 | object = self.object(filename, target_folder) 124 | if self.private? 125 | options = {:secure => self.secure?} 126 | options[:expires] = self.expires unless self.expires.nil? 127 | object.url_for(:read, options).to_s 128 | else 129 | object.public_url(:secure => self.secure?).to_s 130 | end 131 | end 132 | 133 | def get(filename, target_folder = self.folder) 134 | object = self.object(filename, target_folder) 135 | object.read 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /lib/redmine_s3/thumbnail_patch.rb: -------------------------------------------------------------------------------- 1 | module RedmineS3 2 | module ThumbnailPatch 3 | # Generates a thumbnail for the source image to target 4 | def self.generate_s3_thumb(source, target, size, update_thumb = false) 5 | target_folder = RedmineS3::Connection.thumb_folder 6 | if update_thumb 7 | return unless Object.const_defined?(:Magick) 8 | require 'open-uri' 9 | img = Magick::ImageList.new 10 | url = RedmineS3::Connection.object_url(source) 11 | open(url, 'rb') do |f| img = img.from_blob(f.read) end 12 | img = img.strip! 13 | img = img.resize_to_fit(size) 14 | 15 | RedmineS3::Connection.put(target, File.basename(target), img.to_blob, img.mime_type, target_folder) 16 | end 17 | RedmineS3::Connection.object_url(target, target_folder) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/redmine_s3_hooks.rb: -------------------------------------------------------------------------------- 1 | # This class hooks into Redmine's View Listeners in order to add content to the page 2 | class RedmineS3Hooks < Redmine::Hook::ViewListener 3 | 4 | def view_layouts_base_html_head(context = {}) 5 | javascript_include_tag 'redmine_s3.js', :plugin => 'redmine_s3' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/tasks/files_to_s3.rake: -------------------------------------------------------------------------------- 1 | namespace :redmine_s3 do 2 | task :files_to_s3 => :environment do 3 | require 'thread' 4 | 5 | def s3_file_data(file_path) 6 | target = filename = File.basename(file_path) 7 | attachment = Attachment.find_by_disk_filename(File.basename(target)) 8 | unless attachment.nil? 9 | target = File.join(attachment.disk_directory, target) unless attachment.disk_directory.blank? 10 | filename = attachment.filename unless attachment.filename.blank? 11 | end 12 | {source: file_path, target: target, filename: filename} 13 | end 14 | 15 | # updates a single file on s3 16 | def update_file_on_s3(data, objects) 17 | source = data[:source] 18 | target = data[:target] 19 | filename = data[:filename] 20 | object = objects[RedmineS3::Connection.folder + target] 21 | return if target.nil? 22 | # get the file modified time, which will stay nil if the file doesn't exist yet 23 | # we could check if the file exists, but this saves a head request 24 | s3_mtime = object.last_modified rescue nil 25 | 26 | # put it on s3 if the file has been updated or it doesn't exist on s3 yet 27 | if s3_mtime.nil? || s3_mtime < File.mtime(source) 28 | file_obj = File.open(source, 'r') 29 | if Setting.attachment_max_size.to_i.kilobytes <= file_obj.size 30 | puts "File #{target} cannot be uploaded because it exceeds the maximum allowed file size (#{Setting.attachment_max_size.to_i.kilobytes}) " 31 | return 32 | end 33 | default_content_type = 'application/octet-stream' 34 | content_type = IO.popen(["file", "--brief", "--mime-type", file_obj.path], in: :close, err: :close) { |io| io.read.chomp } || default_content_type rescue default_content_type 35 | RedmineS3::Connection.put(target, filename, file_obj.read, content_type) 36 | file_obj.close 37 | 38 | puts "Put file #{target}" 39 | else 40 | puts File.basename(source) + ' is up-to-date on S3' 41 | end 42 | end 43 | 44 | # enqueue all of the files to be "worked" on 45 | file_q = Queue.new 46 | storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files") 47 | Dir.glob(File.join(storage_path,'**/*')).each do |file| 48 | file_q << s3_file_data(file) if File.file? file 49 | end 50 | 51 | # init the connection, and grab the ObjectCollection object for the bucket 52 | conn = RedmineS3::Connection.establish_connection 53 | objects = conn.buckets[RedmineS3::Connection.bucket].objects 54 | 55 | # create some threads to start syncing all of the queued files with s3 56 | threads = Array.new 57 | 8.times do 58 | threads << Thread.new do 59 | while !file_q.empty? 60 | update_file_on_s3(file_q.pop, objects) 61 | end 62 | end 63 | end 64 | 65 | # wait on all of the threads to finish 66 | threads.each do |thread| 67 | thread.join 68 | end 69 | 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Load the normal Rails helper 2 | require File.expand_path(File.dirname(__FILE__) + '/../../../../test/test_helper') 3 | 4 | # Ensure that we are using the temporary fixture path 5 | Engines::Testing.set_fixture_path 6 | --------------------------------------------------------------------------------