2019-05-02 18:17:27 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-11-05 13:04:47 -05:00
|
|
|
module Jobs
|
2019-10-02 00:01:53 -04:00
|
|
|
class PullHotlinkedImages < ::Jobs::Base
|
2016-04-06 22:56:43 -04:00
|
|
|
sidekiq_options queue: "low"
|
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
def execute(args)
|
2022-05-16 12:56:00 -04:00
|
|
|
disable_if_low_on_disk_space
|
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
@post_id = args[:post_id]
|
|
|
|
raise Discourse::InvalidParameters.new(:post_id) if @post_id.blank?
|
|
|
|
|
2024-07-02 05:06:46 -04:00
|
|
|
# in test we have no choice cause we don't want to cause a deadlock
|
|
|
|
if Jobs.run_immediately?
|
|
|
|
pull_hotlinked_images
|
|
|
|
else
|
|
|
|
DistributedMutex.synchronize("pull_hotlinked_images_#{@post_id}", validity: 2.minutes) do
|
|
|
|
pull_hotlinked_images
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def pull_hotlinked_images
|
2020-08-05 07:14:59 -04:00
|
|
|
post = Post.find_by(id: @post_id)
|
2022-05-16 12:56:00 -04:00
|
|
|
return if post.nil? || post.topic.nil?
|
2020-08-05 07:14:59 -04:00
|
|
|
|
2022-05-03 08:53:32 -04:00
|
|
|
hotlinked_map = post.post_hotlinked_media.map { |r| [r.url, r] }.to_h
|
2020-08-05 07:14:59 -04:00
|
|
|
|
2022-05-03 08:53:32 -04:00
|
|
|
changed_hotlink_records = false
|
2020-08-05 07:14:59 -04:00
|
|
|
|
|
|
|
extract_images_from(post.cooked).each do |node|
|
FEATURE: Allow hotlinked media to be blocked (#16940)
This commit introduces a new site setting: `block_hotlinked_media`. When enabled, all attempts to hotlink media (images, videos, and audio) will fail, and be replaced with a linked placeholder. Exceptions to the rule can be added via `block_hotlinked_media_exceptions`.
`download_remote_image_to_local` can be used alongside this feature. In that case, hotlinked images will be blocked immediately when the post is created, but will then be replaced with the downloaded version a few seconds later.
This implementation is purely server-side, and does not impact the composer preview.
Technically, there are two stages to this feature:
1. `PrettyText.sanitize_hotlinked_media` is called during `PrettyText.cook`, and whenever new images are introduced by Onebox. It will iterate over all src/srcset attributes in the post HTML and check if they're allowed. If not, the attributes will be removed and replaced with a `data-blocked-hotlinked-src(set)` attribute
2. In the `CookedPostProcessor`, we iterate over all `data-blocked-hotlinked-src(set)` attributes and check whether we have a downloaded version of the media. If yes, we update the src to use the downloaded version. If not, the entire media element is replaced with a placeholder. The placeholder is labelled 'external media', and is a link to the offsite media.
2022-06-07 10:23:04 -04:00
|
|
|
download_src =
|
|
|
|
original_src = node["src"] || node[PrettyText::BLOCKED_HOTLINKED_SRC_ATTR] || node["href"]
|
2023-06-19 21:49:22 -04:00
|
|
|
download_src = replace_encoded_src(download_src)
|
2020-08-05 07:14:59 -04:00
|
|
|
download_src =
|
|
|
|
"#{SiteSetting.force_https ? "https" : "http"}:#{original_src}" if original_src.start_with?(
|
|
|
|
"//",
|
|
|
|
)
|
|
|
|
normalized_src = normalize_src(download_src)
|
|
|
|
|
|
|
|
next if !should_download_image?(download_src, post)
|
|
|
|
|
2022-05-03 08:53:32 -04:00
|
|
|
hotlink_record = hotlinked_map[normalized_src]
|
|
|
|
|
|
|
|
if hotlink_record.nil?
|
|
|
|
hotlinked_map[normalized_src] = hotlink_record =
|
|
|
|
PostHotlinkedMedia.new(post: post, url: normalized_src)
|
|
|
|
begin
|
|
|
|
hotlink_record.upload = attempt_download(download_src, post.user_id)
|
|
|
|
hotlink_record.status = :downloaded
|
|
|
|
rescue ImageTooLargeError
|
|
|
|
hotlink_record.status = :too_large
|
|
|
|
rescue ImageBrokenError
|
|
|
|
hotlink_record.status = :download_failed
|
|
|
|
rescue UploadCreateError
|
|
|
|
hotlink_record.status = :upload_create_failed
|
2020-08-05 07:14:59 -04:00
|
|
|
end
|
2022-05-03 08:53:32 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
if hotlink_record.changed?
|
|
|
|
changed_hotlink_records = true
|
|
|
|
hotlink_record.save!
|
2020-08-05 07:14:59 -04:00
|
|
|
end
|
|
|
|
rescue => e
|
|
|
|
raise e if Rails.env.test?
|
|
|
|
log(
|
|
|
|
:error,
|
|
|
|
"Failed to pull hotlinked image (#{download_src}) post: #{@post_id}\n" + e.message +
|
|
|
|
"\n" + e.backtrace.join("\n"),
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2022-05-16 12:56:00 -04:00
|
|
|
if changed_hotlink_records
|
2020-08-05 07:14:59 -04:00
|
|
|
post.trigger_post_process(
|
|
|
|
bypass_bump: true,
|
|
|
|
skip_pull_hotlinked_images: true, # Avoid an infinite loop of job scheduling
|
|
|
|
)
|
|
|
|
end
|
2022-05-16 12:56:00 -04:00
|
|
|
|
|
|
|
if hotlinked_map.size > 0
|
|
|
|
Jobs.cancel_scheduled_job(:update_hotlinked_raw, post_id: post.id)
|
|
|
|
update_raw_delay = SiteSetting.editing_grace_period + 1
|
|
|
|
Jobs.enqueue_in(update_raw_delay, :update_hotlinked_raw, post_id: post.id)
|
|
|
|
end
|
2020-08-05 07:14:59 -04:00
|
|
|
end
|
|
|
|
|
2017-10-11 17:11:44 -04:00
|
|
|
def download(src)
|
|
|
|
downloaded = nil
|
|
|
|
|
|
|
|
begin
|
|
|
|
retries ||= 3
|
|
|
|
|
2021-06-04 08:13:58 -04:00
|
|
|
if SiteSetting.verbose_upload_logging
|
|
|
|
Rails.logger.warn("Verbose Upload Logging: Downloading hotlinked image from #{src}")
|
|
|
|
end
|
|
|
|
|
2017-10-11 17:11:44 -04:00
|
|
|
downloaded =
|
|
|
|
FileHelper.download(
|
|
|
|
src,
|
2024-08-02 11:15:14 -04:00
|
|
|
max_file_size: SiteSetting.max_image_size_kb.kilobytes,
|
2018-08-17 04:52:55 -04:00
|
|
|
retain_on_max_file_size_exceeded: true,
|
2017-10-11 17:11:44 -04:00
|
|
|
tmp_file_name: "discourse-hotlinked",
|
2022-08-23 09:38:10 -04:00
|
|
|
follow_redirect: true,
|
|
|
|
read_timeout: 15,
|
2017-10-11 17:11:44 -04:00
|
|
|
)
|
2022-03-15 17:55:05 -04:00
|
|
|
rescue => e
|
|
|
|
if SiteSetting.verbose_upload_logging
|
|
|
|
Rails.logger.warn("Verbose Upload Logging: Error '#{e.message}' while downloading #{src}")
|
|
|
|
end
|
|
|
|
|
2018-03-28 00:54:11 -04:00
|
|
|
if (retries -= 1) > 0 && !Rails.env.test?
|
2017-10-11 17:11:44 -04:00
|
|
|
sleep 1
|
|
|
|
retry
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
downloaded
|
|
|
|
end
|
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
class ImageTooLargeError < StandardError
|
|
|
|
end
|
2023-12-15 10:46:04 -05:00
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
class ImageBrokenError < StandardError
|
|
|
|
end
|
2023-12-15 10:46:04 -05:00
|
|
|
|
2022-05-03 08:53:32 -04:00
|
|
|
class UploadCreateError < StandardError
|
|
|
|
end
|
2013-11-05 13:04:47 -05:00
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
def attempt_download(src, user_id)
|
2022-09-28 19:24:33 -04:00
|
|
|
# secure-uploads endpoint prevents anonymous downloads, so we
|
2020-08-05 07:14:59 -04:00
|
|
|
# need the presigned S3 URL here
|
2022-09-28 19:24:33 -04:00
|
|
|
src = Upload.signed_url_from_secure_uploads_url(src) if Upload.secure_uploads_url?(src)
|
2013-11-05 13:04:47 -05:00
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
hotlinked = download(src)
|
|
|
|
raise ImageBrokenError if !hotlinked
|
2024-08-02 11:15:14 -04:00
|
|
|
if File.size(hotlinked.path) > SiteSetting.max_image_size_kb.kilobytes
|
|
|
|
raise ImageTooLargeError
|
|
|
|
end
|
2017-11-16 09:45:07 -05:00
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
filename = File.basename(URI.parse(src).path)
|
|
|
|
filename << File.extname(hotlinked.path) unless filename["."]
|
|
|
|
upload = UploadCreator.new(hotlinked, filename, origin: src).create_for(user_id)
|
2017-11-15 23:13:15 -05:00
|
|
|
|
2020-08-05 07:14:59 -04:00
|
|
|
if upload.persisted?
|
|
|
|
upload
|
|
|
|
else
|
|
|
|
log(
|
|
|
|
:info,
|
|
|
|
"Failed to persist downloaded hotlinked image for post: #{@post_id}: #{src} - #{upload.errors.full_messages.join("\n")}",
|
|
|
|
)
|
2022-05-03 08:53:32 -04:00
|
|
|
raise UploadCreateError
|
2020-08-05 07:14:59 -04:00
|
|
|
end
|
|
|
|
end
|
2013-11-05 13:04:47 -05:00
|
|
|
|
|
|
|
def extract_images_from(html)
|
2020-05-04 23:46:57 -04:00
|
|
|
doc = Nokogiri::HTML5.fragment(html)
|
2019-06-06 03:50:35 -04:00
|
|
|
|
FEATURE: Allow hotlinked media to be blocked (#16940)
This commit introduces a new site setting: `block_hotlinked_media`. When enabled, all attempts to hotlink media (images, videos, and audio) will fail, and be replaced with a linked placeholder. Exceptions to the rule can be added via `block_hotlinked_media_exceptions`.
`download_remote_image_to_local` can be used alongside this feature. In that case, hotlinked images will be blocked immediately when the post is created, but will then be replaced with the downloaded version a few seconds later.
This implementation is purely server-side, and does not impact the composer preview.
Technically, there are two stages to this feature:
1. `PrettyText.sanitize_hotlinked_media` is called during `PrettyText.cook`, and whenever new images are introduced by Onebox. It will iterate over all src/srcset attributes in the post HTML and check if they're allowed. If not, the attributes will be removed and replaced with a `data-blocked-hotlinked-src(set)` attribute
2. In the `CookedPostProcessor`, we iterate over all `data-blocked-hotlinked-src(set)` attributes and check whether we have a downloaded version of the media. If yes, we update the src to use the downloaded version. If not, the entire media element is replaced with a placeholder. The placeholder is labelled 'external media', and is a link to the offsite media.
2022-06-07 10:23:04 -04:00
|
|
|
doc.css("img[src], [#{PrettyText::BLOCKED_HOTLINKED_SRC_ATTR}], a.lightbox[href]") -
|
2019-06-06 03:50:35 -04:00
|
|
|
doc.css("img.avatar") - doc.css(".lightbox img[src]")
|
2013-11-05 13:04:47 -05:00
|
|
|
end
|
|
|
|
|
2020-01-28 19:11:38 -05:00
|
|
|
def should_download_image?(src, post = nil)
|
2014-05-07 13:49:16 -04:00
|
|
|
# make sure we actually have a url
|
2024-05-27 06:27:13 -04:00
|
|
|
return false if src.blank?
|
2019-03-27 16:31:12 -04:00
|
|
|
|
2020-06-19 15:21:05 -04:00
|
|
|
local_bases =
|
2021-03-02 14:04:16 -05:00
|
|
|
[Discourse.base_url, Discourse.asset_host, SiteSetting.external_emoji_url.presence].compact
|
2020-06-19 15:21:05 -04:00
|
|
|
.map { |s| normalize_src(s) }
|
|
|
|
|
|
|
|
if Discourse.store.has_been_uploaded?(src) || normalize_src(src).start_with?(*local_bases) ||
|
|
|
|
src =~ %r{\A/[^/]}i
|
2022-09-28 19:24:33 -04:00
|
|
|
return false if !(src =~ %r{/uploads/} || Upload.secure_uploads_url?(src))
|
2019-06-07 08:00:52 -04:00
|
|
|
|
|
|
|
# Someone could hotlink a file from a different site on the same CDN,
|
|
|
|
# so check whether we have it in this database
|
2020-01-28 19:11:38 -05:00
|
|
|
#
|
|
|
|
# if the upload already exists and is attached to a different post,
|
|
|
|
# or the original_sha1 is missing meaning it was created before secure
|
|
|
|
# media was enabled, then we definitely want to redownload again otherwise
|
|
|
|
# we end up reusing existing uploads which may be linked to many posts
|
|
|
|
# already.
|
|
|
|
upload = Upload.consider_for_reuse(Upload.get_from_url(src), post)
|
|
|
|
|
|
|
|
return !upload.present?
|
2019-03-27 16:31:12 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# Don't download non-local images unless site setting enabled
|
|
|
|
return false unless SiteSetting.download_remote_images_to_local?
|
2017-07-06 04:55:28 -04:00
|
|
|
|
2014-05-07 13:49:16 -04:00
|
|
|
# parse the src
|
|
|
|
begin
|
|
|
|
uri = URI.parse(src)
|
2018-08-14 06:23:32 -04:00
|
|
|
rescue URI::Error
|
2014-05-07 13:49:16 -04:00
|
|
|
return false
|
|
|
|
end
|
2017-07-06 04:55:28 -04:00
|
|
|
|
|
|
|
hostname = uri.hostname
|
|
|
|
return false unless hostname
|
|
|
|
|
2020-07-26 20:23:54 -04:00
|
|
|
# check the domains blocklist
|
2014-04-21 16:59:53 -04:00
|
|
|
SiteSetting.should_download_images?(src)
|
2013-11-05 13:04:47 -05:00
|
|
|
end
|
|
|
|
|
2017-07-04 21:34:24 -04:00
|
|
|
def log(log_level, message)
|
|
|
|
Rails.logger.public_send(
|
|
|
|
log_level,
|
|
|
|
"#{RailsMultisite::ConnectionManagement.current_db}: #{message}",
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2021-10-29 10:58:05 -04:00
|
|
|
protected
|
2018-03-28 02:44:42 -04:00
|
|
|
|
2023-06-19 21:49:22 -04:00
|
|
|
def replace_encoded_src(src)
|
|
|
|
PostHotlinkedMedia.normalize_src(src, reset_scheme: false)
|
|
|
|
end
|
|
|
|
|
2020-05-29 12:47:05 -04:00
|
|
|
def normalize_src(src)
|
2022-05-13 09:11:45 -04:00
|
|
|
PostHotlinkedMedia.normalize_src(src)
|
2018-03-28 02:44:42 -04:00
|
|
|
end
|
2022-05-16 12:56:00 -04:00
|
|
|
|
|
|
|
def disable_if_low_on_disk_space
|
|
|
|
return if Discourse.store.external?
|
|
|
|
return if !SiteSetting.download_remote_images_to_local
|
|
|
|
return if available_disk_space >= SiteSetting.download_remote_images_threshold
|
|
|
|
|
|
|
|
SiteSetting.download_remote_images_to_local = false
|
|
|
|
|
|
|
|
# log the site setting change
|
|
|
|
reason = I18n.t("disable_remote_images_download_reason")
|
|
|
|
staff_action_logger = StaffActionLogger.new(Discourse.system_user)
|
|
|
|
staff_action_logger.log_site_setting_change(
|
|
|
|
"download_remote_images_to_local",
|
|
|
|
true,
|
|
|
|
false,
|
|
|
|
details: reason,
|
|
|
|
)
|
|
|
|
|
|
|
|
# also send a private message to the site contact user notify_about_low_disk_space
|
|
|
|
notify_about_low_disk_space
|
|
|
|
end
|
|
|
|
|
|
|
|
def notify_about_low_disk_space
|
|
|
|
SystemMessage.create_from_system_user(
|
|
|
|
Discourse.site_contact_user,
|
|
|
|
:download_remote_images_disabled,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
def available_disk_space
|
|
|
|
100 - DiskSpace.percent_free("#{Rails.root}/public/uploads")
|
|
|
|
end
|
2013-11-05 13:04:47 -05:00
|
|
|
end
|
|
|
|
end
|