# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
require "digest"
require "fileutils"
require "zip"
class Attachment < ApplicationRecord
include Redmine::SafeAttributes
belongs_to :container, :polymorphic => true
belongs_to :author, :class_name => "User"
validates_presence_of :filename, :author
validates_length_of :filename, :maximum => 255
validates_length_of :disk_filename, :maximum => 255
validates_length_of :description, :maximum => 255
validate :validate_max_file_size
validate :validate_file_extension, :if => :filename_changed?
acts_as_event(
:title => :filename,
:url =>
Proc.new do |o|
{:controller => 'attachments', :action => 'show',
:id => o.id, :filename => o.filename}
end
)
acts_as_activity_provider(
:type => 'files',
:permission => :view_files,
:author_key => :author_id,
:scope =>
proc do
select("#{Attachment.table_name}.*").
where(container_type: ['Version', 'Project']).
joins(
"LEFT JOIN #{Version.table_name} " \
"ON #{Attachment.table_name}.container_type='Version' " \
"AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " \
"LEFT JOIN #{Project.table_name} " \
"ON #{Version.table_name}.project_id = #{Project.table_name}.id " \
"OR ( #{Attachment.table_name}.container_type='Project' " \
"AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"
)
end
)
acts_as_activity_provider(
:type => 'documents',
:permission => :view_documents,
:author_key => :author_id,
:scope =>
proc do
select("#{Attachment.table_name}.*").
joins(
"LEFT JOIN #{Document.table_name} " \
"ON #{Attachment.table_name}.container_type='Document' " \
"AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " \
"LEFT JOIN #{Project.table_name} " \
"ON #{Document.table_name}.project_id = #{Project.table_name}.id"
)
end
)
cattr_accessor :storage_path
@@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
cattr_accessor :thumbnails_storage_path
@@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
before_create :files_to_final_location
after_commit :delete_from_disk, :on => :destroy
after_commit :reuse_existing_file_if_possible, :on => :create
after_rollback :delete_from_disk, :on => :create
safe_attributes 'filename', 'content_type', 'description'
# Returns an unsaved copy of the attachment
def copy(attributes=nil)
copy = self.class.new
copy.attributes = self.attributes.dup.except("id", "downloads")
copy.attributes = attributes if attributes
copy
end
def validate_max_file_size
if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
end
end
def validate_file_extension
extension = File.extname(filename)
unless self.class.valid_extension?(extension)
errors.add(:base, l(:error_attachment_extension_not_allowed, :extension => extension))
end
end
def file=(incoming_file)
unless incoming_file.nil?
@temp_file = incoming_file
if @temp_file.respond_to?(:original_filename)
self.filename = @temp_file.original_filename
self.filename.force_encoding("UTF-8")
end
if @temp_file.respond_to?(:content_type)
self.content_type = @temp_file.content_type.to_s.chomp
end
self.filesize = @temp_file.size
end
end
def file
nil
end
def filename=(arg)
write_attribute :filename, sanitize_filename(arg.to_s)
filename
end
# Copies the temporary file to its final location
# and computes its hash
def files_to_final_location
if @temp_file
self.disk_directory = target_directory
sha = Digest::SHA256.new
Attachment.create_diskfile(filename, disk_directory) do |f|
self.disk_filename = File.basename f.path
logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)") if logger
if @temp_file.respond_to?(:read)
buffer = ""
while (buffer = @temp_file.read(8192))
f.write(buffer)
sha.update(buffer)
end
else
f.write(@temp_file)
sha.update(@temp_file)
end
end
self.digest = sha.hexdigest
end
@temp_file = nil
if content_type.blank? && filename.present?
self.content_type = Redmine::MimeType.of(filename)
end
# Don't save the content type if it's longer than the authorized length
if self.content_type && self.content_type.length > 255
self.content_type = nil
end
end
# Deletes the file from the file system if it's not referenced by other attachments
def delete_from_disk
if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
delete_from_disk!
end
end
# Returns file's location on disk
def diskfile
File.join(self.class.storage_path, disk_directory.to_s, disk_filename.to_s)
end
def title
title = filename.dup
if description.present?
title << " (#{description})"
end
title
end
def increment_download
increment!(:downloads)
end
def project
container.try(:project)
end
def visible?(user=User.current)
if container_id
container && container.attachments_visible?(user)
else
author == user
end
end
def editable?(user=User.current)
if container_id
container && container.attachments_editable?(user)
else
author == user
end
end
def deletable?(user=User.current)
if container_id
container && container.attachments_deletable?(user)
else
author == user
end
end
def image?
!!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png|webp)$/i)
end
def thumbnailable?
Redmine::Thumbnail.convert_available? && (
image? || (is_pdf? && Redmine::Thumbnail.gs_available?)
)
end
# Returns the full path the attachment thumbnail, or nil
# if the thumbnail cannot be generated.
def thumbnail(options={})
if thumbnailable? && readable?
size = options[:size].to_i
if size > 0
# Limit the number of thumbnails per image
size = (size / 50.0).ceil * 50
# Maximum thumbnail size
size = 800 if size > 800
else
size = Setting.thumbnails_size.to_i
end
size = 100 unless size > 0
target = thumbnail_path(size)
begin
Redmine::Thumbnail.generate(self.diskfile, target, size, is_pdf?)
rescue => e
if logger
logger.error(
"An error occured while generating thumbnail for #{disk_filename} " \
"to #{target}\nException was: #{e.message}"
)
end
nil
end
end
end
# Deletes all thumbnails
def self.clear_thumbnails
Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
File.delete file
end
end
def is_text?
Redmine::MimeType.is_type?('text', filename) || Redmine::SyntaxHighlighting.filename_supported?(filename)
end
def is_markdown?
Redmine::MimeType.of(filename) == 'text/markdown'
end
def is_textile?
Redmine::MimeType.of(filename) == 'text/x-textile'
end
def is_image?
Redmine::MimeType.is_type?('image', filename)
end
def is_diff?
/\.(patch|diff)$/i.match?(filename)
end
def is_pdf?
Redmine::MimeType.of(filename) == "application/pdf"
end
def is_video?
Redmine::MimeType.is_type?('video', filename)
end
def is_audio?
Redmine::MimeType.is_type?('audio', filename)
end
def previewable?
is_text? || is_image? || is_video? || is_audio?
end
# Returns true if the file is readable
def readable?
disk_filename.present? && File.readable?(diskfile)
end
# Returns the attachment token
def token
"#{id}.#{digest}"
end
# Finds an attachment that matches the given token and that has no container
def self.find_by_token(token)
if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
attachment_id, attachment_digest = $1, $2
attachment = Attachment.find_by(:id => attachment_id, :digest => attachment_digest)
if attachment && attachment.container.nil?
attachment
end
end
end
# Bulk attaches a set of files to an object
#
# Returns a Hash of the results:
# :files => array of the attached files
# :unsaved => array of the files that could not be attached
def self.attach_files(obj, attachments)
result = obj.save_attachments(attachments, User.current)
obj.attach_saved_attachments
result
end
# Updates the filename and description of a set of attachments
# with the given hash of attributes. Returns true if all
# attachments were updated.
#
# Example:
# Attachment.update_attachments(attachments, {
# 4 => {:filename => 'foo'},
# 7 => {:filename => 'bar', :description => 'file description'}
# })
#
def self.update_attachments(attachments, params)
converted = {}
params.each {|key, val| converted[key.to_i] = val}
saved = true
transaction do
attachments.each do |attachment|
if file = converted[attachment.id]
attachment.filename = file[:filename] if file.key?(:filename)
attachment.description = file[:description] if file.key?(:description)
saved &&= attachment.save
end
end
unless saved
raise ActiveRecord::Rollback
end
end
saved
end
def self.latest_attach(attachments, filename)
return unless filename.valid_encoding?
attachments.sort_by{|attachment| [attachment.created_on, attachment.id]}.reverse.detect do |att|
filename.casecmp?(att.filename)
end
end
def self.prune(age=1.day)
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
end
def self.archive_attachments(attachments)
attachments = attachments.select(&:readable?)
return nil if attachments.blank?
Zip.unicode_names = true
archived_file_names = []
buffer = Zip::OutputStream.write_buffer do |zos|
attachments.each do |attachment|
filename = attachment.filename
# rename the file if a file with the same name already exists
dup_count = 0
while archived_file_names.include?(filename)
dup_count += 1
extname = File.extname(attachment.filename)
basename = File.basename(attachment.filename, extname)
filename = "#{basename}(#{dup_count})#{extname}"
end
zos.put_next_entry(filename)
zos << IO.binread(attachment.diskfile)
archived_file_names << filename
end
end
buffer.string
ensure
buffer&.close
end
# Moves an existing attachment to its target directory
def move_to_target_directory!
return unless !new_record? & readable?
src = diskfile
self.disk_directory = target_directory
dest = diskfile
return if src == dest
unless FileUtils.mkdir_p(File.dirname(dest))
logger.error "Could not create directory #{File.dirname(dest)}" if logger
return
end
unless FileUtils.mv(src, dest)
logger.error "Could not move attachment from #{src} to #{dest}" if logger
return
end
update_column :disk_directory, disk_directory
end
# Moves existing attachments that are stored at the root of the files
# directory (ie. created before Redmine 2.3) to their target subdirectories
def self.move_from_root_to_target_directory
Attachment.where("disk_directory IS NULL OR disk_directory = ''").find_each do |attachment|
attachment.move_to_target_directory!
end
end
# Updates digests to SHA256 for all attachments that have a MD5 digest
# (ie. created before Redmine 3.4)
def self.update_digests_to_sha256
Attachment.where("length(digest) < 64").find_each do |attachment|
attachment.update_digest_to_sha256!
end
end
# Updates attachment digest to SHA256
def update_digest_to_sha256!
if readable?
sha = Digest::SHA256.new
File.open(diskfile, 'rb') do |f|
while buffer = f.read(8192)
sha.update(buffer)
end
end
update_column :digest, sha.hexdigest
end
end
# Returns true if the extension is allowed regarding allowed/denied
# extensions defined in application settings, otherwise false
def self.valid_extension?(extension)
denied, allowed = [:attachment_extensions_denied, :attachment_extensions_allowed].map do |setting|
Setting.send(setting)
end
if denied.present? && extension_in?(extension, denied)
return false
end
if allowed.present? && !extension_in?(extension, allowed)
return false
end
true
end
# Returns true if extension belongs to extensions list.
def self.extension_in?(extension, extensions)
extension = extension.downcase.sub(/\A\.+/, '')
unless extensions.is_a?(Array)
extensions = extensions.to_s.split(",").map(&:strip)
end
extensions = extensions.map {|s| s.downcase.sub(/\A\.+/, '')}.reject(&:blank?)
extensions.include?(extension)
end
# Returns true if attachment's extension belongs to extensions list.
def extension_in?(extensions)
self.class.extension_in?(File.extname(filename), extensions)
end
# returns either MD5 or SHA256 depending on the way self.digest was computed
def digest_type
digest.size < 64 ? "MD5" : "SHA256" if digest.present?
end
private
def reuse_existing_file_if_possible
original_diskfile = diskfile
original_filename = disk_filename
reused = with_lock do
if existing = Attachment
.where(digest: self.digest, filesize: self.filesize)
.where.not(disk_filename: original_filename)
.order(:id)
.last
existing.with_lock do
existing_diskfile = existing.diskfile
if File.readable?(original_diskfile) &&
File.readable?(existing_diskfile) &&
FileUtils.identical?(original_diskfile, existing_diskfile)
self.update_columns disk_directory: existing.disk_directory,
disk_filename: existing.disk_filename
end
end
end
end
if reused && Attachment.where(disk_filename: original_filename).none?
File.delete(original_diskfile)
end
rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound
# Catch and ignore lock errors. It is not critical if deduplication does
# not happen, therefore we do not retry.
# with_lock throws ActiveRecord::RecordNotFound if the record isnt there
# anymore, thats why this is caught and ignored as well.
end
# Physically deletes the file from the file system
def delete_from_disk!
FileUtils.rm_f(diskfile) if disk_filename.present?
Dir[thumbnail_path("*")].each do |thumb|
File.delete(thumb)
end
end
def thumbnail_path(size)
File.join(self.class.thumbnails_storage_path,
"#{digest}_#{filesize}_#{size}.thumb")
end
def sanitize_filename(value)
# get only the filename, not the whole path
just_filename = value.gsub(/\A.*(\\|\/)/m, '')
# Finally, replace invalid characters with underscore
just_filename.gsub(/[\/\?\%\*\:\|\"\'<>\n\r]+/, '_')
end
# Returns the subdirectory in which the attachment will be saved
def target_directory
time = created_on || DateTime.now
time.strftime("%Y/%m")
end
# Singleton class method is public
class << self
# Claims a unique ASCII or hashed filename, yields the open file handle
def create_diskfile(filename, directory=nil, &)
timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
ascii = ''
if %r{^[a-zA-Z0-9_\.\-]*$}.match?(filename) && filename.length <= 50
ascii = filename
else
ascii = ActiveSupport::Digest.hexdigest(filename)
# keep the extension if any
ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
end
path = File.join storage_path, directory.to_s
FileUtils.mkdir_p(path) unless File.directory?(path)
begin
name = "#{timestamp}_#{ascii}"
File.open(
File.join(path, name),
flags: File::CREAT | File::EXCL | File::WRONLY,
binmode: true,
&
)
rescue Errno::EEXIST
timestamp.succ!
retry
end
end
end
end
an>
Mode: Centered,
})
The default crop use the specified dimension, but it is possible
to use Width and Heigth as a ratio instead. In this case,
the resulting image will be as big as possible to fit the asked ratio
from the anchor position.
croppedImg, err := cutter.Crop(baseImage, cutter.Config{
Width: 4,
Height: 3,
Mode: Centered,
Options: Ratio,
})
*/
package cutter
import (
"image"
"image/draw"
)
// Config is used to defined
// the way the crop should be realized.
type Config struct {
Width, Height int
Anchor image.Point // The Anchor Point in the source image
Mode AnchorMode // Which point in the resulting image the Anchor Point is referring to
Options Option
}
// AnchorMode is an enumeration of the position an anchor can represent.
type AnchorMode int
const (
// TopLeft defines the Anchor Point
// as the top left of the cropped picture.
TopLeft AnchorMode = iota
// Centered defines the Anchor Point
// as the center of the cropped picture.
Centered = iota
)
// Option flags to modify the way the crop is done.
type Option int
const (
// Ratio flag is use when Width and Height
// must be used to compute a ratio rather
// than absolute size in pixels.
Ratio Option = 1 << iota
// Copy flag is used to enforce the function
// to retrieve a copy of the selected pixels.
// This disable the use of SubImage method
// to compute the result.
Copy = 1 << iota
)
// An interface that is
// image.Image + SubImage method.
type subImageSupported interface {
SubImage(r image.Rectangle) image.Image
}
// Crop retrieves an image that is a
// cropped copy of the original img.
//
// The crop is made given the informations provided in config.
func Crop(img image.Image, c Config) (image.Image, error) {
maxBounds := c.maxBounds(img.Bounds())
size := c.computeSize(maxBounds, image.Point{c.Width, c.Height})
cr := c.computedCropArea(img.Bounds(), size)
cr = img.Bounds().Intersect(cr)
if c.Options&Copy == Copy {
return cropWithCopy(img, cr)
}
if dImg, ok := img.(subImageSupported); ok {
return dImg.SubImage(cr), nil
}
return cropWithCopy(img, cr)
}
func cropWithCopy(img image.Image, cr image.Rectangle) (image.Image, error) {
result := image.NewRGBA(cr)
draw.Draw(result, cr, img, cr.Min, draw.Src)
return result, nil
}
func (c Config) maxBounds(bounds image.Rectangle) (r image.Rectangle) {
if c.Mode == Centered {
anchor := c.centeredMin(bounds)
w := min(anchor.X-bounds.Min.X, bounds.Max.X-anchor.X)
h := min(anchor.Y-bounds.Min.Y, bounds.Max.Y-anchor.Y)
r = image.Rect(anchor.X-w, anchor.Y-h, anchor.X+w, anchor.Y+h)
} else {
r = image.Rect(c.Anchor.X, c.Anchor.Y, bounds.Max.X, bounds.Max.Y)
}
return
}
// computeSize retrieve the effective size of the cropped image.
// It is defined by Height, Width, and Ratio option.
func (c Config) computeSize(bounds image.Rectangle, ratio image.Point) (p image.Point) {
if c.Options&Ratio == Ratio {
// Ratio option is on, so we take the biggest size available that fit the given ratio.
if float64(ratio.X)/float64(bounds.Dx()) > float64(ratio.Y)/float64(bounds.Dy()) {
p = image.Point{bounds.Dx(), (bounds.Dx() / ratio.X) * ratio.Y}
} else {
p = image.Point{(bounds.Dy() / ratio.Y) * ratio.X, bounds.Dy()}
}
} else {
p = image.Point{ratio.X, ratio.Y}
}
return
}
// computedCropArea retrieve the theorical crop area.
// It is defined by Height, Width, Mode and
func (c Config) computedCropArea(bounds image.Rectangle, size image.Point) (r image.Rectangle) {
min := bounds.Min
switch c.Mode {
case Centered:
rMin := c.centeredMin(bounds)
r = image.Rect(rMin.X-size.X/2, rMin.Y-size.Y/2, rMin.X-size.X/2+size.X, rMin.Y-size.Y/2+size.Y)
default: // TopLeft
rMin := image.Point{min.X + c.Anchor.X, min.Y + c.Anchor.Y}
r = image.Rect(rMin.X, rMin.Y, rMin.X+size.X, rMin.Y+size.Y)
}
return
}
func (c *Config) centeredMin(bounds image.Rectangle) (rMin image.Point) {
if c.Anchor.X == 0 && c.Anchor.Y == 0 {
rMin = image.Point{
X: bounds.Dx() / 2,
Y: bounds.Dy() / 2,
}
} else {
rMin = image.Point{
X: c.Anchor.X,
Y: c.Anchor.Y,
}
}
return
}
func min(a, b int) (r int) {
if a < b {
r = a
} else {
r = b
}
return
}