mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 18:09:26 +08:00
Add a default-deny ImageMagick security policy at `config/imagemagick/policy.xml`, loaded via `MAGICK_CONFIGURE_PATH` which is set in `config/initializers/003-imagemagick.rb`. Child processes inherit the env var, so every `identify`/`magick`/`convert` we shell out to is covered, including from plugins. Discourse previously shipped an imagemagick policy in production (see `discourse/discourse_docker` repository). This change brings the policy into the core repository to make it more visible, easier to manage, and applicable for development environments. Resource limitations remain the same. The main change is to move the allowed coders to an allowlist instead of a denylist. This ensures consistent safety, regardless of the set of modules available on the system. `Upload#fix_dimensions!` is updated to explicitly set the `MSVG:` decoder. This is the decoder which Imagemagick was selecting previously for `-ping` calls. But now, since the `svg` coder isn't in our allowlist, we have to specify msvg explicitly. The pdf-to-image logic in discourse-ai (behind an experimental, hidden, and default-disabled setting) is removed, since it depended on processing pdfs via Imagemagick/ghostscript. The spec for svg rasterization via OptimizedImage is also removed. This was not used in any application code. --- https://github.com/discourse/discourse/security/advisories/GHSA-7wq5-jgww-5rw3
730 lines
20 KiB
Ruby
Vendored
730 lines
20 KiB
Ruby
Vendored
# 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 = %r{(/original/\dX[/\.\w]*/(\h+)[\.\w]*)}
|
|
MAX_IDENTIFY_SECONDS = 5
|
|
DOMINANT_COLOR_COMMAND_TIMEOUT_SECONDS = 5
|
|
# the maximum length of a base62 encoded sha1
|
|
MAX_BASE62_SHA1_LENGTH = 27
|
|
|
|
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_hotlinked_media, dependent: :destroy, class_name: "PostHotlinkedMedia"
|
|
has_many :optimized_images, dependent: :destroy
|
|
has_many :optimized_videos, dependent: :destroy
|
|
has_many :optimized_video_uploads, through: :optimized_videos, source: :optimized_upload
|
|
has_many :user_uploads, dependent: :destroy
|
|
has_many :upload_references, dependent: :destroy
|
|
has_many :posts, through: :upload_references, source: :target, source_type: "Post"
|
|
has_many :topic_thumbnails
|
|
has_many :badges, foreign_key: :image_upload_id, dependent: :nullify
|
|
after_create :enqueue_video_conversion_job, if: :should_convert_video?
|
|
|
|
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
|
|
attr_accessor :site_setting_name
|
|
attr_accessor :validate_file_size
|
|
attr_accessor :skip_video_conversion
|
|
|
|
validates :filesize, presence: true
|
|
validates :original_filename, presence: true
|
|
validates :dominant_color, length: { is: 6 }, allow_blank: true
|
|
|
|
validates_with UploadValidator
|
|
|
|
before_destroy do
|
|
UserProfile.where(card_background_upload_id: id).update_all(card_background_upload_id: nil)
|
|
UserProfile.where(profile_background_upload_id: id).update_all(
|
|
profile_background_upload_id: nil,
|
|
)
|
|
end
|
|
|
|
after_destroy do
|
|
User.where(uploaded_avatar_id: id).update_all(uploaded_avatar_id: nil)
|
|
UserAvatar.where(gravatar_upload_id: id).update_all(gravatar_upload_id: nil)
|
|
UserAvatar.where(custom_upload_id: id).update_all(custom_upload_id: nil)
|
|
end
|
|
|
|
scope :by_users, -> { where("uploads.id > ?", SEEDED_ID_THRESHOLD) }
|
|
|
|
scope :without_s3_file_missing_confirmed_verification_status,
|
|
-> do
|
|
where.not(verification_status: Upload.verification_statuses[:s3_file_missing_confirmed])
|
|
end
|
|
|
|
scope :with_invalid_etag_verification_status,
|
|
-> { where(verification_status: Upload.verification_statuses[:invalid_etag]) }
|
|
|
|
def self.verification_statuses
|
|
@verification_statuses ||=
|
|
Enum.new(
|
|
unchecked: 1,
|
|
verified: 2,
|
|
invalid_etag: 3, # Used by S3Inventory to mark S3 Upload records that have an invalid ETag value compared to the ETag value of the inventory file
|
|
s3_file_missing_confirmed: 4, # Used by S3Inventory to skip S3 Upload records that are confirmed to not be backed by a file in the S3 file store
|
|
)
|
|
end
|
|
|
|
def self.fetch_from(sha1:, url:)
|
|
sha1.presence.try { |_| find_by(sha1:) } || get_from_url(url)
|
|
end
|
|
|
|
def self.mark_invalid_s3_uploads_as_missing
|
|
Upload.with_invalid_etag_verification_status.update_all(
|
|
verification_status: Upload.verification_statuses[:s3_file_missing_confirmed],
|
|
)
|
|
end
|
|
|
|
def self.add_unused_callback(&block)
|
|
(@unused_callbacks ||= []) << block
|
|
end
|
|
|
|
def self.unused_callbacks
|
|
@unused_callbacks
|
|
end
|
|
|
|
def self.reset_unused_callbacks
|
|
@unused_callbacks = []
|
|
end
|
|
|
|
def self.add_in_use_callback(&block)
|
|
(@in_use_callbacks ||= []) << block
|
|
end
|
|
|
|
def self.in_use_callbacks
|
|
@in_use_callbacks
|
|
end
|
|
|
|
def self.reset_in_use_callbacks
|
|
@in_use_callbacks = []
|
|
end
|
|
|
|
def self.with_no_non_post_relations
|
|
joins(
|
|
"LEFT JOIN upload_references ur ON ur.upload_id = uploads.id AND ur.target_type != 'Post'",
|
|
).where("ur.upload_id IS NULL")
|
|
end
|
|
|
|
def initialize(*args)
|
|
super
|
|
self.validate_file_size = true
|
|
end
|
|
|
|
def to_s
|
|
url
|
|
end
|
|
|
|
def to_markdown
|
|
UploadMarkdown.new(self).to_markdown
|
|
end
|
|
|
|
def thumbnail(width = thumbnail_width, height = 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 ||= {}
|
|
|
|
save(validate: false) if get_optimized_image(width, height, opts)
|
|
end
|
|
|
|
# this method attempts to correct old incorrect extensions
|
|
def get_optimized_image(width, height, opts = nil)
|
|
opts ||= {}
|
|
|
|
fix_image_extension if !extension || extension.length == 0
|
|
|
|
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 content
|
|
original_path = Discourse.store.path_for(self)
|
|
|
|
if original_path.blank?
|
|
File.read(Discourse.store.download!(self))
|
|
else
|
|
File.read(original_path)
|
|
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)
|
|
original_path = Discourse.store.download(self) if original_path.blank?
|
|
|
|
image_info =
|
|
begin
|
|
image = FastImage.new(original_path)
|
|
image.type # eager load to rescue errors early
|
|
image
|
|
rescue StandardError
|
|
nil
|
|
end
|
|
new_extension = image_info&.type&.to_s || "unknown"
|
|
|
|
if new_extension != extension
|
|
update_columns(extension: new_extension)
|
|
true
|
|
end
|
|
rescue StandardError
|
|
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_uploads_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: sha1, extension: extension)
|
|
end
|
|
|
|
def self.consider_for_reuse(upload, post)
|
|
return upload if !SiteSetting.secure_uploads? || upload.blank? || post.blank?
|
|
if !upload.matching_access_control_post?(post) || upload.uploaded_before_secure_uploads_enabled?
|
|
return nil
|
|
end
|
|
upload
|
|
end
|
|
|
|
def self.secure_uploads_url?(url)
|
|
# we do not want to exclude topic links that for whatever reason
|
|
# have secure-uploads in the URL e.g. /t/secure-uploads-are-cool/223452
|
|
route = UrlHelper.rails_route_from_url(url)
|
|
return false if route.blank?
|
|
route[:action] == "show_secure" && route[:controller] == "uploads" &&
|
|
FileHelper.is_supported_media?(url)
|
|
rescue ActionController::RoutingError
|
|
false
|
|
end
|
|
|
|
def self.signed_url_from_secure_uploads_url(url, include_content_disposition:)
|
|
route = UrlHelper.rails_route_from_url(url)
|
|
url = Rails.application.routes.url_for(route.merge(only_path: true))
|
|
secure_upload_s3_path = url[url.index(route[:path])..-1]
|
|
Discourse.store.signed_url_for_path(secure_upload_s3_path, include_content_disposition:)
|
|
end
|
|
|
|
def self.secure_uploads_url_from_upload_url(url)
|
|
return url if !url.include?(SiteSetting.Upload.absolute_base_url)
|
|
uri = URI.parse(url)
|
|
Rails.application.routes.url_for(
|
|
controller: "uploads",
|
|
action: "show_secure",
|
|
path: uri.path[1..-1],
|
|
only_path: true,
|
|
)
|
|
end
|
|
|
|
def self.short_path(sha1:, extension:)
|
|
@url_helpers ||= Rails.application.routes.url_helpers
|
|
|
|
@url_helpers.upload_short_path(base62: base62_sha1(sha1), extension: extension)
|
|
end
|
|
|
|
def self.base62_sha1(sha1)
|
|
Base62.encode(sha1.hex)
|
|
end
|
|
|
|
def base62_sha1
|
|
Upload.base62_sha1(sha1)
|
|
end
|
|
|
|
def local?
|
|
!(url =~ %r{\A(https?:)?//})
|
|
end
|
|
|
|
def fix_dimensions!
|
|
return if !FileHelper.is_supported_image?("image.#{extension}")
|
|
|
|
begin
|
|
path =
|
|
if local?
|
|
Discourse.store.path_for(self)
|
|
else
|
|
Discourse.store.download!(self)
|
|
end
|
|
|
|
if extension == "svg"
|
|
w, h =
|
|
begin
|
|
Discourse::Utils.execute_command(
|
|
"identify",
|
|
"-ping",
|
|
"-format",
|
|
"%w %h",
|
|
"MSVG:#{path}",
|
|
timeout: MAX_IDENTIFY_SECONDS,
|
|
).split(" ")
|
|
rescue StandardError
|
|
[0, 0]
|
|
end
|
|
else
|
|
w, h = FastImage.new(path, raise_on_failure: true).size
|
|
end
|
|
|
|
self.width = w || 0
|
|
self.height = h || 0
|
|
|
|
self.thumbnail_width, self.thumbnail_height = ImageSizer.resize(w, h)
|
|
|
|
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 = self[key]
|
|
return v
|
|
end
|
|
fix_dimensions!
|
|
self[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 dominant_color(calculate_if_missing: false)
|
|
val = read_attribute(:dominant_color)
|
|
if val.nil? && calculate_if_missing
|
|
calculate_dominant_color!
|
|
read_attribute(:dominant_color)
|
|
else
|
|
val
|
|
end
|
|
end
|
|
|
|
def calculate_dominant_color!(local_path = nil)
|
|
color = nil
|
|
|
|
color = "" if !FileHelper.is_supported_image?("image.#{extension}") || extension == "svg"
|
|
|
|
if color.nil?
|
|
local_path ||=
|
|
if local?
|
|
Discourse.store.path_for(self)
|
|
else
|
|
Discourse.store.download(self)
|
|
end
|
|
|
|
if local_path.nil?
|
|
# Download failed. Could be too large to download, or file could be missing in s3
|
|
color = ""
|
|
end
|
|
|
|
color ||=
|
|
begin
|
|
data =
|
|
Discourse::Utils.execute_command(
|
|
"nice",
|
|
"-n",
|
|
"10",
|
|
"convert",
|
|
local_path,
|
|
"-depth",
|
|
"8",
|
|
"-resize",
|
|
"1x1",
|
|
"-define",
|
|
"histogram:unique-colors=true",
|
|
"-format",
|
|
"%c",
|
|
"histogram:info:",
|
|
timeout: DOMINANT_COLOR_COMMAND_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
# Output format:
|
|
# 1: (110.873,116.226,93.8821) #6F745E srgb(43.4798%,45.5789%,36.8165%)
|
|
|
|
color = data[/#([0-9A-F]{6})/, 1]
|
|
|
|
raise "Calculated dominant color but unable to parse output:\n#{data}" if color.nil?
|
|
|
|
color
|
|
rescue Discourse::Utils::CommandError
|
|
# Timeout or unable to parse image
|
|
# This can happen due to bad user input - ignore and save
|
|
# an empty string to prevent re-evaluation
|
|
""
|
|
end
|
|
end
|
|
|
|
if persisted?
|
|
update_column(:dominant_color, color)
|
|
else
|
|
self.dominant_color = color
|
|
end
|
|
end
|
|
|
|
def target_image_quality(local_path, test_quality)
|
|
@file_quality ||=
|
|
begin
|
|
Discourse::Utils.execute_command(
|
|
"identify",
|
|
"-ping",
|
|
"-format",
|
|
"%Q",
|
|
local_path,
|
|
timeout: MAX_IDENTIFY_SECONDS,
|
|
).to_i
|
|
rescue StandardError
|
|
0
|
|
end
|
|
|
|
test_quality if @file_quality == 0 || @file_quality > test_quality
|
|
end
|
|
|
|
def self.sha1_from_short_path(path)
|
|
sha1_from_base62_encoded($2) if path =~ %r{(/uploads/short-url/)([a-zA-Z0-9]+)(\..*)?}
|
|
end
|
|
|
|
def self.sha1_from_short_url(url)
|
|
sha1_from_base62_encoded($2) if url =~ %r{(upload://)?([a-zA-Z0-9]+)(\..*)?}
|
|
end
|
|
|
|
def self.sha1_from_long_url(url)
|
|
$2 if url =~ URL_REGEX || url =~ OptimizedImage::URL_REGEX
|
|
end
|
|
|
|
def self.sha1_from_base62_encoded(encoded_sha1)
|
|
return nil if encoded_sha1.length > MAX_BASE62_SHA1_LENGTH
|
|
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(filesize)
|
|
end
|
|
|
|
def rebake_posts_on_old_scheme
|
|
posts.where("cooked LIKE '%/_optimized/%'").find_each(&:rebake!)
|
|
end
|
|
|
|
def update_secure_status(source: "unknown", override: nil)
|
|
if override.nil?
|
|
mark_secure, reason = UploadSecurity.new(self).should_be_secure_with_reason
|
|
else
|
|
mark_secure = override
|
|
reason = "manually overridden"
|
|
end
|
|
|
|
secure_status_did_change = secure? != mark_secure
|
|
update(secure_params(mark_secure, reason, source))
|
|
|
|
if secure_status_did_change && Discourse.store.external?
|
|
Discourse.store.update_upload_access_control(self)
|
|
sync_optimized_videos_secure_status(mark_secure)
|
|
end
|
|
|
|
secure_status_did_change
|
|
end
|
|
|
|
def sync_optimized_videos_secure_status(mark_secure)
|
|
optimized_videos
|
|
.includes(:optimized_upload)
|
|
.each do |optimized_video|
|
|
optimized_upload = optimized_video.optimized_upload
|
|
if optimized_upload.secure? != mark_secure
|
|
optimized_upload.update_secure_status(
|
|
source: "sync_with_original_upload",
|
|
override: mark_secure,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
def secure_params(secure, reason, source = "unknown")
|
|
{
|
|
secure: secure,
|
|
security_last_changed_reason: reason + " | source: #{source}",
|
|
security_last_changed_at: Time.zone.now,
|
|
}
|
|
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|
|
|
# keep track of the url
|
|
previous_url = upload.url.dup
|
|
# where is the file currently stored?
|
|
external = previous_url =~ %r{\A//}
|
|
# 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 (retries += 1) < 1
|
|
next
|
|
end
|
|
|
|
path = file.path
|
|
else
|
|
path = local_store.path_for(upload)
|
|
end
|
|
# compute SHA if missing
|
|
upload.sha1 = Upload.generate_digest(path) if upload.sha1.blank?
|
|
|
|
# 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 ||=
|
|
Post
|
|
.with_deleted
|
|
.where("raw ~ '/uploads/#{db}/\\d+/' OR raw ~ '/uploads/#{db}/original/(\\d|[a-z])/'")
|
|
.select(:id, :raw, :cooked)
|
|
.all
|
|
|
|
remap_scope.each do |post|
|
|
post.raw.gsub!(previous_url, upload.url)
|
|
post.cooked.gsub!(previous_url, upload.url)
|
|
if post.changed?
|
|
Post.with_deleted.where(id: post.id).update_all(raw: post.raw, cooked: post.cooked)
|
|
end
|
|
end
|
|
|
|
upload.optimized_images.find_each(&:destroy!)
|
|
upload.rebake_posts_on_old_scheme
|
|
# remove the old file (when local)
|
|
FileUtils.rm(path, force: true) unless external
|
|
rescue => e
|
|
problems << { upload: upload, ex: e }
|
|
ensure
|
|
file&.unlink
|
|
file&.close
|
|
end
|
|
end
|
|
end
|
|
|
|
problems
|
|
end
|
|
|
|
def self.extract_upload_ids(raw)
|
|
return [] if raw.blank?
|
|
|
|
sha1s = []
|
|
|
|
raw.scan(/\/(\h{40})/).each { |match| sha1s << match[0] }
|
|
|
|
raw
|
|
.scan(%r{/([a-zA-Z0-9]+)})
|
|
.each { |match| sha1s << Upload.sha1_from_base62_encoded(match[0]) }
|
|
|
|
Upload.where(sha1: sha1s.uniq).pluck(:id)
|
|
end
|
|
|
|
def self.backfill_dominant_colors!(count)
|
|
Upload
|
|
.where(dominant_color: nil)
|
|
.order("id desc")
|
|
.first(count)
|
|
.each { |upload| upload.calculate_dominant_color! }
|
|
end
|
|
|
|
private
|
|
|
|
def short_url_basename
|
|
"#{Upload.base62_sha1(sha1)}#{extension.present? ? ".#{extension}" : ""}"
|
|
end
|
|
|
|
def should_convert_video?
|
|
!skip_video_conversion && SiteSetting.video_conversion_enabled &&
|
|
SiteSetting.Upload.enable_s3_uploads && FileHelper.is_supported_video?(original_filename) &&
|
|
!OptimizedVideo.exists?(upload_id: id)
|
|
end
|
|
|
|
def enqueue_video_conversion_job
|
|
Jobs.enqueue(:convert_video, upload_id: id)
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: uploads
|
|
#
|
|
# id :integer not null, primary key
|
|
# animated :boolean
|
|
# dominant_color :text
|
|
# etag :string
|
|
# extension :string(10)
|
|
# filesize :bigint not null
|
|
# height :integer
|
|
# origin :string(2000)
|
|
# original_filename :string not null
|
|
# original_sha1 :string
|
|
# retain_hours :integer
|
|
# secure :boolean default(FALSE), not null
|
|
# security_last_changed_at :datetime
|
|
# security_last_changed_reason :string
|
|
# sha1 :string(40)
|
|
# thumbnail_height :integer
|
|
# thumbnail_width :integer
|
|
# url :string not null
|
|
# verification_status :integer default(1), not null
|
|
# width :integer
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# access_control_post_id :bigint
|
|
# user_id :integer not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# idx_uploads_on_verification_status (verification_status)
|
|
# 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 (id) WHERE (dominant_color IS NULL)
|
|
# 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)
|
|
#
|