mirror of
https://github.com/discourse/discourse.git
synced 2024-11-26 03:33:39 +08:00
d9a02d1336
This reverts commit20780a1eee
. * SECURITY: re-adds accidentally reverted commit: 03d26cd6: ensure embed_url contains valid http(s) uri * when the merge commite62a85cf
was reverted, git chose the2660c2e2
parent to land on instead of the03d26cd6
parent (which contains security fixes)
451 lines
12 KiB
Ruby
451 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "digest/sha1"
|
|
|
|
class Upload < ActiveRecord::Base
|
|
include ActionView::Helpers::NumberHelper
|
|
include HasUrl
|
|
|
|
SHA1_LENGTH = 40
|
|
SEEDED_ID_THRESHOLD = 0
|
|
URL_REGEX ||= /(\/original\/\dX[\/\.\w]*\/([a-zA-Z0-9]+)[\.\w]*)/
|
|
SECURE_MEDIA_ROUTE = "secure-media-uploads"
|
|
|
|
belongs_to :user
|
|
belongs_to :access_control_post, class_name: 'Post'
|
|
|
|
# when we access this post we don't care if the post
|
|
# is deleted
|
|
def access_control_post
|
|
Post.unscoped { super }
|
|
end
|
|
|
|
has_many :post_uploads, dependent: :destroy
|
|
has_many :posts, through: :post_uploads
|
|
|
|
has_many :optimized_images, dependent: :destroy
|
|
has_many :user_uploads, dependent: :destroy
|
|
has_many :topic_thumbnails
|
|
|
|
attr_accessor :for_group_message
|
|
attr_accessor :for_theme
|
|
attr_accessor :for_private_message
|
|
attr_accessor :for_export
|
|
attr_accessor :for_site_setting
|
|
attr_accessor :for_gravatar
|
|
|
|
validates_presence_of :filesize
|
|
validates_presence_of :original_filename
|
|
|
|
validates_with UploadValidator
|
|
|
|
after_destroy do
|
|
User.where(uploaded_avatar_id: self.id).update_all(uploaded_avatar_id: nil)
|
|
UserAvatar.where(gravatar_upload_id: self.id).update_all(gravatar_upload_id: nil)
|
|
UserAvatar.where(custom_upload_id: self.id).update_all(custom_upload_id: nil)
|
|
end
|
|
|
|
scope :by_users, -> { where("uploads.id > ?", SEEDED_ID_THRESHOLD) }
|
|
|
|
def to_s
|
|
self.url
|
|
end
|
|
|
|
def thumbnail(width = self.thumbnail_width, height = self.thumbnail_height)
|
|
optimized_images.find_by(width: width, height: height)
|
|
end
|
|
|
|
def has_thumbnail?(width, height)
|
|
thumbnail(width, height).present?
|
|
end
|
|
|
|
def create_thumbnail!(width, height, opts = nil)
|
|
return unless SiteSetting.create_thumbnails?
|
|
opts ||= {}
|
|
opts[:allow_animation] = SiteSetting.allow_animated_thumbnails
|
|
|
|
if get_optimized_image(width, height, opts)
|
|
save(validate: false)
|
|
end
|
|
end
|
|
|
|
# this method attempts to correct old incorrect extensions
|
|
def get_optimized_image(width, height, opts)
|
|
if (!extension || extension.length == 0)
|
|
fix_image_extension
|
|
end
|
|
|
|
opts = opts.merge(raise_on_error: true)
|
|
begin
|
|
OptimizedImage.create_for(self, width, height, opts)
|
|
rescue => ex
|
|
Rails.logger.info ex if Rails.env.development?
|
|
opts = opts.merge(raise_on_error: false)
|
|
if fix_image_extension
|
|
OptimizedImage.create_for(self, width, height, opts)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
end
|
|
|
|
def fix_image_extension
|
|
return false if extension == "unknown"
|
|
|
|
begin
|
|
# this is relatively cheap once cached
|
|
original_path = Discourse.store.path_for(self)
|
|
if original_path.blank?
|
|
external_copy = Discourse.store.download(self) rescue nil
|
|
original_path = external_copy.try(:path)
|
|
end
|
|
|
|
image_info = FastImage.new(original_path) rescue nil
|
|
new_extension = image_info&.type&.to_s || "unknown"
|
|
|
|
if new_extension != self.extension
|
|
self.update_columns(extension: new_extension)
|
|
true
|
|
end
|
|
rescue
|
|
self.update_columns(extension: "unknown")
|
|
true
|
|
end
|
|
end
|
|
|
|
def destroy
|
|
Upload.transaction do
|
|
Discourse.store.remove_upload(self)
|
|
super
|
|
end
|
|
end
|
|
|
|
def short_url
|
|
"upload://#{short_url_basename}"
|
|
end
|
|
|
|
def uploaded_before_secure_media_enabled?
|
|
original_sha1.blank?
|
|
end
|
|
|
|
def matching_access_control_post?(post)
|
|
access_control_post_id == post.id
|
|
end
|
|
|
|
def copied_from_other_post?(post)
|
|
return false if access_control_post_id.blank?
|
|
!matching_access_control_post?(post)
|
|
end
|
|
|
|
def short_path
|
|
self.class.short_path(sha1: self.sha1, extension: self.extension)
|
|
end
|
|
|
|
def self.consider_for_reuse(upload, post)
|
|
return upload if !SiteSetting.secure_media? || upload.blank? || post.blank?
|
|
return nil if !upload.matching_access_control_post?(post) || upload.uploaded_before_secure_media_enabled?
|
|
upload
|
|
end
|
|
|
|
def self.secure_media_url?(url)
|
|
# we do not want to exclude topic links that for whatever reason
|
|
# have secure-media-uploads in the URL e.g. /t/secure-media-uploads-are-cool/223452
|
|
url.include?(SECURE_MEDIA_ROUTE) && !url.include?("/t/") && FileHelper.is_supported_media?(url)
|
|
end
|
|
|
|
def self.signed_url_from_secure_media_url(url)
|
|
secure_upload_s3_path = url.sub(Discourse.base_url, "").sub("/#{SECURE_MEDIA_ROUTE}/", "")
|
|
Discourse.store.signed_url_for_path(secure_upload_s3_path)
|
|
end
|
|
|
|
def self.secure_media_url_from_upload_url(url)
|
|
url.sub(SiteSetting.Upload.absolute_base_url, "/#{SECURE_MEDIA_ROUTE}")
|
|
end
|
|
|
|
def self.short_path(sha1:, extension:)
|
|
@url_helpers ||= Rails.application.routes.url_helpers
|
|
|
|
@url_helpers.upload_short_path(
|
|
base62: self.base62_sha1(sha1),
|
|
extension: extension
|
|
)
|
|
end
|
|
|
|
def self.base62_sha1(sha1)
|
|
Base62.encode(sha1.hex)
|
|
end
|
|
|
|
def base62_sha1
|
|
Upload.base62_sha1(self.sha1)
|
|
end
|
|
|
|
def local?
|
|
!(url =~ /^(https?:)?\/\//)
|
|
end
|
|
|
|
def fix_dimensions!
|
|
return if !FileHelper.is_supported_image?("image.#{extension}")
|
|
|
|
path =
|
|
if local?
|
|
Discourse.store.path_for(self)
|
|
else
|
|
Discourse.store.download(self).path
|
|
end
|
|
|
|
begin
|
|
w, h = FastImage.new(path, raise_on_failure: true).size
|
|
|
|
self.width = w || 0
|
|
self.height = h || 0
|
|
|
|
self.thumbnail_width, self.thumbnail_height = ImageSizer.resize(w, h)
|
|
|
|
self.update_columns(
|
|
width: width,
|
|
height: height,
|
|
thumbnail_width: thumbnail_width,
|
|
thumbnail_height: thumbnail_height
|
|
)
|
|
rescue => e
|
|
Discourse.warn_exception(e, message: "Error getting image dimensions")
|
|
end
|
|
nil
|
|
end
|
|
|
|
# on demand image size calculation, this allows us to null out image sizes
|
|
# and still handle as needed
|
|
def get_dimension(key)
|
|
if v = read_attribute(key)
|
|
return v
|
|
end
|
|
fix_dimensions!
|
|
read_attribute(key)
|
|
end
|
|
|
|
def width
|
|
get_dimension(:width)
|
|
end
|
|
|
|
def height
|
|
get_dimension(:height)
|
|
end
|
|
|
|
def thumbnail_width
|
|
get_dimension(:thumbnail_width)
|
|
end
|
|
|
|
def thumbnail_height
|
|
get_dimension(:thumbnail_height)
|
|
end
|
|
|
|
def self.sha1_from_short_path(path)
|
|
if path =~ /(\/uploads\/short-url\/)([a-zA-Z0-9]+)(\..*)?/
|
|
self.sha1_from_base62_encoded($2)
|
|
end
|
|
end
|
|
|
|
def self.sha1_from_short_url(url)
|
|
if url =~ /(upload:\/\/)?([a-zA-Z0-9]+)(\..*)?/
|
|
self.sha1_from_base62_encoded($2)
|
|
end
|
|
end
|
|
|
|
def self.sha1_from_base62_encoded(encoded_sha1)
|
|
sha1 = Base62.decode(encoded_sha1).to_s(16)
|
|
|
|
if sha1.length > SHA1_LENGTH
|
|
nil
|
|
else
|
|
sha1.rjust(SHA1_LENGTH, '0')
|
|
end
|
|
end
|
|
|
|
def self.generate_digest(path)
|
|
Digest::SHA1.file(path).hexdigest
|
|
end
|
|
|
|
def human_filesize
|
|
number_to_human_size(self.filesize)
|
|
end
|
|
|
|
def rebake_posts_on_old_scheme
|
|
self.posts.where("cooked LIKE '%/_optimized/%'").find_each(&:rebake!)
|
|
end
|
|
|
|
def update_secure_status(secure_override_value: nil)
|
|
mark_secure = secure_override_value.nil? ? UploadSecurity.new(self).should_be_secure? : secure_override_value
|
|
|
|
secure_status_did_change = self.secure? != mark_secure
|
|
self.update_column("secure", mark_secure)
|
|
Discourse.store.update_upload_ACL(self) if Discourse.store.external?
|
|
|
|
secure_status_did_change
|
|
end
|
|
|
|
def self.migrate_to_new_scheme(limit: nil)
|
|
problems = []
|
|
|
|
DistributedMutex.synchronize("migrate_upload_to_new_scheme") do
|
|
if SiteSetting.migrate_to_new_scheme
|
|
max_file_size_kb = [
|
|
SiteSetting.max_image_size_kb,
|
|
SiteSetting.max_attachment_size_kb
|
|
].max.kilobytes
|
|
|
|
local_store = FileStore::LocalStore.new
|
|
db = RailsMultisite::ConnectionManagement.current_db
|
|
|
|
scope = Upload.by_users
|
|
.where("url NOT LIKE '%/original/_X/%' AND url LIKE '%/uploads/#{db}%'")
|
|
.order(id: :desc)
|
|
|
|
scope = scope.limit(limit) if limit
|
|
|
|
if scope.count == 0
|
|
SiteSetting.migrate_to_new_scheme = false
|
|
return problems
|
|
end
|
|
|
|
remap_scope = nil
|
|
|
|
scope.each do |upload|
|
|
begin
|
|
# keep track of the url
|
|
previous_url = upload.url.dup
|
|
# where is the file currently stored?
|
|
external = previous_url =~ /^\/\//
|
|
# download if external
|
|
if external
|
|
url = SiteSetting.scheme + ":" + previous_url
|
|
|
|
begin
|
|
retries ||= 0
|
|
|
|
file = FileHelper.download(
|
|
url,
|
|
max_file_size: max_file_size_kb,
|
|
tmp_file_name: "discourse",
|
|
follow_redirect: true
|
|
)
|
|
rescue OpenURI::HTTPError
|
|
retry if (retires += 1) < 1
|
|
next
|
|
end
|
|
|
|
path = file.path
|
|
else
|
|
path = local_store.path_for(upload)
|
|
end
|
|
# compute SHA if missing
|
|
if upload.sha1.blank?
|
|
upload.sha1 = Upload.generate_digest(path)
|
|
end
|
|
|
|
# store to new location & update the filesize
|
|
File.open(path) do |f|
|
|
upload.url = Discourse.store.store_upload(f, upload)
|
|
upload.filesize = f.size
|
|
upload.save!(validate: false)
|
|
end
|
|
# remap the URLs
|
|
DbHelper.remap(UrlHelper.absolute(previous_url), upload.url) unless external
|
|
|
|
DbHelper.remap(
|
|
previous_url,
|
|
upload.url,
|
|
excluded_tables: %w{
|
|
posts
|
|
post_search_data
|
|
incoming_emails
|
|
notifications
|
|
single_sign_on_records
|
|
stylesheet_cache
|
|
topic_search_data
|
|
users
|
|
user_emails
|
|
draft_sequences
|
|
optimized_images
|
|
}
|
|
)
|
|
|
|
remap_scope ||= begin
|
|
Post.with_deleted
|
|
.where("raw ~ '/uploads/#{db}/\\d+/' OR raw ~ '/uploads/#{db}/original/(\\d|[a-z])/'")
|
|
.select(:id, :raw, :cooked)
|
|
.all
|
|
end
|
|
|
|
remap_scope.each do |post|
|
|
post.raw.gsub!(previous_url, upload.url)
|
|
post.cooked.gsub!(previous_url, upload.url)
|
|
Post.with_deleted.where(id: post.id).update_all(raw: post.raw, cooked: post.cooked) if post.changed?
|
|
end
|
|
|
|
upload.optimized_images.find_each(&:destroy!)
|
|
upload.rebake_posts_on_old_scheme
|
|
# remove the old file (when local)
|
|
unless external
|
|
FileUtils.rm(path, force: true)
|
|
end
|
|
rescue => e
|
|
problems << { upload: upload, ex: e }
|
|
ensure
|
|
file&.unlink
|
|
file&.close
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
problems
|
|
end
|
|
|
|
def self.reset_unknown_extensions!
|
|
Upload.where(extension: "unknown").update_all(extension: nil)
|
|
end
|
|
|
|
private
|
|
|
|
def short_url_basename
|
|
"#{Upload.base62_sha1(sha1)}#{extension.present? ? ".#{extension}" : ""}"
|
|
end
|
|
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: uploads
|
|
#
|
|
# id :integer not null, primary key
|
|
# user_id :integer not null
|
|
# original_filename :string not null
|
|
# filesize :integer not null
|
|
# width :integer
|
|
# height :integer
|
|
# url :string not null
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# sha1 :string(40)
|
|
# origin :string(1000)
|
|
# retain_hours :integer
|
|
# extension :string(10)
|
|
# thumbnail_width :integer
|
|
# thumbnail_height :integer
|
|
# etag :string
|
|
# secure :boolean default(FALSE), not null
|
|
# access_control_post_id :bigint
|
|
# original_sha1 :string
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_uploads_on_access_control_post_id (access_control_post_id)
|
|
# index_uploads_on_etag (etag)
|
|
# index_uploads_on_extension (lower((extension)::text))
|
|
# index_uploads_on_id_and_url (id,url)
|
|
# index_uploads_on_original_sha1 (original_sha1)
|
|
# index_uploads_on_sha1 (sha1) UNIQUE
|
|
# index_uploads_on_url (url)
|
|
# index_uploads_on_user_id (user_id)
|
|
#
|