diff --git a/.circleci/install.sh b/.circleci/install.sh index 4eccd04e4..047315c7f 100755 --- a/.circleci/install.sh +++ b/.circleci/install.sh @@ -10,7 +10,7 @@ DB_CONFIG="test:\n adapter: postgresql\n database: zammad_test\n host: 127.0. # install build dependencies sudo apt-get update -sudo apt-get install -y --no-install-recommends autoconf automake autotools-dev bison build-essential curl git-core libffi-dev libgdbm-dev libgmp-dev libmariadbclient-dev-compat libncurses5-dev libreadline-dev libsqlite3-dev libssl-dev libtool libxml2-dev libxslt1-dev libyaml-0-2 libyaml-dev patch pkg-config postfix sqlite3 zlib1g-dev +sudo apt-get install -y --no-install-recommends autoconf automake autotools-dev bison build-essential curl git-core libffi-dev libgdbm-dev libgmp-dev libmariadbclient-dev-compat libncurses5-dev libreadline-dev libsqlite3-dev libssl-dev libtool libxml2-dev libxslt1-dev libyaml-0-2 libyaml-dev patch pkg-config postfix sqlite3 zlib1g-dev libimlib2 libimlib2-dev if [ "${CIRCLE_JOB}" == "install-mysql" ]; then DB_ADAPTER="mysql2" diff --git a/.pkgr.yml b/.pkgr.yml index 6e32b62eb..b5b36456d 100644 --- a/.pkgr.yml +++ b/.pkgr.yml @@ -10,36 +10,69 @@ targets: - nginx - postgresql-server - which - debian-8: - dependencies: - - curl - - elasticsearch - - nginx|apache2 - - postgresql|mysql-server|mariadb-server|sqlite + - epel-release + - imlib2 + - imlib2-devel + build_dependencies: + - http://download.fedoraproject.org/pub/epel/7/x86_64/Packages/i/imlib2-1.4.5-9.el7.x86_64.rpm + - http://download.fedoraproject.org/pub/epel/7/x86_64/Packages/i/imlib2-devel-1.4.5-9.el7.x86_64.rpm debian-9: dependencies: - curl - elasticsearch - nginx|apache2 - postgresql|mariadb-server|sqlite + - libimlib2 + - libimlib2-dev + build_dependencies: + - libimlib2 + - libimlib2-dev ubuntu-16.04: dependencies: - curl - elasticsearch - nginx|apache2 - postgresql|mysql-server|mariadb-server|sqlite + - libimlib2 + - libimlib2-dev + build_dependencies: + - libimlib2 + - libimlib2-dev ubuntu-18.04: dependencies: - curl - elasticsearch - nginx|apache2 - postgresql|mysql-server|mariadb-server|sqlite + - libimlib2 + build_dependencies: + - libimlib2-dev sles-12: dependencies: - curl - elasticsearch - nginx - postgresql-server + - imlib2 + - imlib2-devel + build_dependencies: + - imlib2 + - imlib2-devel + sles-12: + dependencies: + - curl + - elasticsearch + - nginx + - postgresql-server + - imlib2 + - libImlib2-1 + - imlib2 + - imlib2-devel + build_dependencies: + - https://ftp.gwdg.de/pub/opensuse/discontinued/distribution/12.3/repo/oss/suse/x86_64/imlib2-1.4.5-12.1.1.x86_64.rpm + - https://ftp.gwdg.de/pub/opensuse/discontinued/distribution/12.3/repo/oss/suse/x86_64/imlib2-devel-1.4.5-12.1.1.x86_64.rpm + - https://ftp.gwdg.de/pub/opensuse/discontinued/distribution/12.3/repo/oss/suse/x86_64/imlib2-filters-1.4.5-12.1.1.x86_64.rpm + - https://ftp.gwdg.de/pub/opensuse/discontinued/distribution/12.3/repo/oss/suse/x86_64/libImlib2-1-1.4.5-12.1.1.x86_64.rpm before: - contrib/packager.io/before.sh after: diff --git a/Gemfile b/Gemfile index e52605d67..cca8287d4 100644 --- a/Gemfile +++ b/Gemfile @@ -116,6 +116,9 @@ gem 'autodiscover', git: 'https://github.com/zammad-deps/autodiscover' gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm' gem 'viewpoint' +# image processing +gem 'rszr' + # Gems used only for develop/test and not required # in production environments by default. group :development, :test do diff --git a/Gemfile.lock b/Gemfile.lock index 338636dc6..e358c64c6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -408,6 +408,7 @@ GEM rspec-mocks (~> 3.8.0) rspec-support (~> 3.8.0) rspec-support (3.8.0) + rszr (0.3.2) rubocop (0.64.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) @@ -584,6 +585,7 @@ DEPENDENCIES rb-fsevent rchardet (>= 1.8.0) rspec-rails + rszr rubocop rubyntlm! sassc-rails diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_image_view.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_image_view.coffee index df8661ed2..de542b3fc 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_image_view.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_image_view.coffee @@ -11,9 +11,33 @@ class App.TicketZoomArticleImageView extends App.ControllerModal 'click .js-cancel': 'cancel' 'click .js-close': 'cancel' + constructor: -> + super + @unbindAll() + $(document).bind('keydown.image_preview', 'right', (e) => + nextElement = @parentElement.closest('.attachment').next('.attachment.attachment--preview') + return if nextElement.length is 0 + @close() + nextElement.find('img').click() + ) + $(document).bind('keydown.image_preview', 'left', (e) => + prevElement = @parentElement.closest('.attachment').prev('.attachment.attachment--preview') + return if prevElement.length is 0 + @close() + prevElement.find('img').click() + ) + content: -> + @image = @image.replace(/view=preview/, 'view=inline') "
#{@image}
" onSubmit: => + @image = @image.replace(/(\?|)view=(preview|inline)/, '') url = "#{$(@image).attr('src')}?disposition=attachment" window.open(url, '_blank') + + onClose: => + @unbindAll() + + unbindAll: -> + $(document).unbind('keydown.image_preview') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee index 33f1b2406..f5da7281e 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee @@ -421,4 +421,4 @@ class ArticleViewItem extends App.ObserverController imageView: (e) -> e.preventDefault() e.stopPropagation() - new App.TicketZoomArticleImageView(image: $(e.target).get(0).outerHTML) + new App.TicketZoomArticleImageView(image: $(e.target).get(0).outerHTML, parentElement: $(e.currentTarget)) diff --git a/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco index 348805aa2..aaa00e171 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco @@ -66,7 +66,7 @@
<% if attachment.preferences && attachment.preferences['Content-Type'] && @ContentTypeIcon(attachment.preferences['Content-Type']): %> <% if @canPreview(attachment.preferences['Content-Type']): %> - + <% else: %> <%- @Icon( @ContentTypeIcon(attachment.preferences['Content-Type']) ) %> <% end %> diff --git a/app/controllers/ticket_articles_controller.rb b/app/controllers/ticket_articles_controller.rb index 681ffb17b..138a23fdd 100644 --- a/app/controllers/ticket_articles_controller.rb +++ b/app/controllers/ticket_articles_controller.rb @@ -255,8 +255,21 @@ class TicketArticlesController < ApplicationController disposition = sanitized_disposition + content = nil + if params[:view].present? && file.preferences[:resizable] == true + if file.preferences[:content_inline] == true && params[:view] == 'inline' + content = file.content_inline + elsif file.preferences[:content_preview] == true && params[:view] == 'preview' + content = file.content_preview + end + end + + if content.blank? + content = file.content + end + send_data( - file.content, + content, filename: file.filename, type: file.preferences['Content-Type'] || file.preferences['Mime-Type'] || 'application/octet-stream', disposition: disposition diff --git a/app/models/store.rb b/app/models/store.rb index 5a79c1966..4827b2c5a 100644 --- a/app/models/store.rb +++ b/app/models/store.rb @@ -34,7 +34,7 @@ returns =end def self.add(data) - data = data.stringify_keys + data.deep_stringify_keys! # lookup store_object.id store_object = Store::Object.create_if_not_exists(name: data['object']) @@ -50,9 +50,34 @@ returns data.delete('data') data.delete('object') + data['preferences'] ||= {} + ['Mime-Type', 'Content-Type', 'mime_type', 'content_type'].each do |key| + next if data['preferences'][key].blank? + next if !data['preferences'][key].match(%r{image/(jpeg|jpg|png)}i) + + data['preferences']['resizable'] = true + break + end + # store meta data store = Store.create!(data) + begin + if store.preferences[:resizable] == true + if store.content_preview(silence: true) + store.preferences[:content_preview] = true + end + if store.content_inline(silence: true) + store.preferences[:content_inline] = true + end + store.save! + end + rescue => e + logger.error e + store.preferences[:resizable] = false + store.save! + end + store end @@ -165,6 +190,52 @@ returns =begin +get content of file in preview size + + store = Store.find(store_id) + content_as_string = store.content_preview + +returns + + content_as_string + +=end + + def content_preview(options = {}) + file = Store::File.find_by(id: store_file_id) + if !file + raise "No such file #{store_file_id}!" + end + raise 'Unable to generate preview' if options[:silence] != true && preferences[:content_preview] != true + + image_resize(file.content, 200) + end + +=begin + +get content of file in inline size + + store = Store.find(store_id) + content_as_string = store.content_inline + +returns + + content_as_string + +=end + + def content_inline(options = {}) + file = Store::File.find_by(id: store_file_id) + if !file + raise "No such file #{store_file_id}!" + end + raise 'Unable to generate inline' if options[:silence] != true && preferences[:content_inline] != true + + image_resize(file.content, 1800) + end + +=begin + get content of file store = Store.find(store_id) @@ -204,4 +275,32 @@ returns file.provider end + + private + + def image_resize(content, width) + local_sha = Digest::SHA256.hexdigest(content) + + cache_key = "image-resize-#{local_sha}_#{width}" + all = nil + image = Cache.get(cache_key) + return image if image + + temp_file = ::Tempfile.new + temp_file.binmode + temp_file.write(content) + temp_file.close + image = Rszr::Image.load(temp_file.path) + return if image.width < width + + image.resize!(width, :auto) + temp_file_resize = ::Tempfile.new.path + image.save(temp_file_resize) + image_resized = ::File.binread(temp_file_resize) + + Cache.write(cache_key, image_resized, { expires_in: 6.months }) + + image_resized + end + end diff --git a/app/models/ticket/article.rb b/app/models/ticket/article.rb index e5c1c56f7..d4f23bf83 100644 --- a/app/models/ticket/article.rb +++ b/app/models/ticket/article.rb @@ -82,7 +82,7 @@ returns article['attachments'].each do |file| next if !file[:preferences] || !file[:preferences]['Content-ID'] || (file[:preferences]['Content-ID'] != cid && file[:preferences]['Content-ID'] != "<#{cid}>" ) - replace = "#{tag_start}/api/v1/ticket_attachment/#{article['ticket_id']}/#{article['id']}/#{file[:id]}\"#{tag_end}>" + replace = "#{tag_start}/api/v1/ticket_attachment/#{article['ticket_id']}/#{article['id']}/#{file[:id]}?view=inline\"#{tag_end}>" inline_attachments[file[:id]] = true break end diff --git a/db/migrate/20190131000001_setting_change_ticket_zoom_attachment_preview.rb b/db/migrate/20190131000001_setting_change_ticket_zoom_attachment_preview.rb new file mode 100644 index 000000000..296db7b3f --- /dev/null +++ b/db/migrate/20190131000001_setting_change_ticket_zoom_attachment_preview.rb @@ -0,0 +1,34 @@ +class SettingChangeTicketZoomAttachmentPreview < ActiveRecord::Migration[5.1] + def up + # return if it's a new setup + return if !Setting.find_by(name: 'system_init_done') + + Setting.create_or_update( + title: 'Sidebar Attachments', + name: 'ui_ticket_zoom_attachments_preview', + area: 'UI::TicketZoom::Preview', + description: 'Enables preview of attachments.', + options: { + form: [ + { + display: '', + null: true, + name: 'ui_ticket_zoom_attachments_preview', + tag: 'boolean', + translate: true, + options: { + true => 'yes', + false => 'no', + }, + }, + ], + }, + state: true, + preferences: { + prio: 400, + permission: ['admin.ui'], + }, + frontend: true + ) + end +end diff --git a/db/seeds/settings.rb b/db/seeds/settings.rb index 778538f0c..0d6ce04e1 100644 --- a/db/seeds/settings.rb +++ b/db/seeds/settings.rb @@ -793,7 +793,7 @@ Setting.create_if_not_exists( }, ], }, - state: false, + state: true, preferences: { prio: 400, permission: ['admin.ui'], diff --git a/public/assets/chat/chat-no-jquery.coffee b/public/assets/chat/chat-no-jquery.coffee index f43d1285b..7b8fe5cc7 100644 --- a/public/assets/chat/chat-no-jquery.coffee +++ b/public/assets/chat/chat-no-jquery.coffee @@ -501,7 +501,7 @@ do(window) -> stopPropagation: (event) -> event.stopPropagation() - onPaste: (e) => + onDrop: (e) => e.stopPropagation() e.preventDefault() diff --git a/test/data/image/1000x1000.png b/test/data/image/1000x1000.png new file mode 100644 index 000000000..02756786d Binary files /dev/null and b/test/data/image/1000x1000.png differ diff --git a/test/data/image/1x1.png b/test/data/image/1x1.png new file mode 100644 index 000000000..a3e716292 Binary files /dev/null and b/test/data/image/1x1.png differ diff --git a/test/unit/store_test.rb b/test/unit/store_test.rb index 492fd313b..92b8657fc 100644 --- a/test/unit/store_test.rb +++ b/test/unit/store_test.rb @@ -151,4 +151,124 @@ class StoreTest < ActiveSupport::TestCase assert_not(attachments[0]) end end + + test 'test resizable' do + + # not possible + store = Store.add( + object: 'SomeObject1', + o_id: rand(1_234_567_890), + data: File.binread(Rails.root.join('test', 'data', 'upload', 'upload1.txt')), + filename: 'test1.pdf', + preferences: { + content_type: 'text/plain', + content_id: 234, + }, + created_by_id: 1, + ) + assert_not(store.preferences.key?(:resizable)) + assert_not(store.preferences.key?(:content_inline)) + assert_not(store.preferences.key?(:content_preview)) + assert_raises(RuntimeError) do + store.content_inline + end + assert_raises(RuntimeError) do + store.content_preview + end + + # not possible + store = Store.add( + object: 'SomeObject2', + o_id: rand(1_234_567_890), + data: File.binread(Rails.root.join('test', 'data', 'upload', 'upload1.txt')), + filename: 'test1.pdf', + preferences: { + content_type: 'image/jpg', + content_id: 234, + }, + created_by_id: 1, + ) + assert_equal(store.preferences[:resizable], false) + assert_not(store.preferences.key?(:content_inline)) + assert_not(store.preferences.key?(:content_preview)) + assert_raises(RuntimeError) do + store.content_inline + end + assert_raises(RuntimeError) do + store.content_preview + end + + # possible (preview and inline) + store = Store.add( + object: 'SomeObject3', + o_id: rand(1_234_567_890), + data: File.binread(Rails.root.join('test', 'data', 'upload', 'upload2.jpg')), + filename: 'test1.pdf', + preferences: { + content_type: 'image/jpg', + content_id: 234, + }, + created_by_id: 1, + ) + assert_equal(store.preferences[:resizable], true) + assert_equal(store.preferences[:content_inline], true) + assert_equal(store.preferences[:content_preview], true) + + temp_file = ::Tempfile.new.path + File.binwrite(temp_file, store.content_inline) + image = Rszr::Image.load(temp_file) + assert_equal(image.width, 1800) + + temp_file = ::Tempfile.new.path + File.binwrite(temp_file, store.content_preview) + image = Rszr::Image.load(temp_file) + assert_equal(image.width, 200) + + # possible (preview only) + store = Store.add( + object: 'SomeObject4', + o_id: rand(1_234_567_890), + data: File.binread(Rails.root.join('test', 'data', 'image', '1000x1000.png')), + filename: 'test1.png', + preferences: { + content_type: 'image/png', + content_id: 234, + }, + created_by_id: 1, + ) + assert_equal(store.preferences[:resizable], true) + assert_nil(store.preferences[:content_inline]) + assert_equal(store.preferences[:content_preview], true) + assert_raises(RuntimeError) do + store.content_inline + end + + temp_file = ::Tempfile.new.path + File.binwrite(temp_file, store.content_preview) + image = Rszr::Image.load(temp_file) + assert_equal(image.width, 200) + + # possible (now preview or inline needed) + store = Store.add( + object: 'SomeObject5', + o_id: rand(1_234_567_890), + data: File.binread(Rails.root.join('test', 'data', 'image', '1x1.png')), + filename: 'test1.png', + preferences: { + content_type: 'image/png', + content_id: 234, + }, + created_by_id: 1, + ) + assert_equal(store.preferences[:resizable], true) + assert_nil(store.preferences[:content_inline]) + assert_nil(store.preferences[:content_preview]) + assert_raises(RuntimeError) do + store.content_inline + end + assert_raises(RuntimeError) do + store.content_preview + end + + end end