Maintenance: Enhance attachment preview capabilities

This commit is contained in:
Thorsten Eckel 2021-09-22 14:49:22 +02:00
parent 7dbd1c1b15
commit acc93a23fb
8 changed files with 322 additions and 158 deletions

View file

@ -10,8 +10,8 @@ class ApplicationController < ActionController::Base
include ApplicationController::RendersModels include ApplicationController::RendersModels
include ApplicationController::HasUser include ApplicationController::HasUser
include ApplicationController::HasResponseExtentions include ApplicationController::HasResponseExtentions
include ApplicationController::HasDownload
include ApplicationController::PreventsCsrf include ApplicationController::PreventsCsrf
include ApplicationController::HasSecureContentSecurityPolicyForDownloads
include ApplicationController::LogsHttpAccess include ApplicationController::LogsHttpAccess
include ApplicationController::Authorizes include ApplicationController::Authorizes
end end

View file

@ -0,0 +1,44 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
module ApplicationController::HasDownload
extend ActiveSupport::Concern
included do
around_action do |_controller, block|
subscriber = proc do
policy = ActionDispatch::ContentSecurityPolicy.new
policy.default_src :none
# The 'plugin_types' rule is deprecated and should be changed in the future.
policy.plugin_types 'application/pdf'
request.content_security_policy = policy
end
ActiveSupport::Notifications.subscribed(subscriber, 'send_file.action_controller') do
ActiveSupport::Notifications.subscribed(subscriber, 'send_data.action_controller') do
block.call
end
end
end
end
private
def file_id
@file_id ||= params[:id]
end
def download_file
@download_file ||= ::ApplicationController::HasDownload::DownloadFile.new(file_id, disposition: sanitized_disposition)
end
def sanitized_disposition
disposition = params.fetch(:disposition, 'inline')
valid_disposition = %w[inline attachment]
return disposition if valid_disposition.include?(disposition)
raise Exceptions::Forbidden, "Invalid disposition #{disposition} requested. Only #{valid_disposition.join(', ')} are valid."
end
end

View file

@ -0,0 +1,54 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
class ApplicationController::HasDownload::DownloadFile < SimpleDelegator
attr_reader :requested_disposition
def initialize(id, disposition: 'inline')
@requested_disposition = disposition
super(Store.find(id))
end
def disposition
return 'attachment' if forcibly_download_as_binary? || !allowed_inline?
requested_disposition
end
def content_type
return ActiveStorage.binary_content_type if forcibly_download_as_binary?
file_content_type
end
def content(view_type)
return __getobj__.content if view_type.blank? || !preferences[:resizable]
return content_inline if content_inline? && view_type == 'inline'
return content_preview if content_preview? && view_type == 'preview'
__getobj__.content
end
private
def allowed_inline?
ActiveStorage.content_types_allowed_inline.include?(content_type)
end
def forcibly_download_as_binary?
ActiveStorage.content_types_to_serve_as_binary.include?(file_content_type)
end
def file_content_type
@file_content_type ||= preferences['Content-Type'] || preferences['Mime-Type'] || ActiveStorage.binary_content_type
end
def content_inline?
preferences[:content_inline] == true
end
def content_preview?
preferences[:content_preview] == true
end
end

View file

@ -1,25 +0,0 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
module ApplicationController::HasSecureContentSecurityPolicyForDownloads
extend ActiveSupport::Concern
included do
around_action do |_controller, block|
subscriber = proc do
policy = ActionDispatch::ContentSecurityPolicy.new
policy.default_src :none
policy.plugin_types 'application/pdf'
request.content_security_policy = policy
end
ActiveSupport::Notifications.subscribed(subscriber, 'send_file.action_controller') do
ActiveSupport::Notifications.subscribed(subscriber, 'send_data.action_controller') do
block.call
end
end
end
end
end

View file

@ -6,14 +6,13 @@ class AttachmentsController < ApplicationController
prepend_before_action :authentication_check_only, only: %i[show destroy] prepend_before_action :authentication_check_only, only: %i[show destroy]
def show def show
content = @file.content_preview if params[:preview] && @file.preferences[:content_preview] view_type = params[:preview] ? 'preview' : nil
content ||= @file.content
send_data( send_data(
content, download_file.content(view_type),
filename: @file.filename, filename: download_file.filename,
type: @file.preferences['Content-Type'] || @file.preferences['Mime-Type'] || 'application/octet-stream', type: download_file.content_type,
disposition: sanitized_disposition disposition: download_file.disposition
) )
end end
@ -52,7 +51,7 @@ class AttachmentsController < ApplicationController
end end
def destroy def destroy
Store.remove_item(@file.id) Store.remove_item(download_file.id)
render json: { render json: {
success: true, success: true,
@ -72,18 +71,8 @@ class AttachmentsController < ApplicationController
private private
def sanitized_disposition
disposition = params.fetch(:disposition, 'inline')
valid_disposition = %w[inline attachment]
return disposition if valid_disposition.include?(disposition)
raise Exceptions::Forbidden, "Invalid disposition #{disposition} requested. Only #{valid_disposition.join(', ')} are valid."
end
def authorize! def authorize!
@file = Store.find(params[:id]) record = download_file&.store_object&.name&.safe_constantize&.find(download_file.o_id)
record = @file&.store_object&.name&.safe_constantize&.find(@file.o_id)
authorize(record) if record authorize(record) if record
rescue Pundit::NotAuthorizedError rescue Pundit::NotAuthorizedError
raise ActiveRecord::RecordNotFound raise ActiveRecord::RecordNotFound

View file

@ -175,29 +175,11 @@ class TicketArticlesController < ApplicationController
end end
raise Exceptions::Forbidden, 'Requested file id is not linked with article_id.' if !access raise Exceptions::Forbidden, 'Requested file id is not linked with article_id.' if !access
# find file
file = Store.find(params[:id])
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( send_data(
content, download_file.content(params[:view]),
filename: file.filename, filename: download_file.filename,
type: file.preferences['Content-Type'] || file.preferences['Mime-Type'] || 'application/octet-stream', type: download_file.content_type,
disposition: disposition disposition: download_file.disposition
) )
end end
@ -278,14 +260,4 @@ class TicketArticlesController < ApplicationController
render json: result render json: result
end end
private
def sanitized_disposition
disposition = params.fetch(:disposition, 'inline')
valid_disposition = %w[inline attachment]
return disposition if valid_disposition.include?(disposition)
raise Exceptions::Forbidden, "Invalid disposition #{disposition} requested. Only #{valid_disposition.join(', ')} are valid."
end
end end

View file

@ -0,0 +1,88 @@
# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
require 'rails_helper'
RSpec.describe ApplicationController::HasDownload::DownloadFile do
subject(:download_file) { described_class.new(stored_file.id, disposition: 'inline') }
let(:file_content_type) { 'application/pdf' }
let(:file_data) { 'A example file.' }
let(:file_name) { 'example.pdf' }
let(:stored_file) do
Store.add(
object: 'Ticket',
o_id: 1,
data: file_data,
filename: file_name,
preferences: {
'Content-Type' => file_content_type,
},
created_by_id: 1,
)
end
describe '#disposition' do
context "with given object dispostion 'inline'" do
context 'with allowed inline content type (from ActiveStorage.content_types_allowed_inline)' do
it 'disposition is inline' do
expect(download_file.disposition).to eq('inline')
end
end
context 'with binary content type (ActiveStorage.content_types_to_serve_as_binary)' do
let(:file_content_type) { 'image/svg+xml' }
it 'disposition forced to attachment' do
expect(download_file.disposition).to eq('attachment')
end
end
end
context "with given object dispostion 'attachment'" do
subject(:download_file) { described_class.new(stored_file.id, disposition: 'attachment') }
it 'disposition is attachment' do
expect(download_file.disposition).to eq('attachment')
end
end
end
describe '#content_type' do
context 'with none binary content type' do
it 'check content type' do
expect(download_file.content_type).to eq('application/pdf')
end
end
context 'with forced active storage binary content type' do
let(:file_content_type) { 'image/svg+xml' }
it 'check content type' do
expect(download_file.content_type).to eq('application/octet-stream')
end
end
end
describe '#content' do
context 'with not resizable file' do
it 'check that normal content will be returned' do
expect(download_file.content('preview')).to eq('A example file.')
end
end
context 'with image content type' do
let(:file_content_type) { 'image/jpg' }
let(:file_data) { File.binread(Rails.root.join('test/data/upload/upload2.jpg')) }
let(:file_name) { 'image.jpg' }
it 'check that inline content will be returned' do
expect(download_file.content('inline')).to not_eq(file_data)
end
it 'check that preview content will be returned' do
expect(download_file.content('preview')).to not_eq(file_data)
end
end
end
end

View file

@ -2,7 +2,7 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe 'Ticket Article Attachments', type: :request do RSpec.describe 'Ticket Article Attachments', type: :request, authenticated_as: -> { agent } do
let(:group) { create(:group) } let(:group) { create(:group) }
@ -11,107 +11,149 @@ RSpec.describe 'Ticket Article Attachments', type: :request do
end end
describe 'request handling' do describe 'request handling' do
context 'with attachment urls' do
let(:ticket1) { create(:ticket, group: group) }
let(:article1) { create(:ticket_article, ticket: ticket1) }
let(:ticket2) { create(:ticket, group: group) }
let(:article2) { create(:ticket_article, ticket: ticket2) }
it 'does test attachment urls' do let(:store_file_content_type) { 'text/plain' }
ticket1 = create(:ticket, group: group) let!(:store_file) do
article1 = create(:ticket_article, ticket_id: ticket1.id) Store.add(
object: 'Ticket::Article',
o_id: article1.id,
data: 'some content',
filename: 'some_file.txt',
preferences: {
'Content-Type' => store_file_content_type,
},
created_by_id: 1,
)
end
store1 = Store.add( context 'with one article attachment' do
object: 'Ticket::Article', it 'does test different attachment urls' do
o_id: article1.id, get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}", params: {}
data: 'some content', expect(response).to have_http_status(:ok)
filename: 'some_file.txt', expect('some content').to eq(response.body)
preferences: {
'Content-Type' => 'text/plain',
},
created_by_id: 1,
)
article2 = create(:ticket_article, ticket_id: ticket1.id)
authenticated_as(agent) get "/api/v1/ticket_attachment/#{ticket1.id}/#{article2.id}/#{store_file.id}", params: {}
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store1.id}", params: {} expect(response).to have_http_status(:forbidden)
expect(response).to have_http_status(:ok) expect(response.body).to match(%r{403: Forbidden})
expect('some content').to eq(@response.body) end
end
authenticated_as(agent) context 'with attachment from merged ticket' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article2.id}/#{store1.id}", params: {} before do
expect(response).to have_http_status(:forbidden) ticket1.merge_to(
expect(@response.body).to match(%r{403: Forbidden}) ticket_id: ticket2.id,
user_id: 1,
)
end
ticket2 = create(:ticket, group: group) it 'does test attachment url after ticket merge' do
ticket1.merge_to( get "/api/v1/ticket_attachment/#{ticket2.id}/#{article1.id}/#{store_file.id}", params: {}
ticket_id: ticket2.id, expect(response).to have_http_status(:ok)
user_id: 1, expect('some content').to eq(response.body)
)
authenticated_as(agent) get "/api/v1/ticket_attachment/#{ticket2.id}/#{article2.id}/#{store_file.id}", params: {}
get "/api/v1/ticket_attachment/#{ticket2.id}/#{article1.id}/#{store1.id}", params: {} expect(response).to have_http_status(:forbidden)
expect(response).to have_http_status(:ok) expect(response.body).to match(%r{403: Forbidden})
expect('some content').to eq(@response.body)
authenticated_as(agent) # allow access via merged ticket id also
get "/api/v1/ticket_attachment/#{ticket2.id}/#{article2.id}/#{store1.id}", params: {} get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}", params: {}
expect(response).to have_http_status(:forbidden) expect(response).to have_http_status(:ok)
expect(@response.body).to match(%r{403: Forbidden}) expect('some content').to eq(response.body)
# allow access via merged ticket id also get "/api/v1/ticket_attachment/#{ticket1.id}/#{article2.id}/#{store_file.id}", params: {}
authenticated_as(agent) expect(response).to have_http_status(:forbidden)
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store1.id}", params: {} expect(response.body).to match(%r{403: Forbidden})
expect(response).to have_http_status(:ok) end
expect('some content').to eq(@response.body) end
authenticated_as(agent) context 'with different file content types' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article2.id}/#{store1.id}", params: {} context 'without allowed inline file content type' do
expect(response).to have_http_status(:forbidden) it 'disposition can not be inline' do
expect(@response.body).to match(%r{403: Forbidden}) get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}?disposition=inline", params: {}
expect(response.headers['Content-Disposition']).to include('attachment')
end
it 'content-type is correct' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}?disposition=inline", params: {}
expect(response.headers['Content-Type']).to include('text/plain')
end
end
context 'with binary file content type' do
let(:store_file_content_type) { 'image/svg+xml' }
it 'disposition can not be inline' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}?disposition=inline", params: {}
expect(response.headers['Content-Disposition']).to include('attachment')
end
it 'content-type was forced to active storage binary content type' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}?disposition=inline", params: {}
expect(response.headers['Content-Type']).to include('application/octet-stream')
end
end
context 'with allowed inline file content type' do
let(:store_file_content_type) { 'application/pdf' }
it 'disposition is inline' do
get "/api/v1/ticket_attachment/#{ticket1.id}/#{article1.id}/#{store_file.id}?disposition=inline", params: {}
expect(response.headers['Content-Disposition']).to include('inline')
end
end
end
end end
it 'does test attachments for split' do context 'when attachment actions are used' do
email_file_path = Rails.root.join('test/data/mail/mail024.box') it 'does test attachments for split' do
email_raw_string = File.read(email_file_path) email_file_path = Rails.root.join('test/data/mail/mail024.box')
ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string) email_raw_string = File.read(email_file_path)
ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string)
authenticated_as(agent) get '/api/v1/ticket_split', params: { form_id: '1234-2', ticket_id: ticket_p.id, article_id: article_p.id }, as: :json
get '/api/v1/ticket_split', params: { form_id: '1234-2', ticket_id: ticket_p.id, article_id: article_p.id }, as: :json expect(response).to have_http_status(:ok)
expect(response).to have_http_status(:ok) expect(json_response['assets']).to be_truthy
expect(json_response['assets']).to be_truthy expect(json_response['attachments']).to be_a_kind_of(Array)
expect(json_response['attachments']).to be_a_kind_of(Array) expect(json_response['attachments'].count).to eq(1)
expect(json_response['attachments'].count).to eq(1) expect(json_response['attachments'][0]['filename']).to eq('rulesets-report.csv')
expect(json_response['attachments'][0]['filename']).to eq('rulesets-report.csv')
end end
it 'does test attachments for forward' do it 'does test attachments for forward' do
email_file_path = Rails.root.join('test/data/mail/mail008.box') email_file_path = Rails.root.join('test/data/mail/mail008.box')
email_raw_string = File.read(email_file_path) email_raw_string = File.read(email_file_path)
_ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string) _ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string)
authenticated_as(agent) post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: {}, as: :json
post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: {}, as: :json expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_entity) expect(json_response).to be_a_kind_of(Hash)
expect(json_response).to be_a_kind_of(Hash) expect(json_response['error']).to eq('Need form_id to attach attachments to new form.')
expect(json_response['error']).to eq('Need form_id to attach attachments to new form.')
post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-1' }, as: :json post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-1' }, as: :json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response['attachments']).to be_a_kind_of(Array) expect(json_response['attachments']).to be_a_kind_of(Array)
expect(json_response['attachments']).to be_blank expect(json_response['attachments']).to be_blank
email_file_path = Rails.root.join('test/data/mail/mail024.box') email_file_path = Rails.root.join('test/data/mail/mail024.box')
email_raw_string = File.read(email_file_path) email_raw_string = File.read(email_file_path)
_ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string) _ticket_p, article_p, _user_p = Channel::EmailParser.new.process({}, email_raw_string)
post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-2' }, as: :json post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-2' }, as: :json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response['attachments']).to be_a_kind_of(Array) expect(json_response['attachments']).to be_a_kind_of(Array)
expect(json_response['attachments'].count).to eq(1) expect(json_response['attachments'].count).to eq(1)
expect(json_response['attachments'][0]['filename']).to eq('rulesets-report.csv') expect(json_response['attachments'][0]['filename']).to eq('rulesets-report.csv')
post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-2' }, as: :json post "/api/v1/ticket_attachment_upload_clone_by_article/#{article_p.id}", params: { form_id: '1234-2' }, as: :json
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json_response['attachments']).to be_a_kind_of(Array) expect(json_response['attachments']).to be_a_kind_of(Array)
expect(json_response['attachments']).to be_blank expect(json_response['attachments']).to be_blank
end
end end
end end
end end