From 1c5c45a1e19e6fa87af231afe1fc4046c3ff987a Mon Sep 17 00:00:00 2001 From: Joshua Dwire Date: Thu, 24 Mar 2016 11:33:28 -0400 Subject: [PATCH] Improved handling of various document types; better documents zip --- app/Http/Controllers/DocumentController.php | 10 +- .../Controllers/PublicClientController.php | 128 +++++++++--------- app/Models/Document.php | 79 ++++++++--- app/Ninja/Repositories/DocumentRepository.php | 32 +++-- app/Providers/AppServiceProvider.php | 6 + public/built.js | 6 +- public/js/pdf.pdfmake.js | 6 +- resources/lang/en/texts.php | 2 +- resources/views/invoices/edit.blade.php | 19 ++- resources/views/invoices/history.blade.php | 4 +- resources/views/invoices/view.blade.php | 8 +- 11 files changed, 183 insertions(+), 117 deletions(-) diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index 6c824dab61..593ab595f3 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -46,7 +46,7 @@ class DocumentController extends BaseController if($stream){ $headers = [ - 'Content-Type' => $document->type, + 'Content-Type' => Document::$types[$document->type]['mime'], 'Content-Length' => $document->size, ]; @@ -56,7 +56,7 @@ class DocumentController extends BaseController } else{ $response = Response::make($document->getRaw(), 200); - $response->header('content-type', $document->type); + $response->header('content-type', Document::$types[$document->type]['mime']); } return $response; @@ -80,9 +80,9 @@ class DocumentController extends BaseController return redirect($direct_url); } - $extension = pathinfo($document->preview, PATHINFO_EXTENSION); + $previewType = pathinfo($document->preview, PATHINFO_EXTENSION); $response = Response::make($document->getRawPreview(), 200); - $response->header('content-type', Document::$extensions[$extension]); + $response->header('content-type', Document::$types[$previewType]['mime']); return $response; } @@ -99,7 +99,7 @@ class DocumentController extends BaseController return $response; } - if(substr($document->type, 0, 6) != 'image/'){ + if(!$document->isPDFEmbeddable()){ return Response::view('error', array('error'=>'Image does not exist!'), 404); } diff --git a/app/Http/Controllers/PublicClientController.php b/app/Http/Controllers/PublicClientController.php index 6c2ad8cd49..7aca779532 100644 --- a/app/Http/Controllers/PublicClientController.php +++ b/app/Http/Controllers/PublicClientController.php @@ -19,8 +19,7 @@ use App\Ninja\Repositories\ActivityRepository; use App\Events\InvoiceInvitationWasViewed; use App\Events\QuoteInvitationWasViewed; use App\Services\PaymentService; -use League\Flysystem\Filesystem; -use League\Flysystem\ZipArchive\ZipArchiveAdapter; +use Barracuda\ArchiveStream\ZipArchive; class PublicClientController extends BaseController { @@ -138,8 +137,13 @@ class PublicClientController extends BaseController 'phantomjs' => Input::has('phantomjs'), ); - if($account->isPro() && $this->canCreateInvoiceDocsZip($invoice)){ - $data['documentsZipURL'] = URL::to("client/documents/{$invitation->invitation_key}"); + if($account->isPro() && $this->canCreateZip()){ + $zipDocs = $this->getInvoiceZipDocuments($invoice, $size); + + if(count($zipDocs) > 1){ + $data['documentsZipURL'] = URL::to("client/documents/{$invitation->invitation_key}"); + $data['documentsZipSize'] = $size; + } } return View::make('invoices.view', $data); @@ -172,6 +176,12 @@ class PublicClientController extends BaseController return $paymentTypes; } + + protected function humanFilesize($bytes, $decimals = 2) { + $size = array('B','kB','MB','GB','TB','PB','EB','ZB','YB'); + $factor = floor((strlen($bytes) - 1) / 3); + return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . @$size[$factor]; + } public function download($invitationKey) { @@ -391,7 +401,7 @@ class PublicClientController extends BaseController $document = Document::scope($publicId, $invitation->account_id)->first(); - if(!$document || substr($document->type, 0, 6) != 'image/'){ + if(!$document->isPDFEmbeddable()){ return Response::view('error', array('error'=>'Image does not exist!'), 404); } @@ -423,19 +433,43 @@ class PublicClientController extends BaseController return function_exists('gmp_init'); } - protected function canCreateInvoiceDocsZip($invoice){ - if(!$this->canCreateZip())return false; - if(count($invoice->documents) == 1)return false; - + protected function getInvoiceZipDocuments($invoice, &$size=0){ + $documents = $invoice->documents->sortBy('size'); + + $size = 0; $maxSize = MAX_ZIP_DOCUMENTS_SIZE * 1000; - $i = 0; - foreach($invoice->documents as $document){ - if($document->size <= $maxSize)$i++; - if($i > 1){ - return true; + $toZip = array(); + foreach($documents as $document){ + if($size + $document->size > $maxSize)break; + + if(!empty($toZip[$document->name])){ + // This name is taken + if($toZip[$document->name]->hash != $document->hash){ + // 2 different files with the same name + $nameInfo = pathinfo($document->name); + + for($i = 1;; $i++){ + $name = $nameInfo['filename'].' ('.$i.').'.$nameInfo['extension']; + + if(empty($toZip[$name])){ + $toZip[$name] = $document; + $size += $document->size; + break; + } else if ($toZip[$name]->hash == $document->hash){ + // We're not adding this after all + break; + } + } + + } + } + else{ + $toZip[$document->name] = $document; + $size += $document->size; } } - return false; + + return $toZip; } public function getInvoiceDocumentsZip($invitationKey){ @@ -451,64 +485,24 @@ class PublicClientController extends BaseController return Response::view('error', array('error'=>'No documents'), 404); } - $documents = $invoice->documents->sortBy('size'); - - $size = 0; - $maxSize = MAX_ZIP_DOCUMENTS_SIZE * 1000; - $toZip = array(); - foreach($documents as $document){ - $size += $document->size; - if($size > $maxSize)break; - - if(!empty($toZip[$document->name])){ - $hasSameHash = false; - foreach($toZip[$document->name] as $sameName){ - if($sameName->hash == $document->hash){ - $hasSameHash = true; - break; - } - } - - if(!$hasSameHash){ - // 2 different files with the same name - $toZip[$document->name][] = $document; - } - else{ - // We're not adding this after all - $size -= $document->size; - } - } - else{ - $toZip[$document->name] = array($document); - } - } + $toZip = $this->getInvoiceZipDocuments($invoice); if(!count($toZip)){ return Response::view('error', array('error'=>'No documents small enough'), 404); } - $zip = new \Barracuda\ArchiveStream\ZipArchive($invitation->account->name.' Invoice '.$invoice->invoice_number.'.zip'); + $zip = new ZipArchive($invitation->account->name.' Invoice '.$invoice->invoice_number.'.zip'); return Response::stream(function() use ($toZip, $zip) { - foreach($toZip as $documentsSameName){ - $i = 0; - foreach($documentsSameName as $document){ - $name = $document->name; - - if($i){ - $nameInfo = pathinfo($document->name); - $name = $nameInfo['filename'].' ('.$i.').'.$nameInfo['extension']; - } - - $fileStream = $document->getStream(); - if($fileStream){ - $zip->init_file_stream_transfer($name, $document->size); - while ($buffer = fread($fileStream, 8192))$zip->stream_file_part($buffer); - fclose($fileStream); - $zip->complete_file_stream(); - } - else{ - $zip->add_file($name, $document->getRaw()); - } + foreach($toZip as $name=>$document){ + $fileStream = $document->getStream(); + if($fileStream){ + $zip->init_file_stream_transfer($name, $document->size, array('time'=>$document->created_at->timestamp)); + while ($buffer = fread($fileStream, 256000))$zip->stream_file_part($buffer); + fclose($fileStream); + $zip->complete_file_stream(); + } + else{ + $zip->add_file($name, $document->getRaw()); } } $zip->finish(); diff --git a/app/Models/Document.php b/app/Models/Document.php index 92d3e79439..e1f01b5842 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -5,31 +5,68 @@ use DB; class Document extends EntityModel { - public static $extensions = array( - 'png' => 'image/png', - 'jpg' => 'image/jpeg', - 'jpeg' => 'image/jpeg', - 'tiff' => 'image/tiff', - 'tif' => 'image/tiff', - 'pdf' => 'application/pdf', - 'gif' => 'image/gif' + public static $extraExtensions = array( + 'jpg' => 'jpeg', + 'tif' => 'tiff', + ); + + public static $allowedMimes = array(// Used by Dropzone.js; does not affect what the server accepts + 'image/png', 'image/jpeg', 'image/tiff', 'application/pdf', 'image/gif', 'image/vnd.adobe.photoshop', 'text/plain', + 'application/zip', 'application/msword', + 'application/excel', 'application/vnd.ms-excel', 'application/x-excel', 'application/x-msexcel', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet','application/postscript', 'image/svg+xml', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.ms-powerpoint', ); public static $types = array( - 'image/png' => array( - 'extension' => 'png', + 'png' => array( + 'mime' => 'image/png', ), - 'image/jpeg' => array( - 'extension' => 'jpeg', + 'ai' => array( + 'mime' => 'application/postscript', ), - 'image/tiff' => array( - 'extension' => 'tiff', + 'svg' => array( + 'mime' => 'image/svg+xml', ), - 'image/gif' => array( - 'extension' => 'gif', + 'jpeg' => array( + 'mime' => 'image/jpeg', ), - 'application/pdf' => array( - 'extension' => 'pdf', + 'tiff' => array( + 'mime' => 'image/tiff', + ), + 'pdf' => array( + 'mime' => 'application/pdf', + ), + 'gif' => array( + 'mime' => 'image/gif', + ), + 'psd' => array( + 'mime' => 'image/vnd.adobe.photoshop', + ), + 'txt' => array( + 'mime' => 'text/plain', + ), + 'zip' => array( + 'mime' => 'application/zip', + ), + 'doc' => array( + 'mime' => 'application/msword', + ), + 'xls' => array( + 'mime' => 'application/vnd.ms-excel', + ), + 'ppt' => array( + 'mime' => 'application/vnd.ms-powerpoint', + ), + 'xlsx' => array( + 'mime' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + 'docx' => array( + 'mime' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ), + 'pptx' => array( + 'mime' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ), ); @@ -142,11 +179,17 @@ class Document extends EntityModel return url('client/document/'.$invitation->invitation_key.'/'.$this->public_id.'/'.$this->name); } + public function isPDFEmbeddable(){ + return $this->type == 'jpeg' || $this->type == 'png' || $this->preview; + } + public function getVFSJSUrl(){ + if(!$this->isPDFEmbeddable())return null; return url('document/js/'.$this->public_id.'/'.$this->name.'.js'); } public function getClientVFSJSUrl(){ + if(!$this->isPDFEmbeddable())return null; return url('client/document/js/'.$this->public_id.'/'.$this->name.'.js'); } diff --git a/app/Ninja/Repositories/DocumentRepository.php b/app/Ninja/Repositories/DocumentRepository.php index 2ce58720de..ce4c7fd9d8 100644 --- a/app/Ninja/Repositories/DocumentRepository.php +++ b/app/Ninja/Repositories/DocumentRepository.php @@ -61,14 +61,22 @@ class DocumentRepository extends BaseRepository $uploaded = $input['file']; $extension = strtolower($uploaded->extension()); - if(empty(Document::$extensions[$extension])){ + if(empty(Document::$types[$extension]) && !empty(Document::$extraExtensions[$extension])){ + $documentType = Document::$extraExtensions[$extension]; + } + else{ + $documentType = $extension; + } + + if(empty(Document::$types[$documentType])){ return Response::json([ 'error' => 'Unsupported extension', 'code' => 400 ], 400); } - - $documentType = Document::$extensions[$extension]; + + $documentTypeData = Document::$types[$documentType]; + $filePath = $uploaded->path(); $name = $uploaded->getClientOriginalName(); $size = filesize($filePath); @@ -80,10 +88,10 @@ class DocumentRepository extends BaseRepository ], 400); } - $documentTypeData = Document::$types[$documentType]; + $hash = sha1_file($filePath); - $filename = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentTypeData['extension']; + $filename = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentType; $document = Document::createNew(); $disk = $document->getDisk(); @@ -94,20 +102,20 @@ class DocumentRepository extends BaseRepository } // This is an image; check if we need to create a preview - if(in_array($documentType, array('image/jpeg','image/png','image/gif','image/bmp','image/tiff'))){ + if(in_array($documentType, array('jpeg','png','gif','bmp','tiff','psd'))){ $makePreview = false; $imageSize = getimagesize($filePath); $width = $imageSize[0]; $height = $imageSize[1]; $imgManagerConfig = array(); - if(in_array($documentType, array('image/gif','image/bmp','image/tiff'))){ + if(in_array($documentType, array('gif','bmp','tiff','psd'))){ // Needs to be converted $makePreview = true; } else if($width > DOCUMENT_PREVIEW_SIZE || $height > DOCUMENT_PREVIEW_SIZE){ $makePreview = true; } - if($documentType == 'image/bmp' || $documentType == 'image/tiff'){ + if(in_array($documentType,array('bmp','tiff','psd'))){ if(!class_exists('Imagick')){ // Cant't read this $makePreview = false; @@ -117,13 +125,13 @@ class DocumentRepository extends BaseRepository } if($makePreview){ - $previewType = 'jpg'; - if(in_array($documentType, array('image/png','image/gif','image/bmp','image/tiff'))){ + $previewType = 'jpeg'; + if(in_array($documentType, array('png','gif','tiff','psd'))){ // Has transparency $previewType = 'png'; } - $document->preview = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentTypeData['extension'].'.x'.DOCUMENT_PREVIEW_SIZE.'.'.$previewType; + $document->preview = \Auth::user()->account->account_key.'/'.$hash.'.'.$documentType.'.x'.DOCUMENT_PREVIEW_SIZE.'.'.$previewType; if(!$disk->exists($document->preview)){ // We haven't created a preview yet $imgManager = new ImageManager($imgManagerConfig); @@ -170,7 +178,7 @@ class DocumentRepository extends BaseRepository $doc_array = $document->toArray(); if(!empty($base64)){ - $mime = !empty($previewType)?Document::$extensions[$previewType]:$documentType; + $mime = Document::$types[!empty($previewType)?$previewType:$documentType]['mime']; $doc_array['base64'] = 'data:'.$mime.';base64,'.$base64; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c5d597ded2..8a6760718a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -159,6 +159,12 @@ class AppServiceProvider extends ServiceProvider { return $str . ''; }); + Form::macro('human_filesize', function($bytes, $decimals = 1) { + $size = array('B','kB','MB','GB','TB','PB','EB','ZB','YB'); + $factor = floor((strlen($bytes) - 1) / 3); + return sprintf("%.{$decimals}f", $bytes / pow(1024, $factor)) . ' ' . @$size[$factor]; + }); + Validator::extend('positive', function($attribute, $value, $parameters) { return Utils::parseFloat($value) >= 0; }); diff --git a/public/built.js b/public/built.js index 6a2954d3a7..0b46c5659d 100644 --- a/public/built.js +++ b/public/built.js @@ -31404,10 +31404,10 @@ NINJA.invoiceDocuments = function(invoice) { for (var i = 0; i < invoice.documents.length; i++) { var document = invoice.documents[i]; var path = document.base64; - if(!path && (document.preview_url || document.type == 'image/png' || document.type == 'image/jpeg')){ - path = 'docs/'+document.public_id+'/'+document.name; - } + + if(!path)path = 'docs/'+document.public_id+'/'+document.name; if(path && (window.pdfMake.vfs[path] || document.base64)){ + // Only embed if we actually have an image for it if(j%3==0){ stackItem = {columns:[]}; stack.push(stackItem); diff --git a/public/js/pdf.pdfmake.js b/public/js/pdf.pdfmake.js index 6989916a88..29da491112 100644 --- a/public/js/pdf.pdfmake.js +++ b/public/js/pdf.pdfmake.js @@ -412,10 +412,10 @@ NINJA.invoiceDocuments = function(invoice) { for (var i = 0; i < invoice.documents.length; i++) { var document = invoice.documents[i]; var path = document.base64; - if(!path && (document.preview_url || document.type == 'image/png' || document.type == 'image/jpeg')){ - path = 'docs/'+document.public_id+'/'+document.name; - } + + if(!path)path = 'docs/'+document.public_id+'/'+document.name; if(path && (window.pdfMake.vfs[path] || document.base64)){ + // Only embed if we actually have an image for it if(j%3==0){ stackItem = {columns:[]}; stack.push(stackItem); diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 7eec8138a5..ec8ba2a2c4 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -1107,7 +1107,7 @@ $LANG = array( 'invoice_embed_documents' => 'Embed Documents', 'invoice_embed_documents_help' => 'Include attached images in the invoice.', 'document_email_attachment' => 'Attach Documents', - 'download_documents' => 'Download Documents', + 'download_documents' => 'Download Documents (:size)', ); return $LANG; diff --git a/resources/views/invoices/edit.blade.php b/resources/views/invoices/edit.blade.php index bf2ce391c6..134826e44a 100644 --- a/resources/views/invoices/edit.blade.php +++ b/resources/views/invoices/edit.blade.php @@ -930,7 +930,7 @@ params:{ _token:"{{ Session::getToken() }}" }, - acceptedFiles:{!! json_encode(implode(',',array_keys(\App\Models\Document::$types))) !!}, + acceptedFiles:{!! json_encode(implode(',',\App\Models\Document::$allowedMimes)) !!}, addRemoveLinks:true, maxFileSize:{{floatval(MAX_DOCUMENT_SIZE/1000)}}, dictDefaultMessage:{!! json_encode(trans('texts.document_upload_message')) !!} @@ -955,9 +955,12 @@ dropzone.emit('addedfile', mockFile); dropzone.emit('complete', mockFile); - if(document.type().match(/image.*/)){ + if(document.preview_url()){ dropzone.emit('thumbnail', mockFile, document.preview_url()||document.url()); } + else if(document.type()=='jpeg' || document.type()=='png' || document.type()=='svg'){ + dropzone.emit('thumbnail', mockFile, document.url()); + } dropzone.files.push(mockFile); } @endif @@ -1005,7 +1008,9 @@ } function createInvoiceModel() { - var invoice = ko.toJS(window.model).invoice; + var model = ko.toJS(window.model); + if(!model)return; + var invoice = model.invoice; invoice.is_pro = {{ Auth::user()->isPro() ? 'true' : 'false' }}; invoice.is_quote = {{ $entityType == ENTITY_QUOTE ? 'true' : 'false' }}; invoice.contact = _.findWhere(invoice.client.contacts, {send_invoice: true}); @@ -1335,13 +1340,19 @@ file.public_id = response.document.public_id model.invoice().documents()[file.index].update(response.document); refreshPDF(true); + + if(response.document.preview_url){ + dropzone.emit('thumbnail', file, response.document.preview_url); + } } @endif @if ($account->isPro() && $account->invoice_embed_documents) @foreach ($invoice->documents as $document) - + @if($document->isPDFEmbeddable()) + + @endif @endforeach @endif diff --git a/resources/views/invoices/history.blade.php b/resources/views/invoices/history.blade.php index 19d7eeff52..256f541f02 100644 --- a/resources/views/invoices/history.blade.php +++ b/resources/views/invoices/history.blade.php @@ -59,7 +59,9 @@ @if (Auth::user()->account->isPro() && Auth::user()->account->invoice_embed_documents) @foreach ($invoice->documents as $document) - + @if($document->isPDFEmbeddable()) + + @endif @endforeach @endif @stop \ No newline at end of file diff --git a/resources/views/invoices/view.blade.php b/resources/views/invoices/view.blade.php index 16479df684..bad07e9e73 100644 --- a/resources/views/invoices/view.blade.php +++ b/resources/views/invoices/view.blade.php @@ -43,7 +43,7 @@
@if(!empty($documentsZipURL)) - {!! Button::normal(trans('texts.download_documents'))->asLinkTo($documentsZipURL)->large() !!} + {!! Button::normal(trans('texts.download_documents', array('size'=>Form::human_filesize($documentsZipSize))))->asLinkTo($documentsZipURL)->large() !!} @endif
@endif @@ -54,7 +54,7 @@

{{ trans('texts.documents_header') }}

@@ -62,7 +62,9 @@ @if ($account->isPro() && $account->invoice_embed_documents) @foreach ($invoice->documents as $document) - + @if($document->isPDFEmbeddable()) + + @endif @endforeach @endif