The ensure_cors! function needs to be able to add CORS rules for the S3 bucket for 3 things now: * assets * direct S3 backups * direct S3 uploads As it is, only one rule can be applied, which is generally the assets rule as it is called first. This commit changes the ensure_cors! method to be able to apply new rules as well as the existing ones. Also in this commit we are calling ensure_cors! when creating presigned PUT and multipart uploads when uploading direct to S3, so the rules definitely exist to avoid errors in the process.
154 lines
5.1 KiB
Ruby
154 lines
5.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class ExternalUploadManager
|
|
DOWNLOAD_LIMIT = 100.megabytes
|
|
SIZE_MISMATCH_BAN_MINUTES = 5
|
|
BAN_USER_REDIS_PREFIX = "ban_user_from_external_uploads_"
|
|
|
|
class ChecksumMismatchError < StandardError; end
|
|
class DownloadFailedError < StandardError; end
|
|
class CannotPromoteError < StandardError; end
|
|
class SizeMismatchError < StandardError; end
|
|
|
|
attr_reader :external_upload_stub
|
|
|
|
def self.ban_user_from_external_uploads!(user:, ban_minutes: 5)
|
|
Discourse.redis.setex("#{BAN_USER_REDIS_PREFIX}#{user.id}", ban_minutes.minutes.to_i, "1")
|
|
end
|
|
|
|
def self.user_banned?(user)
|
|
Discourse.redis.get("#{BAN_USER_REDIS_PREFIX}#{user.id}") == "1"
|
|
end
|
|
|
|
def self.create_direct_upload(current_user:, file_name:, file_size:, upload_type:, metadata: {})
|
|
Discourse.store.s3_helper.ensure_cors!([S3CorsRulesets::DIRECT_UPLOAD])
|
|
url = Discourse.store.signed_url_for_temporary_upload(
|
|
file_name, metadata: metadata
|
|
)
|
|
key = Discourse.store.path_from_url(url)
|
|
|
|
upload_stub = ExternalUploadStub.create!(
|
|
key: key,
|
|
created_by: current_user,
|
|
original_filename: file_name,
|
|
upload_type: upload_type,
|
|
filesize: file_size
|
|
)
|
|
|
|
{ url: url, key: key, unique_identifier: upload_stub.unique_identifier }
|
|
end
|
|
|
|
def self.create_direct_multipart_upload(
|
|
current_user:, file_name:, file_size:, upload_type:, metadata: {}
|
|
)
|
|
Discourse.store.s3_helper.ensure_cors!([S3CorsRulesets::DIRECT_UPLOAD])
|
|
content_type = MiniMime.lookup_by_filename(file_name)&.content_type
|
|
multipart_upload = Discourse.store.create_multipart(
|
|
file_name, content_type, metadata: metadata
|
|
)
|
|
|
|
upload_stub = ExternalUploadStub.create!(
|
|
key: multipart_upload[:key],
|
|
created_by: current_user,
|
|
original_filename: file_name,
|
|
upload_type: upload_type,
|
|
external_upload_identifier: multipart_upload[:upload_id],
|
|
multipart: true,
|
|
filesize: file_size
|
|
)
|
|
|
|
{
|
|
external_upload_identifier: upload_stub.external_upload_identifier,
|
|
key: upload_stub.key,
|
|
unique_identifier: upload_stub.unique_identifier
|
|
}
|
|
end
|
|
|
|
def initialize(external_upload_stub, upload_create_opts = {})
|
|
@external_upload_stub = external_upload_stub
|
|
@upload_create_opts = upload_create_opts
|
|
end
|
|
|
|
def can_promote?
|
|
external_upload_stub.status == ExternalUploadStub.statuses[:created]
|
|
end
|
|
|
|
def promote_to_upload!
|
|
raise CannotPromoteError if !can_promote?
|
|
|
|
external_upload_stub.update!(status: ExternalUploadStub.statuses[:uploaded])
|
|
external_stub_object = Discourse.store.object_from_path(external_upload_stub.key)
|
|
external_etag = external_stub_object.etag
|
|
external_size = external_stub_object.size
|
|
external_sha1 = external_stub_object.metadata["sha1-checksum"]
|
|
|
|
# This could be legitimately nil, if it's too big to download on the
|
|
# server, or it could have failed. To this end we set a should_download
|
|
# variable as well to check.
|
|
tempfile = nil
|
|
should_download = external_size < DOWNLOAD_LIMIT
|
|
|
|
# We require that the file size is specified ahead of time, and compare
|
|
# it here to make sure that people are not uploading excessively large
|
|
# files to the external provider. If this happens, the user will be banned
|
|
# from uploading to the external provider for N minutes.
|
|
if external_size != external_upload_stub.filesize
|
|
ExternalUploadManager.ban_user_from_external_uploads!(
|
|
user: external_upload_stub.created_by,
|
|
ban_minutes: SIZE_MISMATCH_BAN_MINUTES
|
|
)
|
|
raise SizeMismatchError.new("expected: #{external_upload_stub.filesize}, actual: #{external_size}")
|
|
end
|
|
|
|
if should_download
|
|
tempfile = download(external_upload_stub.key, external_upload_stub.upload_type)
|
|
|
|
raise DownloadFailedError if tempfile.blank?
|
|
|
|
actual_sha1 = Upload.generate_digest(tempfile)
|
|
if external_sha1 && external_sha1 != actual_sha1
|
|
raise ChecksumMismatchError
|
|
end
|
|
end
|
|
|
|
# TODO (martin): See if these additional opts will be needed
|
|
# - check if retain_hours is needed
|
|
opts = {
|
|
type: external_upload_stub.upload_type,
|
|
existing_external_upload_key: external_upload_stub.key,
|
|
external_upload_too_big: external_size > DOWNLOAD_LIMIT,
|
|
filesize: external_size
|
|
}.merge(@upload_create_opts)
|
|
|
|
UploadCreator.new(tempfile, external_upload_stub.original_filename, opts).create_for(
|
|
external_upload_stub.created_by_id
|
|
)
|
|
rescue
|
|
if !SiteSetting.enable_upload_debug_mode
|
|
# We don't need to do anything special to abort multipart uploads here,
|
|
# because at this point (calling promote_to_upload!), the multipart
|
|
# upload would already be complete.
|
|
Discourse.store.delete_file(external_upload_stub.key)
|
|
external_upload_stub.destroy!
|
|
else
|
|
external_upload_stub.update(status: ExternalUploadStub.statuses[:failed])
|
|
end
|
|
|
|
raise
|
|
ensure
|
|
tempfile&.close!
|
|
end
|
|
|
|
private
|
|
|
|
def download(key, type)
|
|
url = Discourse.store.signed_url_for_path(external_upload_stub.key)
|
|
FileHelper.download(
|
|
url,
|
|
max_file_size: DOWNLOAD_LIMIT,
|
|
tmp_file_name: "discourse-upload-#{type}",
|
|
follow_redirect: true
|
|
)
|
|
end
|
|
end
|