Add a model for storing image and video metadata for uploaded files. Metadata is extracted using ExifTool. You will need to install ExifTool after this commit. ExifTool 12.22 is the minimum required version because we use the `--binary` option, which was added in this release. The MediaMetadata model is separate from the MediaAsset model because some files contain tons of metadata, and most of it is non-essential. The MediaAsset model represents an uploaded file and contains essential metadata, like the file's size and type, while the MediaMetadata model represents all the other non-essential metadata associated with a file. Metadata is stored as a JSON column in the database. ExifTool returns all the file's metadata, not just the EXIF metadata. EXIF is one of several types of image metadata, hence why we call it MediaMetadata instead of EXIFMetadata.
235 lines
6.8 KiB
Ruby
235 lines
6.8 KiB
Ruby
class Upload < ApplicationRecord
|
|
class Error < StandardError; end
|
|
|
|
MAX_VIDEO_DURATION = 140
|
|
|
|
class FileValidator < ActiveModel::Validator
|
|
def validate(record)
|
|
validate_file_ext(record)
|
|
validate_integrity(record)
|
|
validate_md5_uniqueness(record)
|
|
validate_video_duration(record)
|
|
validate_resolution(record)
|
|
end
|
|
|
|
def validate_file_ext(record)
|
|
if record.file_ext.in?(["bin", "swf"])
|
|
record.errors.add(:file_ext, "is invalid (only JPEG, PNG, GIF, MP4, and WebM files are allowed")
|
|
end
|
|
end
|
|
|
|
def validate_integrity(record)
|
|
if record.media_file.is_corrupt?
|
|
record.errors.add(:file, "is corrupted")
|
|
end
|
|
end
|
|
|
|
def validate_md5_uniqueness(record)
|
|
if record.md5.nil?
|
|
return
|
|
end
|
|
|
|
md5_post = Post.find_by_md5(record.md5)
|
|
|
|
if md5_post.nil?
|
|
return
|
|
end
|
|
|
|
if record.replaced_post && record.replaced_post == md5_post
|
|
return
|
|
end
|
|
|
|
record.errors.add(:md5, "duplicate: #{md5_post.id}")
|
|
end
|
|
|
|
def validate_resolution(record)
|
|
resolution = record.image_width.to_i * record.image_height.to_i
|
|
|
|
if resolution > Danbooru.config.max_image_resolution
|
|
record.errors.add(:base, "image resolution is too large (resolution: #{(resolution / 1_000_000.0).round(1)} megapixels (#{record.image_width}x#{record.image_height}); max: #{Danbooru.config.max_image_resolution / 1_000_000} megapixels)")
|
|
elsif record.image_width > Danbooru.config.max_image_width
|
|
record.errors.add(:image_width, "is too large (width: #{record.image_width}; max width: #{Danbooru.config.max_image_width})")
|
|
elsif record.image_height > Danbooru.config.max_image_height
|
|
record.errors.add(:image_height, "is too large (height: #{record.image_height}; max height: #{Danbooru.config.max_image_height})")
|
|
end
|
|
end
|
|
|
|
def validate_video_duration(record)
|
|
if !record.uploader.is_admin? && record.media_file.is_video? && record.media_file.duration > MAX_VIDEO_DURATION
|
|
record.errors.add(:base, "video must not be longer than #{MAX_VIDEO_DURATION.seconds.inspect}")
|
|
end
|
|
end
|
|
end
|
|
|
|
attr_accessor :as_pending, :replaced_post, :file
|
|
|
|
belongs_to :uploader, :class_name => "User"
|
|
belongs_to :post, optional: true
|
|
has_one :media_asset, foreign_key: :md5, primary_key: :md5
|
|
|
|
before_validation :initialize_attributes, on: :create
|
|
before_validation :assign_rating_from_tags
|
|
# validates :source, format: { with: /\Ahttps?/ }, if: ->(record) {record.file.blank?}, on: :create
|
|
validates :rating, inclusion: { in: %w[q e s] }, allow_nil: true
|
|
validates :md5, confirmation: true, if: ->(rec) { rec.md5_confirmation.present? }
|
|
validates_with FileValidator, on: :file
|
|
serialize :context, JSON
|
|
|
|
after_destroy_commit :delete_files
|
|
|
|
scope :pending, -> { where(status: "pending") }
|
|
scope :preprocessed, -> { where(status: "preprocessed") }
|
|
scope :completed, -> { where(status: "completed") }
|
|
scope :uploaded_by, ->(user_id) { where(uploader_id: user_id) }
|
|
|
|
def initialize_attributes
|
|
self.uploader_id = CurrentUser.id
|
|
self.uploader_ip_addr = CurrentUser.ip_addr
|
|
self.server = Socket.gethostname
|
|
end
|
|
|
|
def self.prune!
|
|
completed.where("created_at < ?", 1.hour.ago).lock.destroy_all
|
|
preprocessed.where("created_at < ?", 1.day.ago).lock.destroy_all
|
|
where("created_at < ?", 3.days.ago).lock.destroy_all
|
|
end
|
|
|
|
def self.visible(user)
|
|
if user.is_admin?
|
|
all
|
|
elsif user.is_anonymous?
|
|
completed
|
|
else
|
|
completed.or(where(uploader: user))
|
|
end
|
|
end
|
|
|
|
concerning :FileMethods do
|
|
def media_file
|
|
@media_file ||= MediaFile.open(file, frame_data: context.to_h.dig("ugoira", "frame_data"))
|
|
end
|
|
|
|
def delete_files
|
|
# md5 is blank if the upload errored out before downloading the file.
|
|
if is_completed? || md5.blank? || Upload.exists?(md5: md5) || Post.exists?(md5: md5)
|
|
return
|
|
end
|
|
|
|
media_asset.destroy!
|
|
DanbooruLogger.info("Uploads: Deleting files for upload md5=#{md5}")
|
|
Danbooru.config.storage_manager.delete_file(nil, md5, file_ext, :original)
|
|
Danbooru.config.storage_manager.delete_file(nil, md5, file_ext, :large)
|
|
Danbooru.config.storage_manager.delete_file(nil, md5, file_ext, :preview)
|
|
Danbooru.config.backup_storage_manager.delete_file(nil, md5, file_ext, :original)
|
|
Danbooru.config.backup_storage_manager.delete_file(nil, md5, file_ext, :large)
|
|
Danbooru.config.backup_storage_manager.delete_file(nil, md5, file_ext, :preview)
|
|
end
|
|
end
|
|
|
|
concerning :StatusMethods do
|
|
def is_pending?
|
|
status == "pending"
|
|
end
|
|
|
|
def is_processing?
|
|
status == "processing"
|
|
end
|
|
|
|
def is_completed?
|
|
status == "completed"
|
|
end
|
|
|
|
def is_preprocessed?
|
|
status == "preprocessed"
|
|
end
|
|
|
|
def is_preprocessing?
|
|
status == "preprocessing"
|
|
end
|
|
|
|
def is_duplicate?
|
|
status.match?(/duplicate: \d+/)
|
|
end
|
|
|
|
def is_errored?
|
|
status.match?(/error:/)
|
|
end
|
|
|
|
def sanitized_status
|
|
if is_errored?
|
|
status.sub(/DETAIL:.+/m, "...")
|
|
else
|
|
status
|
|
end
|
|
end
|
|
|
|
def duplicate_post_id
|
|
@duplicate_post_id ||= status[/duplicate: (\d+)/, 1]
|
|
end
|
|
end
|
|
|
|
concerning :SourceMethods do
|
|
def source=(source)
|
|
source = source.unicode_normalize(:nfc)
|
|
|
|
# percent encode unicode characters in urls
|
|
if source =~ %r{\Ahttps?://}i
|
|
source = Addressable::URI.normalized_encode(source) rescue source
|
|
end
|
|
|
|
super(source)
|
|
end
|
|
|
|
def source_url
|
|
return nil unless source =~ %r{\Ahttps?://}i
|
|
Addressable::URI.heuristic_parse(source) rescue nil
|
|
end
|
|
end
|
|
|
|
def self.search(params)
|
|
q = search_attributes(params, :id, :created_at, :updated_at, :source, :rating, :parent_id, :server, :md5, :server, :file_ext, :file_size, :image_width, :image_height, :referer_url, :uploader, :post)
|
|
|
|
if params[:source_matches].present?
|
|
q = q.where_like(:source, params[:source_matches])
|
|
end
|
|
|
|
if params[:has_post].to_s.truthy?
|
|
q = q.where.not(post_id: nil)
|
|
elsif params[:has_post].to_s.falsy?
|
|
q = q.where(post_id: nil)
|
|
end
|
|
|
|
if params[:status].present?
|
|
q = q.where_like(:status, params[:status])
|
|
end
|
|
|
|
if params[:backtrace].present?
|
|
q = q.where_like(:backtrace, params[:backtrace])
|
|
end
|
|
|
|
if params[:tag_string].present?
|
|
q = q.where_like(:tag_string, params[:tag_string])
|
|
end
|
|
|
|
q.apply_default_order(params)
|
|
end
|
|
|
|
def assign_rating_from_tags
|
|
if rating = PostQueryBuilder.new(tag_string).find_metatag(:rating)
|
|
self.rating = rating.downcase.first
|
|
end
|
|
end
|
|
|
|
def upload_as_pending?
|
|
as_pending.to_s.truthy?
|
|
end
|
|
|
|
def has_commentary?
|
|
artist_commentary_title.present? || artist_commentary_desc.present? || translated_commentary_title.present? || translated_commentary_desc.present?
|
|
end
|
|
|
|
def self.available_includes
|
|
[:uploader, :post]
|
|
end
|
|
end
|