From c7f8045569aa39669ec6bf69946494f6c6e2d1b1 Mon Sep 17 00:00:00 2001 From: Thorsten Eckel Date: Tue, 26 Mar 2019 08:37:15 +0100 Subject: [PATCH] Refactoring: Created generic UploadCache class, controller and routes to handle attachment upload. --- .../controllers/_ui_element/richtext.coffee | 96 ++++++++------- .../controllers/_ui_element/textarea.coffee | 5 +- .../app/controllers/ticket_zoom.coffee | 3 +- .../ticket_zoom/article_new.coffee | 9 +- .../concerns/creates_ticket_articles.rb | 12 +- app/controllers/ticket_articles_controller.rb | 60 --------- app/controllers/upload_caches_controller.rb | 58 +++++++++ app/models/taskbar.rb | 2 +- config/routes/ticket.rb | 2 - config/routes/upload_cache.rb | 9 ++ lib/upload_cache.rb | 98 +++++++++++++++ spec/fixtures/upload/hello_world.txt | 1 + spec/lib/upload_cache_spec.rb | 114 ++++++++++++++++++ spec/requests/upload_cache_spec.rb | 82 +++++++++++++ 14 files changed, 419 insertions(+), 132 deletions(-) create mode 100644 app/controllers/upload_caches_controller.rb create mode 100644 config/routes/upload_cache.rb create mode 100644 lib/upload_cache.rb create mode 100644 spec/fixtures/upload/hello_world.txt create mode 100644 spec/lib/upload_cache_spec.rb create mode 100644 spec/requests/upload_cache_spec.rb diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee index b43483c31..45ed32c8a 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee @@ -38,8 +38,7 @@ class App.UiElement.richtext # delete attachment from storage App.Ajax.request( type: 'DELETE' - url: "#{App.Config.get('api_path')}/ticket_attachment_upload" - data: JSON.stringify(id: id), + url: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}/items/#{id}" processData: false ) @@ -57,59 +56,58 @@ class App.UiElement.richtext @attachmentsHolder = item.find('.attachments') @cancelContainer = item.find('.js-cancel') - u = => html5Upload.initialize( - uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload' - dropContainer: item.closest('form').get(0) - cancelContainer: @cancelContainer - inputField: item.find('input').get(0) - maxSimultaneousUploads: 1, - key: 'File' - data: - form_id: item.closest('form').find('[name=form_id]').val() - onFileAdded: (file) => + upload_initialize_callback = => + form_id = item.closest('form').find('[name=form_id]').val() + html5Upload.initialize( + uploadUrl: "#{App.Config.get('api_path')}/upload_caches/#{form_id}" + dropContainer: item.closest('form').get(0) + cancelContainer: @cancelContainer + inputField: item.find('input').get(0) + maxSimultaneousUploads: 1, + key: 'File' + onFileAdded: (file) => - file.on( - onStart: => - @attachmentPlaceholder.addClass('hide') - @attachmentUpload.removeClass('hide') - @cancelContainer.removeClass('hide') - item.find('[contenteditable]').trigger('fileUploadStart') - App.Log.debug 'UiElement.richtext', 'upload start' + file.on( + onStart: => + @attachmentPlaceholder.addClass('hide') + @attachmentUpload.removeClass('hide') + @cancelContainer.removeClass('hide') + item.find('[contenteditable]').trigger('fileUploadStart') + App.Log.debug 'UiElement.richtext', 'upload start' - onAborted: => - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') - item.find('input').val('') - item.find('[contenteditable]').trigger('fileUploadStop', ['aborted']) + onAborted: => + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + item.find('input').val('') + item.find('[contenteditable]').trigger('fileUploadStop', ['aborted']) - # Called after received response from the server - onCompleted: (response) => - response = JSON.parse(response) + # Called after received response from the server + onCompleted: (response) => + response = JSON.parse(response) - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') - # reset progress bar - @progressBar.width(parseInt(0) + '%') - @progressText.text('') + # reset progress bar + @progressBar.width(parseInt(0) + '%') + @progressText.text('') - renderFile(response.data) - item.find('input').val('') - item.find('[contenteditable]').trigger('fileUploadStop', ['completed']) - App.Log.debug 'UiElement.richtext', 'upload complete', response.data + renderFile(response.data) + item.find('input').val('') + item.find('[contenteditable]').trigger('fileUploadStop', ['completed']) + App.Log.debug 'UiElement.richtext', 'upload complete', response.data - # Called during upload progress, first parameter - # is decimal value from 0 to 100. - onProgress: (progress, fileSize, uploadedBytes) => - @progressBar.width(parseInt(progress) + '%') - @progressText.text(parseInt(progress)) - # hide cancel on 90% - if parseInt(progress) >= 90 - @cancelContainer.addClass('hide') - App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress) - - ) - ) - App.Delay.set(u, 100, undefined, 'form_upload') + # Called during upload progress, first parameter + # is decimal value from 0 to 100. + onProgress: (progress, fileSize, uploadedBytes) => + @progressBar.width(parseInt(progress) + '%') + @progressText.text(parseInt(progress)) + # hide cancel on 90% + if parseInt(progress) >= 90 + @cancelContainer.addClass('hide') + App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress) + ) + ) + App.Delay.set(upload_initialize_callback, 100, undefined, 'form_upload') item diff --git a/app/assets/javascripts/app/controllers/_ui_element/textarea.coffee b/app/assets/javascripts/app/controllers/_ui_element/textarea.coffee index 494936c73..3987bd43f 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/textarea.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/textarea.coffee @@ -22,11 +22,10 @@ class App.UiElement.textarea # only add upload item if html element exists if $('#' + fileUploaderId )[0] + form_id = item.closest('form').find('[name=form_id]').val() $('#' + fileUploaderId ).fineUploader( request: - endpoint: App.Config.get('api_path') + '/ticket_attachment_upload' - params: - form_id: item.closest('form').find('[name=form_id]').val() + endpoint: "#{App.Config.get('api_path')}/upload_caches/#{form_id}" text: uploadButton: App.Utils.icon('paperclip') template: '
' + diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index 89f174b58..c5a9abd88 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -958,8 +958,7 @@ class App.TicketZoom extends App.Controller # reset/delete uploaded attachments App.Ajax.request( type: 'DELETE' - url: App.Config.get('api_path') + '/ticket_attachment_upload' - data: JSON.stringify(form_id: @form_id) + url: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}" processData: false ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee index 50805f372..d4e66b40b 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -172,13 +172,11 @@ class App.TicketZoomArticleNew extends App.Controller }) html5Upload.initialize( - uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload' + uploadUrl: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}" dropContainer: @$('.article-add').get(0) cancelContainer: @cancelContainer inputField: @$('.article-attachment input').get(0) key: 'File' - data: - form_id: @form_id maxSimultaneousUploads: 1 onFileAdded: (file) => @@ -599,9 +597,8 @@ class App.TicketZoomArticleNew extends App.Controller # delete attachment from storage App.Ajax.request( - type: 'DELETE' - url: App.Config.get('api_path') + '/ticket_attachment_upload' - data: JSON.stringify(id: id) + type: 'DELETE' + url: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}/items/#{id}" processData: false ) diff --git a/app/controllers/concerns/creates_ticket_articles.rb b/app/controllers/concerns/creates_ticket_articles.rb index 58fcfd304..1c8987aac 100644 --- a/app/controllers/concerns/creates_ticket_articles.rb +++ b/app/controllers/concerns/creates_ticket_articles.rb @@ -55,10 +55,7 @@ module CreatesTicketArticles # find attachments in upload cache if form_id - article.attachments = Store.list( - object: 'UploadCache', - o_id: form_id, - ) + article.attachments = UploadCache.new(form_id).attachments end # set subtype of present @@ -130,11 +127,8 @@ module CreatesTicketArticles .first { |taskbar| taskbar.persisted_form_id == form_id } &.update!(state: {}) - # remove attachments from upload cache - Store.remove( - object: 'UploadCache', - o_id: form_id, - ) + # remove temporary attachment cache + UploadCache.new(form_id).destroy article end diff --git a/app/controllers/ticket_articles_controller.rb b/app/controllers/ticket_articles_controller.rb index 138a23fdd..afb502f54 100644 --- a/app/controllers/ticket_articles_controller.rb +++ b/app/controllers/ticket_articles_controller.rb @@ -154,66 +154,6 @@ class TicketArticlesController < ApplicationController raise Exceptions::NotAuthorized, 'Not authorized (admin permission required)!' end - # DELETE /ticket_attachment_upload - def ticket_attachment_upload_delete - - if params[:id].present? - Store.remove_item(params[:id]) - render json: { - success: true, - } - return - end - - if params[:form_id].present? - Store.remove( - object: 'UploadCache', - o_id: params[:form_id], - ) - render json: { - success: true, - } - return - end - - render json: { message: 'No such id or form_id!' }, status: :unprocessable_entity - end - - # POST /ticket_attachment_upload - def ticket_attachment_upload_add - - # store file - file = params[:File] - content_type = file.content_type - if !content_type || content_type == 'application/octet-stream' - content_type = if MIME::Types.type_for(file.original_filename).first - MIME::Types.type_for(file.original_filename).first.content_type - else - 'application/octet-stream' - end - end - headers_store = { - 'Content-Type' => content_type - } - store = Store.add( - object: 'UploadCache', - o_id: params[:form_id], - data: file.read, - filename: file.original_filename, - preferences: headers_store - ) - - # return result - render json: { - success: true, - data: { - id: store.id, - filename: file.original_filename, - size: store.size, - } - } - end - # POST /ticket_attachment_upload_clone_by_article def ticket_attachment_upload_clone_by_article article = Ticket::Article.find(params[:article_id]) diff --git a/app/controllers/upload_caches_controller.rb b/app/controllers/upload_caches_controller.rb new file mode 100644 index 000000000..088d592d2 --- /dev/null +++ b/app/controllers/upload_caches_controller.rb @@ -0,0 +1,58 @@ +# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ + +class UploadCachesController < ApplicationController + prepend_before_action :authentication_check + + # POST /upload_caches/1 + def update + file = params[:File] + content_type = file.content_type + if !content_type || content_type == 'application/octet-stream' + mime_type = MIME::Types.type_for(file.original_filename).first + content_type = mime_type&.content_type || 'application/octet-stream' + end + headers_store = { + 'Content-Type' => content_type + } + + store = cache.add( + filename: file.original_filename, + data: file.read, + preferences: headers_store + ) + + # return result + render json: { + success: true, + data: { + id: store.id, # TODO: rename? + filename: file.original_filename, + size: store.size, + } + } + end + + # DELETE /upload_caches/1 + def destroy + cache.destroy + + render json: { + success: true, + } + end + + # DELETE /upload_caches/1/items/1 + def remove_item + cache.remove_item(params[:store_id]) + + render json: { + success: true, + } + end + + private + + def cache + UploadCache.new(params[:id]) + end +end diff --git a/app/models/taskbar.rb b/app/models/taskbar.rb index 992093d55..2207a67e7 100644 --- a/app/models/taskbar.rb +++ b/app/models/taskbar.rb @@ -54,7 +54,7 @@ class Taskbar < ApplicationModel def attachments return [] if persisted_form_id.blank? - Store.list(object: 'UploadCache', o_id: persisted_form_id) + UploadCache.new(persisted_form_id).attachments end def add_attachments_to_attributes(attributes) diff --git a/config/routes/ticket.rb b/config/routes/ticket.rb index fef82d720..cebc84c52 100644 --- a/config/routes/ticket.rb +++ b/config/routes/ticket.rb @@ -42,8 +42,6 @@ Zammad::Application.routes.draw do match api_path + '/ticket_articles/:id', to: 'ticket_articles#update', via: :put match api_path + '/ticket_articles/:id', to: 'ticket_articles#destroy', via: :delete match api_path + '/ticket_attachment/:ticket_id/:article_id/:id', to: 'ticket_articles#attachment', via: :get - match api_path + '/ticket_attachment_upload', to: 'ticket_articles#ticket_attachment_upload_add', via: :post - match api_path + '/ticket_attachment_upload', to: 'ticket_articles#ticket_attachment_upload_delete', via: :delete match api_path + '/ticket_attachment_upload_clone_by_article/:article_id', to: 'ticket_articles#ticket_attachment_upload_clone_by_article', via: :post match api_path + '/ticket_article_plain/:id', to: 'ticket_articles#article_plain', via: :get diff --git a/config/routes/upload_cache.rb b/config/routes/upload_cache.rb new file mode 100644 index 000000000..0a3d26090 --- /dev/null +++ b/config/routes/upload_cache.rb @@ -0,0 +1,9 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + # upload cache + match api_path + '/upload_caches/:id', to: 'upload_caches#update', via: :post + match api_path + '/upload_caches/:id', to: 'upload_caches#destroy', via: :delete + match api_path + '/upload_caches/:id/items/:store_id', to: 'upload_caches#remove_item', via: :delete + +end diff --git a/lib/upload_cache.rb b/lib/upload_cache.rb new file mode 100644 index 000000000..853f24c79 --- /dev/null +++ b/lib/upload_cache.rb @@ -0,0 +1,98 @@ +# A wrapper class around Store that handles temporary attachment uploads +# and provides an interface for those. +class UploadCache + + attr_reader :id + + # Initializes a UploadCache for a given form_id. + # + # @example + # cache = UploadCache.new(form_id) + # + # @return [UploadCache] + def initialize(id) + # conversion to Integer is required for proper Store#o_id comparsion + @id = id.to_i + end + + # Adds a Store item with the given attributes for the form_id. + # + # @see Store#add + # + # @example + # cache = UploadCache.new(form_id) + # store = cache.add( + # filename: file.original_filename, + # data: file.read, + # preferences: { + # 'Content-Type' => 'application/octet-stream' + # } + # ) + # + # @return [Store] the created Store item + def add(args) + Store.add( + args.merge( + object: store_object, + o_id: id, + ) + ) + end + + # Provides all Store items associated to the form_id. + # + # @see Store#list + # + # @example + # attachments = UploadCache.new(form_id).attachments + # + # @return [Array] an enumerator of Store items + def attachments + Store.list( + object: store_object, + o_id: id, + ) + end + + # Removes all Store items associated to the form_id. + # + # @see Store#remove + # + # @example + # UploadCache.new(form_id).destroy + # + def destroy + Store.remove( + object: store_object, + o_id: id, + ) + end + + # Removes all Store items associated to the form_id. + # + # @see Store#remove + # + # @example + # UploadCache.new(form_id).remove_item(store_id) + # + # @raise [Exceptions::UnprocessableEntity] in cases where a Store item should get deleted that is not an UploadCache item + # + def remove_item(store_id = nil) + store = Store.find(store_id) + if store.o_id != id || store.store_object_id != store_object_id + raise Exceptions::UnprocessableEntity, "Attempt to delete Store item #{store_id} that is not bound to UploadCache object" + end + + Store.remove_item(store_id) + end + + private + + def store_object + self.class.name + end + + def store_object_id + Store::Object.lookup(name: store_object).id + end +end diff --git a/spec/fixtures/upload/hello_world.txt b/spec/fixtures/upload/hello_world.txt new file mode 100644 index 000000000..c57eff55e --- /dev/null +++ b/spec/fixtures/upload/hello_world.txt @@ -0,0 +1 @@ +Hello World! \ No newline at end of file diff --git a/spec/lib/upload_cache_spec.rb b/spec/lib/upload_cache_spec.rb new file mode 100644 index 000000000..eeb424c17 --- /dev/null +++ b/spec/lib/upload_cache_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +RSpec.describe UploadCache do + + let(:subject) { described_class.new(1337) } + + # required for adding items to the Store + before { UserInfo.current_user_id = 1 } + + describe '#initialize' do + + it 'converts given (form_)id to an Integer' do + expect(described_class.new('1337').id).to eq(1337) + end + end + + describe '#add' do + + it 'adds a Store item' do + expect do + subject.add( + data: 'content_file3_normally_should_be_an_image', + filename: 'some_file3.jpg', + preferences: { + 'Content-Type' => 'image/jpeg', + 'Mime-Type' => 'image/jpeg', + 'Content-Disposition' => 'attached', + }, + ) + end.to change { Store.count }.by(1) + end + end + + describe '#attachments' do + + before do + subject.add( + data: 'hello world', + filename: 'some.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + end + + it 'returns all Store items' do + attachments = subject.attachments + + expect(attachments.count).to be(1) + expect(attachments).to include(Store.last) + end + end + + describe '#destroy' do + + before do + subject.add( + data: 'hello world', + filename: 'some.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + + subject.add( + data: 'hello other world', + filename: 'another_some.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + end + + it 'removes all added Store items' do + expect { subject.destroy }.to change { Store.count }.by(-2) + end + end + + describe '#remove_item' do + + before do + subject.add( + data: 'hello world', + filename: 'some.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + end + + it 'removes the Store item matching the given ID' do + expect { subject.remove_item(Store.last.id) }.to change { Store.count }.by(-1) + end + + it 'prevents removage of non UploadCache Store items' do + + item = Store.add( + object: 'Ticket', + o_id: 1, + data: "Can't touch this", + filename: 'keep.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + + expect { subject.remove_item(item.id) }.to raise_error(Exceptions::UnprocessableEntity) + end + + it 'fails for non existing UploadCache Store items' do + expect { subject.remove_item(1337) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/requests/upload_cache_spec.rb b/spec/requests/upload_cache_spec.rb new file mode 100644 index 000000000..277ea9492 --- /dev/null +++ b/spec/requests/upload_cache_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe 'UploadCache', type: :request do + + let(:user) { create(:customer_user) } + let(:form_id) { 1337 } + let(:upload_cache) { UploadCache.new(form_id) } + + # required for adding items to the Store + before { UserInfo.current_user_id = 1 } + + before { authenticated_as(user) } + + describe '/upload_caches/:id' do + + context 'for POST requests' do + + it 'adds items to UploadCache' do + params = { + File: fixture_file_upload('upload/hello_world.txt', 'text/plain') + } + post "/api/v1/upload_caches/#{form_id}", params: params + + expect(response).to have_http_status(200) + end + + it 'detects Content-Type for binary uploads' do + params = { + File: fixture_file_upload('upload/hello_world.txt', 'application/octet-stream') + } + post "/api/v1/upload_caches/#{form_id}", params: params + + expect(Store.last.preferences['Content-Type']).to eq('text/plain') + end + end + + context 'for DELETE requests' do + + before do + 2.times do |iteration| + upload_cache.add( + data: "Can't touch this #{iteration}", + filename: 'keep.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + end + end + + it 'removes all form_id UploadCache items' do + expect do + delete "/api/v1/upload_caches/#{form_id}", as: :json + end.to change { upload_cache.attachments }.to([]) + end + end + end + + describe '/upload_caches/:id/items/:store_id' do + + context 'for DELETE requests' do + + before do + upload_cache.add( + data: "Can't touch this", + filename: 'keep.txt', + preferences: { + 'Content-Type' => 'text/plain', + }, + ) + end + + it 'removes a UploadCache item by given store id' do + + store_id = upload_cache.attachments.first.id + delete "/api/v1/upload_caches/#{form_id}/items/#{store_id}", as: :json + + expect(Store.exists?(store_id)).to be(false) + end + end + end +end