middleman-s3_sync 4.6.5 → 4.8.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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -0
- data/.github/workflows/release.yml +53 -0
- data/Changelog.md +54 -0
- data/README.md +108 -2
- data/lib/middleman/s3_sync/caching_policy.rb +6 -0
- data/lib/middleman/s3_sync/options.rb +12 -1
- data/lib/middleman/s3_sync/resource.rb +30 -11
- data/lib/middleman/s3_sync/version.rb +1 -1
- data/lib/middleman/s3_sync.rb +116 -0
- data/lib/middleman-s3_sync/commands.rb +3 -1
- data/lib/middleman-s3_sync/extension.rb +3 -0
- data/middleman-s3_sync.gemspec +21 -18
- data/spec/aws_sdk_parameters_spec.rb +96 -0
- data/spec/caching_policy_spec.rb +27 -2
- data/spec/resource_spec.rb +206 -0
- data/spec/s3_sync_integration_spec.rb +293 -4
- data/spec/spec_helper.rb +0 -1
- metadata +74 -74
data/middleman-s3_sync.gemspec
CHANGED
|
@@ -13,28 +13,31 @@ Gem::Specification.new do |gem|
|
|
|
13
13
|
gem.homepage = "http://github.com/fredjean/middleman-s3_sync"
|
|
14
14
|
gem.license = 'MIT'
|
|
15
15
|
|
|
16
|
+
gem.required_ruby_version = '>= 3.0'
|
|
17
|
+
|
|
16
18
|
gem.files = `git ls-files`.split($/)
|
|
17
19
|
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
|
18
20
|
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
|
19
21
|
gem.require_paths = ["lib"]
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
gem.add_runtime_dependency 'middleman-
|
|
23
|
-
gem.add_runtime_dependency '
|
|
24
|
-
gem.add_runtime_dependency 'aws-sdk-
|
|
25
|
-
gem.add_runtime_dependency '
|
|
26
|
-
gem.add_runtime_dependency '
|
|
27
|
-
gem.add_runtime_dependency '
|
|
28
|
-
gem.add_runtime_dependency '
|
|
29
|
-
gem.add_runtime_dependency '
|
|
23
|
+
# Runtime dependencies
|
|
24
|
+
gem.add_runtime_dependency 'middleman-core', '~> 4.4'
|
|
25
|
+
gem.add_runtime_dependency 'middleman-cli', '~> 4.4'
|
|
26
|
+
gem.add_runtime_dependency 'aws-sdk-s3', '~> 1.187', '>= 1.187.0'
|
|
27
|
+
gem.add_runtime_dependency 'aws-sdk-cloudfront', '~> 1.0'
|
|
28
|
+
gem.add_runtime_dependency 'parallel', '~> 1.20'
|
|
29
|
+
gem.add_runtime_dependency 'ruby-progressbar', '~> 1.11'
|
|
30
|
+
gem.add_runtime_dependency 'ansi', '~> 1.5'
|
|
31
|
+
gem.add_runtime_dependency 'mime-types', '~> 3.4'
|
|
32
|
+
gem.add_runtime_dependency 'nokogiri', '~> 1.18', '>= 1.18.4'
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
gem.add_development_dependency '
|
|
33
|
-
gem.add_development_dependency 'pry
|
|
34
|
-
gem.add_development_dependency '
|
|
35
|
-
gem.add_development_dependency 'rspec
|
|
36
|
-
gem.add_development_dependency 'rspec-
|
|
37
|
-
gem.add_development_dependency 'rspec-
|
|
38
|
-
gem.add_development_dependency '
|
|
39
|
-
gem.add_development_dependency 'webrick'
|
|
34
|
+
# Development dependencies
|
|
35
|
+
gem.add_development_dependency 'rake', '~> 13.0'
|
|
36
|
+
gem.add_development_dependency 'pry', '~> 0.14'
|
|
37
|
+
gem.add_development_dependency 'pry-byebug', '~> 3.10'
|
|
38
|
+
gem.add_development_dependency 'rspec', '~> 3.12'
|
|
39
|
+
gem.add_development_dependency 'rspec-support', '~> 3.12'
|
|
40
|
+
gem.add_development_dependency 'rspec-its', '~> 2.0'
|
|
41
|
+
gem.add_development_dependency 'rspec-mocks', '~> 3.12'
|
|
42
|
+
gem.add_development_dependency 'webrick', '~> 1.8'
|
|
40
43
|
end
|
|
@@ -87,6 +87,60 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
87
87
|
}.to raise_error('S3 requires `index_document` if `error_document` is specified')
|
|
88
88
|
end
|
|
89
89
|
end
|
|
90
|
+
|
|
91
|
+
context 'when routing_rules are set' do
|
|
92
|
+
before do
|
|
93
|
+
options.routing_rules = [
|
|
94
|
+
{
|
|
95
|
+
condition: { key_prefix_equals: 'docs/' },
|
|
96
|
+
redirect: { replace_key_prefix_with: 'documents/' }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
condition: { http_error_code_returned_equals: '404' },
|
|
100
|
+
redirect: { host_name: 'example.com', replace_key_with: 'error.html' }
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'includes routing_rules in website configuration' do
|
|
106
|
+
expect(s3_client).to receive(:put_bucket_website) do |params|
|
|
107
|
+
expect(params[:website_configuration]).to have_key(:routing_rules)
|
|
108
|
+
rules = params[:website_configuration][:routing_rules]
|
|
109
|
+
expect(rules).to be_an(Array)
|
|
110
|
+
expect(rules.length).to eq(2)
|
|
111
|
+
|
|
112
|
+
# First rule
|
|
113
|
+
expect(rules[0][:condition][:key_prefix_equals]).to eq('docs/')
|
|
114
|
+
expect(rules[0][:redirect][:replace_key_prefix_with]).to eq('documents/')
|
|
115
|
+
|
|
116
|
+
# Second rule
|
|
117
|
+
expect(rules[1][:condition][:http_error_code_returned_equals]).to eq('404')
|
|
118
|
+
expect(rules[1][:redirect][:host_name]).to eq('example.com')
|
|
119
|
+
expect(rules[1][:redirect][:replace_key_with]).to eq('error.html')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
Middleman::S3Sync.send(:update_bucket_website)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
context 'when routing_rules are set without index_document' do
|
|
127
|
+
before do
|
|
128
|
+
options.index_document = nil
|
|
129
|
+
options.error_document = nil
|
|
130
|
+
options.routing_rules = [
|
|
131
|
+
{
|
|
132
|
+
condition: { key_prefix_equals: 'old/' },
|
|
133
|
+
redirect: { replace_key_prefix_with: 'new/' }
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'raises an error because S3 requires index_document if routing_rules are specified' do
|
|
139
|
+
expect {
|
|
140
|
+
Middleman::S3Sync.send(:update_bucket_website)
|
|
141
|
+
}.to raise_error('S3 requires `index_document` if `routing_rules` are specified')
|
|
142
|
+
end
|
|
143
|
+
end
|
|
90
144
|
end
|
|
91
145
|
|
|
92
146
|
describe 'put_bucket_versioning parameters' do
|
|
@@ -425,6 +479,48 @@ describe 'AWS SDK Parameter Validation' do
|
|
|
425
479
|
end
|
|
426
480
|
end
|
|
427
481
|
|
|
482
|
+
context 'when a caching policy with only max_age is in effect' do
|
|
483
|
+
before do
|
|
484
|
+
policy = Middleman::S3Sync::BrowserCachePolicy.new(max_age: 31536000, public: true, immutable: true)
|
|
485
|
+
allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
it 'sets cache_control but omits expires' do
|
|
489
|
+
attributes = resource.to_h
|
|
490
|
+
|
|
491
|
+
expect(attributes[:cache_control]).to eq('max-age=31536000, public, immutable')
|
|
492
|
+
expect(attributes).not_to have_key(:expires)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
context 'when a caching policy with only expires is in effect' do
|
|
497
|
+
before do
|
|
498
|
+
policy = Middleman::S3Sync::BrowserCachePolicy.new(expires: Time.utc(2030, 1, 1))
|
|
499
|
+
allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
it 'sets expires but omits cache_control' do
|
|
503
|
+
attributes = resource.to_h
|
|
504
|
+
|
|
505
|
+
expect(attributes[:expires]).to eq(CGI.rfc1123_date(Time.utc(2030, 1, 1)))
|
|
506
|
+
expect(attributes).not_to have_key(:cache_control)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
context 'when a caching policy sets both max_age and expires' do
|
|
511
|
+
before do
|
|
512
|
+
policy = Middleman::S3Sync::BrowserCachePolicy.new(max_age: 300, expires: Time.utc(2030, 1, 1))
|
|
513
|
+
allow(Middleman::S3Sync).to receive(:caching_policy_for).and_return(policy)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
it 'emits cache_control and suppresses the redundant expires' do
|
|
517
|
+
attributes = resource.to_h
|
|
518
|
+
|
|
519
|
+
expect(attributes[:cache_control]).to eq('max-age=300')
|
|
520
|
+
expect(attributes).not_to have_key(:expires)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
428
524
|
context 'when resource has a redirect' do
|
|
429
525
|
let(:mm_resource) do
|
|
430
526
|
double(
|
data/spec/caching_policy_spec.rb
CHANGED
|
@@ -17,6 +17,7 @@ describe Middleman::S3Sync::BrowserCachePolicy do
|
|
|
17
17
|
expect(policy.to_s).to_not match /no-store/
|
|
18
18
|
expect(policy.to_s).to_not match /must-revalidate/
|
|
19
19
|
expect(policy.to_s).to_not match /proxy-revalidate/
|
|
20
|
+
expect(policy.to_s).to_not match /immutable/
|
|
20
21
|
expect(policy.expires).to eq nil
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -62,6 +63,19 @@ describe Middleman::S3Sync::BrowserCachePolicy do
|
|
|
62
63
|
its(:to_s) { is_expected.to match /proxy-revalidate/ }
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
context "setting the immutable flag" do
|
|
67
|
+
let(:options) { { immutable: true } }
|
|
68
|
+
its(:to_s) { is_expected.to match /immutable/ }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
context "combining max_age, public, and immutable for hashed assets" do
|
|
72
|
+
let(:options) { { max_age: 31536000, public: true, immutable: true } }
|
|
73
|
+
|
|
74
|
+
it "emits the directives in policy order" do
|
|
75
|
+
expect(policy.to_s).to eq "max-age=31536000, public, immutable"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
65
79
|
context "divide caching policiies with a comma and a space" do
|
|
66
80
|
let(:options) { { :max_age => 300, :public => true } }
|
|
67
81
|
|
|
@@ -74,9 +88,20 @@ describe Middleman::S3Sync::BrowserCachePolicy do
|
|
|
74
88
|
end
|
|
75
89
|
|
|
76
90
|
context "set the expiration date" do
|
|
77
|
-
let(:
|
|
91
|
+
let(:expires_at) { Time.utc(2030, 1, 1) }
|
|
92
|
+
let(:options) { { expires: expires_at } }
|
|
93
|
+
|
|
94
|
+
its(:expires) { is_expected.to eq CGI.rfc1123_date(expires_at) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
context "max_age suppresses the Expires header" do
|
|
98
|
+
let(:options) { { max_age: 300, expires: Time.utc(2030, 1, 1) } }
|
|
99
|
+
|
|
100
|
+
it "still emits max-age in cache_control" do
|
|
101
|
+
expect(policy.to_s).to match /max-age=300/
|
|
102
|
+
end
|
|
78
103
|
|
|
79
|
-
its(:expires) { is_expected.to
|
|
104
|
+
its(:expires) { is_expected.to be_nil }
|
|
80
105
|
end
|
|
81
106
|
end
|
|
82
107
|
end
|
data/spec/resource_spec.rb
CHANGED
|
@@ -182,6 +182,212 @@ describe Middleman::S3Sync::Resource do
|
|
|
182
182
|
end
|
|
183
183
|
end
|
|
184
184
|
|
|
185
|
+
context 'content type detection' do
|
|
186
|
+
context 'when mm_resource provides content_type' do
|
|
187
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
|
|
188
|
+
|
|
189
|
+
let(:mm_resource) {
|
|
190
|
+
double(
|
|
191
|
+
destination_path: 'path/to/file.html',
|
|
192
|
+
content_type: 'text/html'
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
before do
|
|
197
|
+
allow(File).to receive(:exist?).with('build/path/to/file.html').and_return(true)
|
|
198
|
+
allow(File).to receive(:read).with('build/path/to/file.html').and_return('content')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'uses the content_type from mm_resource' do
|
|
202
|
+
expect(resource.content_type).to eq 'text/html'
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
context 'when mm_resource is nil (orphan file)' do
|
|
207
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
|
|
208
|
+
|
|
209
|
+
let(:remote) { mock_s3_object('path/to/file.webp') }
|
|
210
|
+
|
|
211
|
+
before do
|
|
212
|
+
resource.full_s3_resource = remote
|
|
213
|
+
allow(File).to receive(:exist?).with('build/path/to/file.webp').and_return(true)
|
|
214
|
+
allow(File).to receive(:read).with('build/path/to/file.webp').and_return('content')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'falls back to mime-types for known extensions' do
|
|
218
|
+
expect(resource.content_type).to eq 'image/webp'
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
context 'when mm_resource has nil content_type' do
|
|
223
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
|
|
224
|
+
|
|
225
|
+
let(:mm_resource) {
|
|
226
|
+
double(
|
|
227
|
+
destination_path: 'path/to/image.svg',
|
|
228
|
+
content_type: nil
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
before do
|
|
233
|
+
allow(File).to receive(:exist?).with('build/path/to/image.svg').and_return(true)
|
|
234
|
+
allow(File).to receive(:read).with('build/path/to/image.svg').and_return('content')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'falls back to mime-types' do
|
|
238
|
+
expect(resource.content_type).to eq 'image/svg+xml'
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
context 'when content_types option is set' do
|
|
243
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
|
|
244
|
+
|
|
245
|
+
let(:mm_resource) {
|
|
246
|
+
double(
|
|
247
|
+
destination_path: 'path/to/data.custom',
|
|
248
|
+
content_type: nil
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
before do
|
|
253
|
+
allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
|
|
254
|
+
allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
|
|
255
|
+
options.content_types = { 'path/to/data.custom' => 'application/x-custom' }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'uses content_types option by path' do
|
|
259
|
+
expect(resource.content_type).to eq 'application/x-custom'
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
context 'when content_types option uses local_path key' do
|
|
264
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
|
|
265
|
+
|
|
266
|
+
let(:mm_resource) {
|
|
267
|
+
double(
|
|
268
|
+
destination_path: 'path/to/data.custom',
|
|
269
|
+
content_type: nil
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
before do
|
|
274
|
+
allow(File).to receive(:exist?).with('build/path/to/data.custom').and_return(true)
|
|
275
|
+
allow(File).to receive(:read).with('build/path/to/data.custom').and_return('content')
|
|
276
|
+
options.content_types = { 'build/path/to/data.custom' => 'application/x-custom-local' }
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
it 'uses content_types option by local_path' do
|
|
280
|
+
expect(resource.content_type).to eq 'application/x-custom-local'
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
context 'when extension is unknown' do
|
|
285
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(mm_resource, nil) }
|
|
286
|
+
|
|
287
|
+
let(:mm_resource) {
|
|
288
|
+
double(
|
|
289
|
+
destination_path: 'path/to/file.unknownext123',
|
|
290
|
+
content_type: nil
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
before do
|
|
295
|
+
allow(File).to receive(:exist?).with('build/path/to/file.unknownext123').and_return(true)
|
|
296
|
+
allow(File).to receive(:read).with('build/path/to/file.unknownext123').and_return('content')
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'defaults to application/octet-stream' do
|
|
300
|
+
expect(resource.content_type).to eq 'application/octet-stream'
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
context 'a resource with explicit path (orphan file)' do
|
|
306
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(nil, nil, path: 'orphan/file.webp') }
|
|
307
|
+
|
|
308
|
+
before do
|
|
309
|
+
allow(File).to receive(:exist?).with('build/orphan/file.webp').and_return(true)
|
|
310
|
+
allow(File).to receive(:exist?).with('build/orphan/file.webp.gz').and_return(false)
|
|
311
|
+
allow(File).to receive(:read).with('build/orphan/file.webp').and_return('content')
|
|
312
|
+
allow(File).to receive(:directory?).with('build/orphan/file.webp').and_return(false)
|
|
313
|
+
|
|
314
|
+
# Mock the bucket to return NotFound for head request (file doesn't exist on S3)
|
|
315
|
+
mock_object = instance_double(Aws::S3::Object)
|
|
316
|
+
allow(mock_object).to receive(:head).and_raise(Aws::S3::Errors::NotFound.new(nil, 'Not Found'))
|
|
317
|
+
allow(bucket).to receive(:object).with('orphan/file.webp').and_return(mock_object)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
its(:path) { is_expected.to eq 'orphan/file.webp' }
|
|
321
|
+
its(:local_path) { is_expected.to eq 'build/orphan/file.webp' }
|
|
322
|
+
its(:status) { is_expected.to eq :new }
|
|
323
|
+
|
|
324
|
+
it 'detects content type via mime-types' do
|
|
325
|
+
expect(resource.content_type).to eq 'image/webp'
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
context 'redirect preservation' do
|
|
330
|
+
context 'remote-only redirect (no local file)' do
|
|
331
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
|
|
332
|
+
|
|
333
|
+
let(:remote) do
|
|
334
|
+
double(
|
|
335
|
+
key: 'old-page.html',
|
|
336
|
+
metadata: {},
|
|
337
|
+
etag: '"abc123"',
|
|
338
|
+
content_encoding: nil,
|
|
339
|
+
cache_control: nil,
|
|
340
|
+
website_redirect_location: '/new-page.html'
|
|
341
|
+
)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
before do
|
|
345
|
+
allow(File).to receive(:exist?).with('build/old-page.html').and_return(false)
|
|
346
|
+
resource.full_s3_resource = remote
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'detects as a redirect' do
|
|
350
|
+
expect(resource.redirect?).to be true
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
its(:status) { is_expected.to eq :ignored }
|
|
354
|
+
|
|
355
|
+
it 'is not marked for deletion' do
|
|
356
|
+
expect(resource.to_delete?).to be false
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
context 'remote file without redirect (no local file)' do
|
|
361
|
+
subject(:resource) { Middleman::S3Sync::Resource.new(nil, remote) }
|
|
362
|
+
|
|
363
|
+
let(:remote) do
|
|
364
|
+
double(
|
|
365
|
+
key: 'deleted-page.html',
|
|
366
|
+
metadata: {},
|
|
367
|
+
etag: '"abc123"',
|
|
368
|
+
content_encoding: nil,
|
|
369
|
+
cache_control: nil,
|
|
370
|
+
website_redirect_location: nil
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
before do
|
|
375
|
+
allow(File).to receive(:exist?).with('build/deleted-page.html').and_return(false)
|
|
376
|
+
resource.full_s3_resource = remote
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
it 'is not a redirect' do
|
|
380
|
+
expect(resource.redirect?).to be_falsey
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
its(:status) { is_expected.to eq :deleted }
|
|
384
|
+
|
|
385
|
+
it 'is marked for deletion' do
|
|
386
|
+
expect(resource.to_delete?).to be true
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
|
|
185
391
|
context 'An ignored resource' do
|
|
186
392
|
context "that is local" do
|
|
187
393
|
|