sq-asset_sync 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +15 -0
  3. data/.ruby-version +1 -0
  4. data/.travis.yml +42 -0
  5. data/Appraisals +15 -0
  6. data/CHANGELOG.md +738 -0
  7. data/Gemfile +7 -0
  8. data/README.md +466 -0
  9. data/Rakefile +44 -0
  10. data/asset_sync.gemspec +38 -0
  11. data/docs/heroku.md +36 -0
  12. data/gemfiles/rails_3.1.gemfile +10 -0
  13. data/gemfiles/rails_3.2.gemfile +10 -0
  14. data/gemfiles/rails_4.0.gemfile +10 -0
  15. data/gemfiles/rails_4.1.gemfile +10 -0
  16. data/kochiku.yml +7 -0
  17. data/lib/asset_sync.rb +15 -0
  18. data/lib/asset_sync/asset_sync.rb +73 -0
  19. data/lib/asset_sync/config.rb +226 -0
  20. data/lib/asset_sync/engine.rb +53 -0
  21. data/lib/asset_sync/multi_mime.rb +16 -0
  22. data/lib/asset_sync/railtie.rb +5 -0
  23. data/lib/asset_sync/storage.rb +291 -0
  24. data/lib/asset_sync/version.rb +3 -0
  25. data/lib/generators/asset_sync/install_generator.rb +67 -0
  26. data/lib/generators/asset_sync/templates/asset_sync.rb +41 -0
  27. data/lib/generators/asset_sync/templates/asset_sync.yml +43 -0
  28. data/lib/tasks/asset_sync.rake +30 -0
  29. data/script/ci +31 -0
  30. data/spec/dummy_app/Rakefile +30 -0
  31. data/spec/dummy_app/app/assets/javascripts/application.js +1 -0
  32. data/spec/fixtures/aws_with_yml/config/asset_sync.yml +25 -0
  33. data/spec/fixtures/google_with_yml/config/asset_sync.yml +19 -0
  34. data/spec/fixtures/rackspace_with_yml/config/asset_sync.yml +20 -0
  35. data/spec/fixtures/with_invalid_yml/config/asset_sync.yml +24 -0
  36. data/spec/integration/aws_integration_spec.rb +77 -0
  37. data/spec/spec_helper.rb +64 -0
  38. data/spec/unit/asset_sync_spec.rb +257 -0
  39. data/spec/unit/google_spec.rb +142 -0
  40. data/spec/unit/multi_mime_spec.rb +48 -0
  41. data/spec/unit/rackspace_spec.rb +90 -0
  42. data/spec/unit/railsless_spec.rb +72 -0
  43. data/spec/unit/storage_spec.rb +244 -0
  44. metadata +248 -0
@@ -0,0 +1,53 @@
1
+ module AssetSync
2
+ class Engine < Rails::Engine
3
+
4
+ engine_name "asset_sync"
5
+
6
+ initializer "asset_sync config", :group => :all do |app|
7
+ app_initializer = Rails.root.join('config', 'initializers', 'asset_sync.rb').to_s
8
+ app_yaml = Rails.root.join('config', 'asset_sync.yml').to_s
9
+
10
+ if File.exist?( app_initializer )
11
+ AssetSync.log "AssetSync: using #{app_initializer}"
12
+ load app_initializer
13
+ elsif !File.exist?( app_initializer ) && !File.exist?( app_yaml )
14
+ AssetSync.log "AssetSync: using default configuration from built-in initializer"
15
+ AssetSync.configure do |config|
16
+ config.fog_provider = ENV['FOG_PROVIDER'] if ENV.has_key?('FOG_PROVIDER')
17
+ config.fog_directory = ENV['FOG_DIRECTORY'] if ENV.has_key?('FOG_DIRECTORY')
18
+ config.fog_region = ENV['FOG_REGION'] if ENV.has_key?('FOG_REGION')
19
+
20
+ config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID'] if ENV.has_key?('AWS_ACCESS_KEY_ID')
21
+ config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY'] if ENV.has_key?('AWS_SECRET_ACCESS_KEY')
22
+ config.aws_reduced_redundancy = ENV['AWS_REDUCED_REDUNDANCY'] == true if ENV.has_key?('AWS_REDUCED_REDUNDANCY')
23
+
24
+ config.rackspace_username = ENV['RACKSPACE_USERNAME'] if ENV.has_key?('RACKSPACE_USERNAME')
25
+ config.rackspace_api_key = ENV['RACKSPACE_API_KEY'] if ENV.has_key?('RACKSPACE_API_KEY')
26
+
27
+ config.google_storage_access_key_id = ENV['GOOGLE_STORAGE_ACCESS_KEY_ID'] if ENV.has_key?('GOOGLE_STORAGE_ACCESS_KEY_ID')
28
+ config.google_storage_secret_access_key = ENV['GOOGLE_STORAGE_SECRET_ACCESS_KEY'] if ENV.has_key?('GOOGLE_STORAGE_SECRET_ACCESS_KEY')
29
+
30
+ config.enabled = (ENV['ASSET_SYNC_ENABLED'] == 'true') if ENV.has_key?('ASSET_SYNC_ENABLED')
31
+
32
+ config.existing_remote_files = ENV['ASSET_SYNC_EXISTING_REMOTE_FILES'] || "keep"
33
+
34
+ config.gzip_compression = (ENV['ASSET_SYNC_GZIP_COMPRESSION'] == 'true') if ENV.has_key?('ASSET_SYNC_GZIP_COMPRESSION')
35
+ config.manifest = (ENV['ASSET_SYNC_MANIFEST'] == 'true') if ENV.has_key?('ASSET_SYNC_MANIFEST')
36
+ end
37
+
38
+ config.prefix = ENV['ASSET_SYNC_PREFIX'] if ENV.has_key?('ASSET_SYNC_PREFIX')
39
+
40
+ config.existing_remote_files = ENV['ASSET_SYNC_EXISTING_REMOTE_FILES'] || "keep"
41
+
42
+ config.gzip_compression = (ENV['ASSET_SYNC_GZIP_COMPRESSION'] == 'true') if ENV.has_key?('ASSET_SYNC_GZIP_COMPRESSION')
43
+ config.manifest = (ENV['ASSET_SYNC_MANIFEST'] == 'true') if ENV.has_key?('ASSET_SYNC_MANIFEST')
44
+
45
+ end
46
+
47
+ if File.exist?( app_yaml )
48
+ AssetSync.log "AssetSync: YAML file found #{app_yaml} settings will be merged into the configuration"
49
+ end
50
+ end
51
+
52
+ end
53
+ end
@@ -0,0 +1,16 @@
1
+ require 'mime/types'
2
+
3
+ module AssetSync
4
+ class MultiMime
5
+
6
+ def self.lookup(ext)
7
+ if defined?(Mime::Type)
8
+ Mime::Type.lookup_by_extension(ext)
9
+ elsif defined?(Rack::Mime)
10
+ ext_with_dot = ".#{ext}"
11
+ Rack::Mime.mime_type(ext_with_dot)
12
+ end || ::MIME::Types.type_for(ext).first
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ class Rails::Railtie::Configuration
2
+ def asset_sync
3
+ AssetSync.config
4
+ end
5
+ end
@@ -0,0 +1,291 @@
1
+ module AssetSync
2
+ class Storage
3
+ REGEXP_FINGERPRINTED_FILES = /^(.*)\/([^-]+)-[^\.]+\.([^\.]+)$/
4
+
5
+ class BucketNotFound < StandardError;
6
+ end
7
+
8
+ attr_accessor :config
9
+
10
+ def initialize(cfg)
11
+ @config = cfg
12
+ end
13
+
14
+ def connection
15
+ @connection ||= Fog::Storage.new(self.config.fog_options)
16
+ end
17
+
18
+ def bucket
19
+ # fixes: https://github.com/rumblelabs/asset_sync/issues/18
20
+ @bucket ||= connection.directories.get(self.config.fog_directory, :prefix => self.config.assets_prefix)
21
+ end
22
+
23
+ def log(msg)
24
+ AssetSync.log(msg)
25
+ end
26
+
27
+ def keep_existing_remote_files?
28
+ self.config.existing_remote_files?
29
+ end
30
+
31
+ def path
32
+ self.config.public_path
33
+ end
34
+
35
+ def ignored_files
36
+ files = []
37
+ Array(self.config.ignored_files).each do |ignore|
38
+ case ignore
39
+ when Regexp
40
+ files += self.local_files.select do |file|
41
+ file =~ ignore
42
+ end
43
+ when String
44
+ files += self.local_files.select do |file|
45
+ file.split('/').last == ignore
46
+ end
47
+ else
48
+ log "Error: please define ignored_files as string or regular expression. #{ignore} (#{ignore.class}) ignored."
49
+ end
50
+ end
51
+ files.uniq
52
+ end
53
+
54
+ def local_files
55
+ @local_files ||= get_local_files.uniq
56
+ end
57
+
58
+ def always_upload_files
59
+ self.config.always_upload.map { |f| File.join(self.config.assets_prefix, f) }
60
+ end
61
+
62
+ def files_with_custom_headers
63
+ self.config.custom_headers.inject({}) { |h,(k, v)| h[File.join(self.config.assets_prefix, k)] = v; h; }
64
+ end
65
+
66
+ def files_to_invalidate
67
+ self.config.invalidate.map { |filename| File.join("/", self.config.assets_prefix, filename) }
68
+ end
69
+
70
+ def get_local_files
71
+ if self.config.manifest
72
+ if ActionView::Base.respond_to?(:assets_manifest)
73
+ log "Using: Rails 4.0 manifest access"
74
+ manifest = Sprockets::Manifest.new(ActionView::Base.assets_manifest.environment, ActionView::Base.assets_manifest.dir)
75
+ return manifest.assets.values.map { |f| File.join(self.config.assets_prefix, f) }
76
+ elsif File.exist?(self.config.manifest_path)
77
+ log "Using: Manifest #{self.config.manifest_path}"
78
+ yml = YAML.load(IO.read(self.config.manifest_path))
79
+
80
+ return yml.map do |original, compiled|
81
+ # Upload font originals and compiled
82
+ if original =~ /^.+(eot|svg|ttf|woff)$/
83
+ [original, compiled]
84
+ else
85
+ compiled
86
+ end
87
+ end.flatten.map { |f| File.join(self.config.assets_prefix, f) }.uniq!
88
+ else
89
+ log "Warning: Manifest could not be found"
90
+ end
91
+ end
92
+ log "Using: Directory Search of #{path}/#{self.config.assets_prefix}"
93
+ Dir.chdir(path) do
94
+ to_load = self.config.assets_prefix.present? ? "#{self.config.assets_prefix}/**/**" : '**/**'
95
+ Dir[to_load]
96
+ end
97
+ end
98
+
99
+ def get_remote_files
100
+ raise BucketNotFound.new("#{self.config.fog_provider} Bucket: #{self.config.fog_directory} not found.") unless bucket
101
+ # fixes: https://github.com/rumblelabs/asset_sync/issues/16
102
+ # (work-around for https://github.com/fog/fog/issues/596)
103
+ files = []
104
+ bucket.files.each { |f| files << f.key }
105
+ return files
106
+ end
107
+
108
+ def delete_file(f, remote_files_to_delete)
109
+ if remote_files_to_delete.include?(f.key)
110
+ log "Deleting: #{f.key}"
111
+ f.destroy
112
+ end
113
+ end
114
+
115
+ def delete_extra_remote_files
116
+ log "Fetching files to flag for delete"
117
+ remote_files = get_remote_files
118
+ # fixes: https://github.com/rumblelabs/asset_sync/issues/19
119
+ from_remote_files_to_delete = remote_files - local_files - ignored_files - always_upload_files
120
+
121
+ log "Flagging #{from_remote_files_to_delete.size} file(s) for deletion"
122
+ # Delete unneeded remote files
123
+ bucket.files.each do |f|
124
+ delete_file(f, from_remote_files_to_delete)
125
+ end
126
+ end
127
+
128
+ def upload_file(f)
129
+ # TODO output files in debug logs as asset filename only.
130
+ one_year = 31557600
131
+ ext = File.extname(f)[1..-1]
132
+ mime = MultiMime.lookup(ext)
133
+ file = {
134
+ :key => f,
135
+ :body => File.open("#{path}/#{f}"),
136
+ :public => true,
137
+ :content_type => mime
138
+ }
139
+
140
+ uncompressed_filename = f.sub(/\.gz\z/, '')
141
+ basename = File.basename(uncompressed_filename, File.extname(uncompressed_filename))
142
+ if /-[0-9a-fA-F]{32,}$/.match(basename)
143
+ file.merge!({
144
+ :cache_control => "public, max-age=#{one_year}",
145
+ :expires => CGI.rfc1123_date(Time.now + one_year)
146
+ })
147
+ end
148
+
149
+ # overwrite headers if applicable, you probably shouldn't specific key/body, but cache-control headers etc.
150
+
151
+ if files_with_custom_headers.has_key? f
152
+ file.merge! files_with_custom_headers[f]
153
+ log "Overwriting #{f} with custom headers #{files_with_custom_headers[f].to_s}"
154
+ elsif key = self.config.custom_headers.keys.detect {|k| f.match(Regexp.new(k))}
155
+ headers = {}
156
+ self.config.custom_headers[key].each do |k, value|
157
+ headers[k.to_sym] = value
158
+ end
159
+ file.merge! headers
160
+ log "Overwriting matching file #{f} with custom headers #{headers.to_s}"
161
+ end
162
+
163
+
164
+ gzipped = "#{path}/#{f}.gz"
165
+ ignore = false
166
+
167
+ if config.gzip? && File.extname(f) == ".gz"
168
+ # Don't bother uploading gzipped assets if we are in gzip_compression mode
169
+ # as we will overwrite file.css with file.css.gz if it exists.
170
+ log "Ignoring: #{f}"
171
+ ignore = true
172
+ elsif config.gzip? && File.exist?(gzipped)
173
+ original_size = File.size("#{path}/#{f}")
174
+ gzipped_size = File.size(gzipped)
175
+
176
+ if gzipped_size < original_size
177
+ percentage = ((gzipped_size.to_f/original_size.to_f)*100).round(2)
178
+ file.merge!({
179
+ :key => f,
180
+ :body => File.open(gzipped),
181
+ :content_encoding => 'gzip'
182
+ })
183
+ log "Uploading: #{gzipped} in place of #{f} saving #{percentage}%"
184
+ else
185
+ percentage = ((original_size.to_f/gzipped_size.to_f)*100).round(2)
186
+ log "Uploading: #{f} instead of #{gzipped} (compression increases this file by #{percentage}%)"
187
+ end
188
+ else
189
+ if !config.gzip? && File.extname(f) == ".gz"
190
+ # set content encoding for gzipped files this allows cloudfront to properly handle requests with Accept-Encoding
191
+ # http://docs.amazonwebservices.com/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html
192
+ uncompressed_filename = f[0..-4]
193
+ ext = File.extname(uncompressed_filename)[1..-1]
194
+ mime = MultiMime.lookup(ext)
195
+ file.merge!({
196
+ :content_type => mime,
197
+ :content_encoding => 'gzip'
198
+ })
199
+ end
200
+ log "Uploading: #{f}"
201
+ end
202
+
203
+ if config.aws? && config.aws_rrs?
204
+ file.merge!({
205
+ :storage_class => 'REDUCED_REDUNDANCY'
206
+ })
207
+ end
208
+
209
+ file = bucket.files.create( file ) unless ignore
210
+ end
211
+
212
+ def skip_uploading?(manifest_file)
213
+ unless manifest_file
214
+ log "Failed to find manifest file"
215
+ return false
216
+ end
217
+
218
+ log "Found manifest file #{manifest_file}"
219
+
220
+ begin
221
+ s3_manifest = connection.head_object(self.config.fog_directory, manifest_file)
222
+
223
+ # If the manifest is within 24 hours old and exists on the server, we're going to assume
224
+ # we can skip uploading the assets.
225
+ created_at = Time.parse(s3_manifest.headers['Last-Modified']).utc
226
+ log "S3 Manifest was created on #{created_at} (#{Time.now.utc})"
227
+ return true if (Time.now.utc - created_at) <= 86400
228
+
229
+ rescue => ex
230
+ log "Got #{ex.class}, #{ex.message} while trying to HEAD the manifest from S3"
231
+ return false
232
+ end
233
+
234
+ false
235
+ end
236
+
237
+ def upload_files
238
+ manifest_file = self.config.manifest_digest_path
239
+ if skip_uploading?(manifest_file)
240
+ log "Skipping file upload"
241
+ return
242
+ end
243
+
244
+ # get a fresh list of remote files
245
+ remote_files = ignore_existing_remote_files? ? [] : get_remote_files
246
+ # fixes: https://github.com/rumblelabs/asset_sync/issues/19
247
+ local_files_to_upload = local_files - ignored_files - remote_files + always_upload_files
248
+ local_files_to_upload = (local_files_to_upload + get_non_fingerprinted(local_files_to_upload)).uniq
249
+
250
+ local_files_to_upload.delete manifest_file if manifest_file
251
+
252
+ # Upload new files
253
+ local_files_to_upload.each do |f|
254
+ next unless File.file? "#{path}/#{f}" # Only files.
255
+ upload_file f
256
+ end
257
+
258
+ # At the very end, upload the manifest indicating we're done
259
+ upload_file manifest_file if manifest_file
260
+
261
+ if self.config.cdn_distribution_id && files_to_invalidate.any?
262
+ log "Invalidating Files"
263
+ cdn ||= Fog::CDN.new(self.config.fog_options.except(:region))
264
+ data = cdn.post_invalidation(self.config.cdn_distribution_id, files_to_invalidate)
265
+ log "Invalidation id: #{data.body["Id"]}"
266
+ end
267
+ end
268
+
269
+ def sync
270
+ # fixes: https://github.com/rumblelabs/asset_sync/issues/19
271
+ log "AssetSync: Syncing."
272
+ upload_files
273
+ delete_extra_remote_files unless keep_existing_remote_files?
274
+ log "AssetSync: Done."
275
+ end
276
+
277
+ private
278
+
279
+ def ignore_existing_remote_files?
280
+ self.config.existing_remote_files == 'ignore'
281
+ end
282
+
283
+ def get_non_fingerprinted(files)
284
+ files.map do |file|
285
+ match_data = file.match(REGEXP_FINGERPRINTED_FILES)
286
+ match_data && "#{match_data[1]}/#{match_data[2]}.#{match_data[3]}"
287
+ end.compact
288
+ end
289
+
290
+ end
291
+ end
@@ -0,0 +1,3 @@
1
+ module AssetSync
2
+ VERSION = "2.0.0"
3
+ end
@@ -0,0 +1,67 @@
1
+ require 'rails/generators'
2
+ module AssetSync
3
+ class InstallGenerator < Rails::Generators::Base
4
+ desc "Install a config/asset_sync.yml and the asset:precompile rake task enhancer"
5
+
6
+ # Commandline options can be defined here using Thor-like options:
7
+ class_option :use_yml, :type => :boolean, :default => false, :desc => "Use YML file instead of Rails Initializer"
8
+ class_option :provider, :type => :string, :default => "AWS", :desc => "Generate with support for 'AWS', 'Rackspace', or 'Google'"
9
+
10
+ def self.source_root
11
+ @source_root ||= File.join(File.dirname(__FILE__), 'templates')
12
+ end
13
+
14
+ def aws?
15
+ options[:provider] == 'AWS'
16
+ end
17
+
18
+ def google?
19
+ options[:provider] == 'Google'
20
+ end
21
+
22
+ def rackspace?
23
+ options[:provider] == 'Rackspace'
24
+ end
25
+
26
+ def aws_access_key_id
27
+ "<%= ENV['AWS_ACCESS_KEY_ID'] %>"
28
+ end
29
+
30
+ def aws_secret_access_key
31
+ "<%= ENV['AWS_SECRET_ACCESS_KEY'] %>"
32
+ end
33
+
34
+ def google_storage_access_key_id
35
+ "<%= ENV['GOOGLE_STORAGE_ACCESS_KEY_ID'] %>"
36
+ end
37
+
38
+ def google_storage_secret_access_key
39
+ "<%= ENV['GOOGLE_STORAGE_SECRET_ACCESS_KEY'] %>"
40
+ end
41
+
42
+ def rackspace_username
43
+ "<%= ENV['RACKSPACE_USERNAME'] %>"
44
+ end
45
+
46
+ def rackspace_api_key
47
+ "<%= ENV['RACKSPACE_API_KEY'] %>"
48
+ end
49
+
50
+ def app_name
51
+ @app_name ||= Rails.application.is_a?(Rails::Application) && Rails.application.class.name.sub(/::Application$/, "").downcase
52
+ end
53
+
54
+ def generate_config
55
+ if options[:use_yml]
56
+ template "asset_sync.yml", "config/asset_sync.yml"
57
+ end
58
+ end
59
+
60
+ def generate_initializer
61
+ unless options[:use_yml]
62
+ template "asset_sync.rb", "config/initializers/asset_sync.rb"
63
+ end
64
+ end
65
+
66
+ end
67
+ end
@@ -0,0 +1,41 @@
1
+ AssetSync.configure do |config|
2
+ <%- if aws? -%>
3
+ config.fog_provider = 'AWS'
4
+ config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID']
5
+ config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY']
6
+ # To use AWS reduced redundancy storage.
7
+ # config.aws_reduced_redundancy = true
8
+ <%- elsif google? -%>
9
+ config.fog_provider = 'Google'
10
+ config.google_storage_access_key_id = ENV['GOOGLE_STORAGE_ACCESS_KEY_ID']
11
+ config.google_storage_secret_access_key = ENV['GOOGLE_STORAGE_SECRET_ACCESS_KEY']
12
+ <%- elsif rackspace? -%>
13
+ config.fog_provider = 'Rackspace'
14
+ config.rackspace_username = ENV['RACKSPACE_USERNAME']
15
+ config.rackspace_api_key = ENV['RACKSPACE_API_KEY']
16
+
17
+ # if you need to change rackspace_auth_url (e.g. if you need to use Rackspace London)
18
+ # config.rackspace_auth_url = "lon.auth.api.rackspacecloud.com"
19
+ <%- end -%>
20
+ config.fog_directory = ENV['FOG_DIRECTORY']
21
+
22
+ # Invalidate a file on a cdn after uploading files
23
+ # config.cdn_distribution_id = "12345"
24
+ # config.invalidate = ['file1.js']
25
+
26
+ # Increase upload performance by configuring your region
27
+ # config.fog_region = 'eu-west-1'
28
+ #
29
+ # Don't delete files from the store
30
+ # config.existing_remote_files = "keep"
31
+ #
32
+ # Automatically replace files with their equivalent gzip compressed version
33
+ # config.gzip_compression = true
34
+ #
35
+ # Use the Rails generated 'manifest.yml' file to produce the list of files to
36
+ # upload instead of searching the assets directory.
37
+ # config.manifest = true
38
+ #
39
+ # Fail silently. Useful for environments such as Heroku
40
+ # config.fail_silently = true
41
+ end