FEATURE: Support subfolders in S3 bucket name.
This commit also fixes a bug where s3 uploads are not moved to a tombstone folder when removed.
This commit is contained in:
parent
9144fe5dc7
commit
aa5de3c40a
|
@ -690,7 +690,7 @@ files:
|
||||||
enum: 'S3RegionSiteSetting'
|
enum: 'S3RegionSiteSetting'
|
||||||
s3_upload_bucket:
|
s3_upload_bucket:
|
||||||
default: ''
|
default: ''
|
||||||
regex: "^[a-z0-9-]+$" # can't use '.' when using HTTPS
|
regex: '^[a-z0-9\-\/]+$' # can't use '.' when using HTTPS
|
||||||
s3_cdn_url:
|
s3_cdn_url:
|
||||||
default: ''
|
default: ''
|
||||||
regex: '^https?:\/\/.+[^\/]$'
|
regex: '^https?:\/\/.+[^\/]$'
|
||||||
|
|
|
@ -7,18 +7,36 @@ require_dependency "file_helper"
|
||||||
module FileStore
|
module FileStore
|
||||||
|
|
||||||
class S3Store < BaseStore
|
class S3Store < BaseStore
|
||||||
|
attr_reader :s3_bucket, :s3_bucket_folder_path
|
||||||
|
|
||||||
TOMBSTONE_PREFIX ||= "tombstone/"
|
TOMBSTONE_PREFIX ||= "tombstone/"
|
||||||
|
|
||||||
def initialize(s3_helper=nil)
|
def initialize(s3_helper=nil)
|
||||||
@s3_helper = s3_helper || S3Helper.new(s3_bucket, TOMBSTONE_PREFIX)
|
@s3_bucket, @s3_bucket_folder_path = begin
|
||||||
|
raise Discourse::SiteSettingMissing.new("s3_upload_bucket") if SiteSetting.s3_upload_bucket.blank?
|
||||||
|
SiteSetting.s3_upload_bucket.downcase.split("/".freeze, 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
tombstone_prefix =
|
||||||
|
if @s3_bucket_folder_path
|
||||||
|
File.join(@s3_bucket_folder_path, TOMBSTONE_PREFIX)
|
||||||
|
else
|
||||||
|
TOMBSTONE_PREFIX
|
||||||
|
end
|
||||||
|
|
||||||
|
@s3_helper = s3_helper || S3Helper.new(s3_bucket, tombstone_prefix)
|
||||||
end
|
end
|
||||||
|
|
||||||
def store_upload(file, upload, content_type = nil)
|
def store_upload(file, upload, content_type = nil)
|
||||||
path = get_path_for_upload(upload)
|
path = get_path_for_s3_upload(get_path_for_upload(upload))
|
||||||
store_file(file, path, filename: upload.original_filename, content_type: content_type, cache_locally: true)
|
store_file(file, path, filename: upload.original_filename, content_type: content_type, cache_locally: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def store_optimized_image(file, optimized_image)
|
||||||
|
path = get_path_for_s3_upload(get_path_for_optimized_image(optimized_image))
|
||||||
|
store_file(file, path)
|
||||||
|
end
|
||||||
|
|
||||||
# options
|
# options
|
||||||
# - filename
|
# - filename
|
||||||
# - content_type
|
# - content_type
|
||||||
|
@ -43,7 +61,7 @@ module FileStore
|
||||||
def remove_file(url, path)
|
def remove_file(url, path)
|
||||||
return unless has_been_uploaded?(url)
|
return unless has_been_uploaded?(url)
|
||||||
# copy the removed file to tombstone
|
# copy the removed file to tombstone
|
||||||
@s3_helper.remove(path, true)
|
@s3_helper.remove(get_path_for_s3_upload(path), path)
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_been_uploaded?(url)
|
def has_been_uploaded?(url)
|
||||||
|
@ -97,11 +115,9 @@ module FileStore
|
||||||
UserAvatar.external_avatar_url(user_id, avatar.upload_id, avatar.width)
|
UserAvatar.external_avatar_url(user_id, avatar.upload_id, avatar.width)
|
||||||
end
|
end
|
||||||
|
|
||||||
def s3_bucket
|
def get_path_for_s3_upload(path)
|
||||||
@s3_bucket ||= begin
|
path = File.join(@s3_bucket_folder_path, path) if @s3_bucket_folder_path
|
||||||
raise Discourse::SiteSettingMissing.new("s3_upload_bucket") if SiteSetting.s3_upload_bucket.blank?
|
path
|
||||||
SiteSetting.s3_upload_bucket.downcase
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,18 +16,18 @@ class S3Helper
|
||||||
obj.upload_file(file, options)
|
obj.upload_file(file, options)
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove(path, copy_to_tombstone=false)
|
def remove(s3_filename, tombstone_filename=false)
|
||||||
bucket = s3_bucket
|
bucket = s3_bucket
|
||||||
|
|
||||||
# copy the file in tombstone
|
# copy the file in tombstone
|
||||||
if copy_to_tombstone && @tombstone_prefix.present?
|
if tombstone_filename && @tombstone_prefix.present?
|
||||||
bucket
|
bucket
|
||||||
.object(File.join(@tombstone_prefix, path))
|
.object(File.join(@tombstone_prefix, tombstone_filename))
|
||||||
.copy_from(copy_source: File.join(@s3_bucket, path))
|
.copy_from(copy_source: File.join(@s3_bucket, s3_filename))
|
||||||
end
|
end
|
||||||
|
|
||||||
# delete the file
|
# delete the file
|
||||||
bucket.object(path).delete
|
bucket.object(s3_filename).delete
|
||||||
rescue Aws::S3::Errors::NoSuchKey
|
rescue Aws::S3::Errors::NoSuchKey
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -19,25 +19,7 @@ describe FileStore::S3Store do
|
||||||
SiteSetting.s3_secret_access_key = "s3-secret-access-key"
|
SiteSetting.s3_secret_access_key = "s3-secret-access-key"
|
||||||
end
|
end
|
||||||
|
|
||||||
describe ".store_upload" do
|
shared_context 's3 helpers' do
|
||||||
|
|
||||||
it "returns an absolute schemaless url" do
|
|
||||||
s3_helper.expects(:upload)
|
|
||||||
expect(store.store_upload(uploaded_file, upload)).to match(/\/\/s3-upload-bucket\.s3\.amazonaws\.com\/original\/.+#{upload.sha1}\.png/)
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
describe ".store_optimized_image" do
|
|
||||||
|
|
||||||
it "returns an absolute schemaless url" do
|
|
||||||
s3_helper.expects(:upload)
|
|
||||||
expect(store.store_optimized_image(optimized_image_file, optimized_image)).to match(/\/\/s3-upload-bucket\.s3\.amazonaws\.com\/optimized\/.+#{optimized_image.upload.sha1}_#{OptimizedImage::VERSION}_100x200\.png/)
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'removal from s3' do
|
|
||||||
let(:store) { FileStore::S3Store.new }
|
let(:store) { FileStore::S3Store.new }
|
||||||
let(:client) { Aws::S3::Client.new(stub_responses: true) }
|
let(:client) { Aws::S3::Client.new(stub_responses: true) }
|
||||||
let(:resource) { Aws::S3::Resource.new(client: client) }
|
let(:resource) { Aws::S3::Resource.new(client: client) }
|
||||||
|
@ -45,10 +27,84 @@ describe FileStore::S3Store do
|
||||||
let(:s3_helper) { store.instance_variable_get(:@s3_helper) }
|
let(:s3_helper) { store.instance_variable_get(:@s3_helper) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
SiteSetting.s3_region = 'us-west-1'
|
SiteSetting.s3_region ='us-west-1'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'uploading to s3' do
|
||||||
|
include_context "s3 helpers"
|
||||||
|
|
||||||
|
describe "#store_upload" do
|
||||||
|
it "returns an absolute schemaless url" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
s3_object = stub
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with("original/1X/#{upload.sha1}.png").returns(s3_object)
|
||||||
|
s3_object.expects(:upload_file)
|
||||||
|
|
||||||
|
expect(store.store_upload(uploaded_file, upload)).to eq(
|
||||||
|
"//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when s3_upload_bucket includes folders path" do
|
||||||
|
before do
|
||||||
|
SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns an absolute schemaless url" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
s3_object = stub
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with("discourse-uploads/original/1X/#{upload.sha1}.png").returns(s3_object)
|
||||||
|
s3_object.expects(:upload_file)
|
||||||
|
|
||||||
|
expect(store.store_upload(uploaded_file, upload)).to eq(
|
||||||
|
"//s3-upload-bucket.s3-us-west-1.amazonaws.com/discourse-uploads/original/1X/#{upload.sha1}.png"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe ".remove_upload" do
|
describe "#store_optimized_image" do
|
||||||
|
it "returns an absolute schemaless url" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
s3_object = stub
|
||||||
|
path = "optimized/1X/#{optimized_image.upload.sha1}_#{OptimizedImage::VERSION}_100x200.png"
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with(path).returns(s3_object)
|
||||||
|
s3_object.expects(:upload_file)
|
||||||
|
|
||||||
|
expect(store.store_optimized_image(optimized_image_file, optimized_image)).to eq(
|
||||||
|
"//s3-upload-bucket.s3-us-west-1.amazonaws.com/#{path}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "when s3_upload_bucket includes folders path" do
|
||||||
|
before do
|
||||||
|
SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns an absolute schemaless url" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
s3_object = stub
|
||||||
|
path = "discourse-uploads/optimized/1X/#{optimized_image.upload.sha1}_#{OptimizedImage::VERSION}_100x200.png"
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with(path).returns(s3_object)
|
||||||
|
s3_object.expects(:upload_file)
|
||||||
|
|
||||||
|
expect(store.store_optimized_image(optimized_image_file, optimized_image)).to eq(
|
||||||
|
"//s3-upload-bucket.s3-us-west-1.amazonaws.com/#{path}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'removal from s3' do
|
||||||
|
include_context "s3 helpers"
|
||||||
|
|
||||||
|
describe "#remove_upload" do
|
||||||
it "removes the file from s3 with the right paths" do
|
it "removes the file from s3 with the right paths" do
|
||||||
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
upload.update_attributes!(url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png")
|
upload.update_attributes!(url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/original/1X/#{upload.sha1}.png")
|
||||||
|
@ -61,9 +117,28 @@ describe FileStore::S3Store do
|
||||||
|
|
||||||
store.remove_upload(upload)
|
store.remove_upload(upload)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "when s3_upload_bucket includes folders path" do
|
||||||
|
before do
|
||||||
|
SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "removes the file from s3 with the right paths" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
upload.update_attributes!(url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/discourse-uploads/original/1X/#{upload.sha1}.png")
|
||||||
|
s3_object = stub
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with("discourse-uploads/tombstone/original/1X/#{upload.sha1}.png").returns(s3_object)
|
||||||
|
s3_object.expects(:copy_from).with(copy_source: "s3-upload-bucket/discourse-uploads/original/1X/#{upload.sha1}.png")
|
||||||
|
s3_bucket.expects(:object).with("discourse-uploads/original/1X/#{upload.sha1}.png").returns(s3_object)
|
||||||
|
s3_object.expects(:delete)
|
||||||
|
|
||||||
|
store.remove_upload(upload)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe ".remove_optimized_image" do
|
describe "#remove_optimized_image" do
|
||||||
let(:optimized_image) do
|
let(:optimized_image) do
|
||||||
Fabricate(:optimized_image,
|
Fabricate(:optimized_image,
|
||||||
url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/optimized/1X/#{upload.sha1}_1_100x200.png",
|
url: "//s3-upload-bucket.s3-us-west-1.amazonaws.com/optimized/1X/#{upload.sha1}_1_100x200.png",
|
||||||
|
@ -82,6 +157,24 @@ describe FileStore::S3Store do
|
||||||
|
|
||||||
store.remove_optimized_image(optimized_image)
|
store.remove_optimized_image(optimized_image)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "when s3_upload_bucket includes folders path" do
|
||||||
|
before do
|
||||||
|
SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads"
|
||||||
|
end
|
||||||
|
|
||||||
|
it "removes the file from s3 with the right paths" do
|
||||||
|
s3_helper.expects(:s3_bucket).returns(s3_bucket)
|
||||||
|
s3_object = stub
|
||||||
|
|
||||||
|
s3_bucket.expects(:object).with("discourse-uploads/tombstone/optimized/1X/#{upload.sha1}_1_100x200.png").returns(s3_object)
|
||||||
|
s3_object.expects(:copy_from).with(copy_source: "s3-upload-bucket/discourse-uploads/optimized/1X/#{upload.sha1}_1_100x200.png")
|
||||||
|
s3_bucket.expects(:object).with("discourse-uploads/optimized/1X/#{upload.sha1}_1_100x200.png").returns(s3_object)
|
||||||
|
s3_object.expects(:delete)
|
||||||
|
|
||||||
|
store.remove_optimized_image(optimized_image)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -152,4 +245,29 @@ describe FileStore::S3Store do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#s3_bucket" do
|
||||||
|
it "should return the right bucket name" do
|
||||||
|
expect(store.s3_bucket).to eq('s3-upload-bucket')
|
||||||
|
end
|
||||||
|
|
||||||
|
it "should return the right bucket name when folders is included" do
|
||||||
|
SiteSetting.s3_upload_bucket = 's3-upload-bucket/this-site-upload-folder'
|
||||||
|
expect(store.s3_bucket).to eq('s3-upload-bucket')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "s3_bucket_folder_path" do
|
||||||
|
context "when no folder path is specified" do
|
||||||
|
it "should return the right folder path" do
|
||||||
|
expect(store.s3_bucket_folder_path).to eq(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "when site setting contains a folder path" do
|
||||||
|
it "should return the right folder path" do
|
||||||
|
SiteSetting.s3_upload_bucket = 's3-upload-bucket/this-site-upload-folder'
|
||||||
|
expect(store.s3_bucket_folder_path).to eq("this-site-upload-folder")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue