From 6fec11ca78cdd726cb1545ccb251226a0027898c Mon Sep 17 00:00:00 2001 From: Sean Wysor Date: Tue, 17 Jul 2018 03:13:38 -0700 Subject: [PATCH] Help-36774: created a converter core library: kazoo_convert (#4904) * Created kazoo_convert core lib for file conversion * added tests for converter * Added stdlib for running commands safely * Added docs * Updaed travis to work with new deps * Make apis * Moved conversion to cb_faxes from fax_worker * updaed fax app to remove conversions in fax worker, added maintenance to migrate * update notify cause people use that * updated teletype to use converter * Fixed spell checker wrecking binary files, migrate file_cache_path, mkdocs updated * make fmt again make fmt * Moved normalize_content_types to kz_mime since its shared between crossbar and fax now * Fixed fax_init crashing on newly initialized system * Fixed bugs found in qa testing * Output for converter should be binary not path * make fmt * Testing migration * Testing related cleanup * Fixed bug in cb_faxes, cleaned up content-type discovery * fmt fix * Fixed dialyzer warning * Updated views to make the filtering simpler * Fixed fax maintenance not fetching doc * Fixed missing return in view * Fixed doc not getting page saved in api_fax * Fixed docs * force view migration on fax_init * fixed a bug with invalid extension on email attachments * Tweaking fax convert commands to preserve image fidelity * Updated docs to match the tweaked commands * Fixed invalid escape in json * ugh * Cleaned up docs * updated converter commands * make fmt * Cleaned up command names and maintenance commands * fixed docs * make apis * Fix docs * Fixed PR comments * Changes requeted on PR * Make fmt * Moved migrate_pending_faxes call to handle_cast in fax_monitor * fixed file size match in tests * refresh_viwes * Delete fax file after sending * Fixed some environmental issues with tiffs * migrate in another process * fix edoc * Fixed errant line * Redesigned how attachments are handled * Better attachment handling * fixed error * fixed circle stuff * mk fmt * fixed invalid clause * Fixed failing tests * fixed tests * fixed test * Updated crossbar api to download pdf, changed teletype config source * fixed ci issues * Fixed all review changes make fmt * Fixed dialyzer things * better error in cb_faxes * removed needless case * added missing field to doc --- .travis.yml | 3 + applications/crossbar/priv/api/swagger.json | 126 ++-- .../couchdb/schemas/system_config.fax.json | 15 + .../schemas/system_config.kazoo_convert.json | 111 +-- .../crossbar/src/modules/cb_faxes.erl | 97 +-- .../fax/priv/couchdb/views/faxes.json | 165 +++- applications/fax/src/fax.app.src | 13 +- applications/fax/src/fax.hrl | 42 +- applications/fax/src/fax_cloud.erl | 2 +- applications/fax/src/fax_init.erl | 1 + applications/fax/src/fax_jobs.erl | 2 +- applications/fax/src/fax_maintenance.erl | 72 +- applications/fax/src/fax_smtp.erl | 36 +- applications/fax/src/fax_util.erl | 133 ---- applications/fax/src/fax_worker.erl | 272 +------ .../src/notify_fax_inbound_to_email.erl | 2 +- .../src/notify_fax_outbound_to_email.erl | 2 +- applications/notify/src/notify_fax_util.erl | 82 +- .../teletype/src/teletype_fax_util.erl | 92 +-- core/kazoo/src/kz_mime.erl.src | 32 + core/kazoo_config/src/kapps_config.erl | 3 + core/kazoo_convert/Makefile | 8 + core/kazoo_convert/doc/README.md | 33 + core/kazoo_convert/doc/fax_converter.md | 278 +++++++ core/kazoo_convert/include/kz_convert.hrl | 25 + core/kazoo_convert/src/gen_kz_converter.erl | 33 + core/kazoo_convert/src/kazoo_convert.app.src | 16 + core/kazoo_convert/src/kazoo_convert_app.erl | 28 + .../src/kazoo_convert_maintenance.erl | 134 ++++ core/kazoo_convert/src/kz_convert.erl | 56 ++ core/kazoo_convert/src/kz_fax_converter.erl | 477 ++++++++++++ core/kazoo_convert/src/kz_fax_converter.hrl | 90 +++ .../src/kz_openoffice_server.erl | 162 ++++ .../src/kz_openoffice_server_sup.erl | 66 ++ core/kazoo_convert/test/kz_convert_tests.erl | 702 ++++++++++++++++++ core/kazoo_documents/src/kzd_fax.erl | 442 +++++++++++ .../kazoo_fixturedb/priv/media_files/huge.pdf | Bin 0 -> 580049 bytes .../priv/media_files/invalid.docx | 1 + .../priv/media_files/invalid.pdf | Bin 0 -> 107 bytes .../priv/media_files/invalid.tiff | 1 + .../priv/media_files/legal.tiff | Bin 0 -> 106675 bytes .../priv/media_files/small.tiff | Bin 0 -> 1584274 bytes .../priv/media_files/valid-multipage.pdf | Bin 0 -> 126669 bytes .../priv/media_files/valid-multipage.tiff | Bin 0 -> 4407389 bytes .../priv/media_files/valid.docx | Bin 0 -> 287919 bytes .../priv/media_files/valid.odt | Bin 0 -> 88415 bytes .../priv/media_files/valid.pdf | Bin 0 -> 24883 bytes .../priv/media_files/valid.tiff | Bin 0 -> 15906 bytes core/kazoo_stdlib/src/kz_os.erl | 208 ++++++ core/kazoo_stdlib/test/kz_os_tests.erl | 70 ++ doc/mkdocs/mkdocs.yml | 3 + scripts/check-spelling.bash | 8 +- 52 files changed, 3341 insertions(+), 803 deletions(-) create mode 100644 core/kazoo_convert/Makefile create mode 100644 core/kazoo_convert/doc/README.md create mode 100644 core/kazoo_convert/doc/fax_converter.md create mode 100644 core/kazoo_convert/include/kz_convert.hrl create mode 100644 core/kazoo_convert/src/gen_kz_converter.erl create mode 100644 core/kazoo_convert/src/kazoo_convert.app.src create mode 100644 core/kazoo_convert/src/kazoo_convert_app.erl create mode 100644 core/kazoo_convert/src/kazoo_convert_maintenance.erl create mode 100644 core/kazoo_convert/src/kz_convert.erl create mode 100644 core/kazoo_convert/src/kz_fax_converter.erl create mode 100644 core/kazoo_convert/src/kz_fax_converter.hrl create mode 100644 core/kazoo_convert/src/kz_openoffice_server.erl create mode 100644 core/kazoo_convert/src/kz_openoffice_server_sup.erl create mode 100644 core/kazoo_convert/test/kz_convert_tests.erl create mode 100755 core/kazoo_fixturedb/priv/media_files/huge.pdf create mode 100644 core/kazoo_fixturedb/priv/media_files/invalid.docx create mode 100644 core/kazoo_fixturedb/priv/media_files/invalid.pdf create mode 100755 core/kazoo_fixturedb/priv/media_files/invalid.tiff create mode 100755 core/kazoo_fixturedb/priv/media_files/legal.tiff create mode 100644 core/kazoo_fixturedb/priv/media_files/small.tiff create mode 100755 core/kazoo_fixturedb/priv/media_files/valid-multipage.pdf create mode 100755 core/kazoo_fixturedb/priv/media_files/valid-multipage.tiff create mode 100755 core/kazoo_fixturedb/priv/media_files/valid.docx create mode 100755 core/kazoo_fixturedb/priv/media_files/valid.odt create mode 100755 core/kazoo_fixturedb/priv/media_files/valid.pdf create mode 100644 core/kazoo_fixturedb/priv/media_files/valid.tiff create mode 100644 core/kazoo_stdlib/src/kz_os.erl create mode 100644 core/kazoo_stdlib/test/kz_os_tests.erl diff --git a/.travis.yml b/.travis.yml index afa5c0d9516..3930bf5c178 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,9 @@ addons: apt: packages: - xsltproc + - libreoffice + - libtiff-tools + - ghostscript cache: apt: true diff --git a/applications/crossbar/priv/api/swagger.json b/applications/crossbar/priv/api/swagger.json index 779c763f1ef..e7791e4b503 100644 --- a/applications/crossbar/priv/api/swagger.json +++ b/applications/crossbar/priv/api/swagger.json @@ -29262,6 +29262,21 @@ "description": "fax smtp sessions", "type": "integer" }, + "store_fax_pdf": { + "default": true, + "description": "store the post processed fax document", + "type": "boolean" + }, + "store_fax_tiff": { + "default": true, + "description": "store a pdf copy of the post processed fax document", + "type": "boolean" + }, + "store_url_document": { + "default": true, + "description": "store the document url result in the database", + "type": "boolean" + }, "wait_for_fax_timeout_ms": { "default": 3600000, "description": "fax wait for fax timeout in milliseconds", @@ -29544,59 +29559,78 @@ "system_config.kazoo_convert": { "description": "Schema for kazoo_convert system_config", "properties": { - "convert_command_timeout": { - "default": 120000, - "description": "kazoo_convert convert_command_timeout", - "type": "integer" - }, - "convert_image_command": { - "default": "convert $FROM -resample 204x98 -units PixelsPerInch -compress group4 -size 1728x1078 $TO", - "description": "kazoo_convert convert_image_command", - "type": "string" - }, - "convert_openoffice_command": { - "default": "libreoffice --headless --convert-to pdf $FROM --outdir $WORKDIR 2>&1 |egrep 'parser error|Error' && exit 1 || exit 0", - "description": "kazoo_convert convert_openoffice_command", - "type": "string" - }, - "convert_pdf_command": { - "default": "/usr/bin/gs -q -r204x98 -g1728x1078 -dNOPAUSE -dBATCH -dSAFER -sDEVICE=tiffg4 -sOutputFile=$TO -- $FROM", - "description": "kazoo_convert convert_pdf_command", - "type": "string" - }, - "convert_tiff_command": { - "default": "tiff2pdf -o $TO $FROM", - "description": "kazoo_convert convert_tiff_command", - "type": "string" - }, - "enable_openoffice": { - "default": true, - "description": "kazoo_convert enable_openoffice", - "type": "boolean" + "fax": { + "properties": { + "attachment_format": { + "default": "pdf", + "description": "Format to use for receipt email messages and api responses", + "type": "string" + }, + "convert_command_timeout": { + "default": 120000, + "description": "Timeout value in ms for how long a convert command can run before being killed", + "type": "integer" + }, + "convert_image_command": { + "default": "convert $FROM -resample 204x98 -units PixelsPerInch -resize 1728x1078\\! -compress group4 $TO", + "description": "The command to resample a tiff file to a fax compatible format or convert a supported image/* format to a tiff", + "type": "string" + }, + "convert_openoffice_command": { + "default": "libreoffice --headless --convert-to pdf $FROM --outdir $WORKDIR 2>&1 |egrep 'parser error|Error' && exit 1 || exit 0", + "description": "The command to convert open office documents to pdf", + "type": "string" + }, + "convert_pdf_command": { + "default": "/usr/bin/gs -q -r204x98 -g1728x1078 -dNOPAUSE -dBATCH -dSAFER -sDEVICE=tiffg4 -sOutputFile=$TO -- $FROM", + "description": "The command to convert pdf documents to tiff", + "type": "string" + }, + "convert_tiff_command": { + "default": "tiff2pdf -o $TO $FROM", + "description": "The command to convert a tiff file to PDF", + "type": "string" + }, + "enable_openoffice": { + "default": true, + "description": "Enables the conversion of openoffice compatible documents", + "type": "boolean" + }, + "large_tiff_command": { + "default": "convert $FROM -resample 204x98 -units PixelsPerInch -resize 1728\\!x1078 -compress group4 $TO", + "description": "The command to convert large tiffs to standard dimensions", + "type": "string" + }, + "serialize_openoffice": { + "default": true, + "description": "Enable Serialization of openoffice compatible document conversions", + "type": "boolean" + }, + "small_tiff_command": { + "default": "convert $FROM -gravity center -resample 204x98 -units PixelsPerInch -extent 1728x1078 -compress group4 $TO", + "description": "The command to convert small tiffs to a fax compatible format", + "type": "string" + }, + "validate_pdf_command": { + "default": "gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FILE", + "description": "The command to verify a PDF file is valid", + "type": "string" + }, + "validate_tiff_command": { + "default": "tiffinfo $FILE", + "description": "The command to verify a TIFF file is valid", + "type": "string" + } + } }, "fax_converter": { "default": "fax_converter", - "description": "kazoo_convert fax_converter", + "description": "Module to use for fax related file conversions", "type": "string" }, "file_cache_path": { "default": "/tmp/", - "description": "kazoo_convert file_cache_path", - "type": "string" - }, - "serialize_openoffice": { - "default": true, - "description": "kazoo_convert serialize_openoffice", - "type": "boolean" - }, - "validate_pdf_command": { - "default": "gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FROM", - "description": "kazoo_convert validate_pdf_command", - "type": "string" - }, - "validate_tiff_command": { - "default": "tiff2pdf -o $TO $FROM", - "description": "kazoo_convert validate_tiff_command", + "description": "The default working directory to use when converting files", "type": "string" } }, diff --git a/applications/crossbar/priv/couchdb/schemas/system_config.fax.json b/applications/crossbar/priv/couchdb/schemas/system_config.fax.json index 7bde68d8184..c0cc505c9b6 100644 --- a/applications/crossbar/priv/couchdb/schemas/system_config.fax.json +++ b/applications/crossbar/priv/couchdb/schemas/system_config.fax.json @@ -211,6 +211,21 @@ "description": "fax smtp sessions", "type": "integer" }, + "store_fax_pdf": { + "default": true, + "description": "store the post processed fax document", + "type": "boolean" + }, + "store_fax_tiff": { + "default": true, + "description": "store a pdf copy of the post processed fax document", + "type": "boolean" + }, + "store_url_document": { + "default": true, + "description": "store the document url result in the database", + "type": "boolean" + }, "wait_for_fax_timeout_ms": { "default": 3600000, "description": "fax wait for fax timeout in milliseconds", diff --git a/applications/crossbar/priv/couchdb/schemas/system_config.kazoo_convert.json b/applications/crossbar/priv/couchdb/schemas/system_config.kazoo_convert.json index 94b6cf38f7c..47f64aa1dbe 100644 --- a/applications/crossbar/priv/couchdb/schemas/system_config.kazoo_convert.json +++ b/applications/crossbar/priv/couchdb/schemas/system_config.kazoo_convert.json @@ -3,59 +3,78 @@ "_id": "system_config.kazoo_convert", "description": "Schema for kazoo_convert system_config", "properties": { - "convert_command_timeout": { - "default": 120000, - "description": "kazoo_convert convert_command_timeout", - "type": "integer" - }, - "convert_image_command": { - "default": "convert $FROM -resample 204x98 -units PixelsPerInch -compress group4 -size 1728x1078 $TO", - "description": "kazoo_convert convert_image_command", - "type": "string" - }, - "convert_openoffice_command": { - "default": "libreoffice --headless --convert-to pdf $FROM --outdir $WORKDIR 2>&1 |egrep 'parser error|Error' && exit 1 || exit 0", - "description": "kazoo_convert convert_openoffice_command", - "type": "string" - }, - "convert_pdf_command": { - "default": "/usr/bin/gs -q -r204x98 -g1728x1078 -dNOPAUSE -dBATCH -dSAFER -sDEVICE=tiffg4 -sOutputFile=$TO -- $FROM", - "description": "kazoo_convert convert_pdf_command", - "type": "string" - }, - "convert_tiff_command": { - "default": "tiff2pdf -o $TO $FROM", - "description": "kazoo_convert convert_tiff_command", - "type": "string" - }, - "enable_openoffice": { - "default": true, - "description": "kazoo_convert enable_openoffice", - "type": "boolean" + "fax": { + "properties": { + "attachment_format": { + "default": "pdf", + "description": "Format to use for receipt email messages and api responses", + "type": "string" + }, + "convert_command_timeout": { + "default": 120000, + "description": "Timeout value in ms for how long a convert command can run before being killed", + "type": "integer" + }, + "convert_image_command": { + "default": "convert $FROM -resample 204x98 -units PixelsPerInch -resize 1728x1078\\! -compress group4 $TO", + "description": "The command to resample a tiff file to a fax compatible format or convert a supported image/* format to a tiff", + "type": "string" + }, + "convert_openoffice_command": { + "default": "libreoffice --headless --convert-to pdf $FROM --outdir $WORKDIR 2>&1 |egrep 'parser error|Error' && exit 1 || exit 0", + "description": "The command to convert open office documents to pdf", + "type": "string" + }, + "convert_pdf_command": { + "default": "/usr/bin/gs -q -r204x98 -g1728x1078 -dNOPAUSE -dBATCH -dSAFER -sDEVICE=tiffg4 -sOutputFile=$TO -- $FROM", + "description": "The command to convert pdf documents to tiff", + "type": "string" + }, + "convert_tiff_command": { + "default": "tiff2pdf -o $TO $FROM", + "description": "The command to convert a tiff file to PDF", + "type": "string" + }, + "enable_openoffice": { + "default": true, + "description": "Enables the conversion of openoffice compatible documents", + "type": "boolean" + }, + "large_tiff_command": { + "default": "convert $FROM -resample 204x98 -units PixelsPerInch -resize 1728\\!x1078 -compress group4 $TO", + "description": "The command to convert large tiffs to standard dimensions", + "type": "string" + }, + "serialize_openoffice": { + "default": true, + "description": "Enable Serialization of openoffice compatible document conversions", + "type": "boolean" + }, + "small_tiff_command": { + "default": "convert $FROM -gravity center -resample 204x98 -units PixelsPerInch -extent 1728x1078 -compress group4 $TO", + "description": "The command to convert small tiffs to a fax compatible format", + "type": "string" + }, + "validate_pdf_command": { + "default": "gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FILE", + "description": "The command to verify a PDF file is valid", + "type": "string" + }, + "validate_tiff_command": { + "default": "tiffinfo $FILE", + "description": "The command to verify a TIFF file is valid", + "type": "string" + } + } }, "fax_converter": { "default": "fax_converter", - "description": "kazoo_convert fax_converter", + "description": "Module to use for fax related file conversions", "type": "string" }, "file_cache_path": { "default": "/tmp/", - "description": "kazoo_convert file_cache_path", - "type": "string" - }, - "serialize_openoffice": { - "default": true, - "description": "kazoo_convert serialize_openoffice", - "type": "boolean" - }, - "validate_pdf_command": { - "default": "gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FROM", - "description": "kazoo_convert validate_pdf_command", - "type": "string" - }, - "validate_tiff_command": { - "default": "tiff2pdf -o $TO $FROM", - "description": "kazoo_convert validate_tiff_command", + "description": "The default working directory to use when converting files", "type": "string" } }, diff --git a/applications/crossbar/src/modules/cb_faxes.erl b/applications/crossbar/src/modules/cb_faxes.erl index c0a349c792f..d5a38f90567 100644 --- a/applications/crossbar/src/modules/cb_faxes.erl +++ b/applications/crossbar/src/modules/cb_faxes.erl @@ -52,6 +52,7 @@ -define(MOD_CONFIG_CAT, <<(?CONFIG_CAT)/binary, ".fax">>). -define(FAX_TYPE, <<"fax">>). +-define(CONVERT_CONFIG_CAT, <<"kazoo_convert">>). -define(SMTP_TYPE, <<"fax_smtp_log">>). -define(OUTBOX_ACTION_RESUBMIT, <<"resubmit">>). @@ -612,27 +613,40 @@ do_load_fax_binary(FaxId, Folder, Context) -> Context1 = load_fax_meta(FaxId, Folder, Context), case cb_context:resp_status(Context1) of 'success' -> - case kz_doc:attachment_names(cb_context:doc(Context1)) of - [] -> cb_context:add_system_error('bad_identifier', kz_json:from_list([{<<"cause">>, FaxId}]), Context1); - [AttachmentId|_] -> - set_fax_binary(Context1, AttachmentId) + Format = kapps_config:get_ne_binary(?CONVERT_CONFIG_CAT, [<<"fax">>, <<"attachment_format">>], <<"pdf">>), + case kzd_fax:fetch_attachment_format(Format, cb_context:account_db(Context1), cb_context:doc(Context1)) of + {'error', Error} -> + crossbar_doc:handle_datamgr_errors(Error, FaxId, Context1); + {'ok', Content, ContentType, Doc} -> + set_fax_binary(cb_context:set_doc(Context1, Doc), Content, ContentType, get_file_name(Doc, ContentType)) end; _Status -> Context1 end. --spec set_fax_binary(cb_context:context(), kz_term:ne_binary()) -> cb_context:context(). -set_fax_binary(Context, AttachmentId) -> +-spec set_fax_binary(cb_context:context(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> cb_context:context(). +set_fax_binary(Context, Content, ContentType, Filename) -> Disposition = cb_context:req_param(Context, <<"disposition">>, <<"attachment">>), - cb_context:setters(crossbar_doc:load_attachment(cb_context:doc(Context), AttachmentId, ?TYPE_CHECK_OPTION(<<"fax">>), Context) - ,[{fun cb_context:set_resp_etag/2, 'undefined'} - ,{fun cb_context:add_resp_headers/2 - ,#{<<"content-disposition">> => <> => kz_doc:attachment_content_type(cb_context:doc(Context), AttachmentId) + cb_context:setters(cb_context:setters(Context + ,[{fun cb_context:set_resp_data/2, Content} + ,{fun cb_context:set_resp_etag/2, crossbar_doc:rev_to_etag(cb_context:doc(Context))} + ] + ) + ,[{fun cb_context:add_resp_headers/2 + ,#{<<"content-disposition">> => <> => ContentType } } ] ). +-spec get_file_name(kz_json:object(), kz_term:ne_binary()) -> kz_term:ne_binary(). +get_file_name(Doc, ContentType) -> + Time = kz_json:get_integer_value(<<"pvt_created">>, Doc, 0), + Ext = kz_mime:to_extension(ContentType), + FName = list_to_binary([<<"fax_document_">>, kz_time:pretty_print_datetime(Time), ".", Ext]), + re:replace(kz_term:to_lower_binary(FName), <<"\\s+">>, <<"_">>, [{'return', 'binary'}, 'global']). + + %%------------------------------------------------------------------------------ %% @doc Attempt to load a summarized listing of all instances of this %% resource. @@ -661,39 +675,36 @@ normalize_modb_view_results(JObj, Acc) -> -spec maybe_save_attachment(cb_context:context()) -> cb_context:context(). maybe_save_attachment(Context) -> - maybe_save_attachment(Context, cb_context:req_files(Context)). - --spec maybe_save_attachment(cb_context:context(), req_files()) -> cb_context:context(). -maybe_save_attachment(Context, []) -> Context; -maybe_save_attachment(Context, [{Filename, FileJObj} | _Others]) -> - save_attachment(Context, Filename, FileJObj). - --spec save_attachment(cb_context:context(), binary(), kz_json:object()) -> cb_context:context(). -save_attachment(Context, Filename, FileJObj) -> JObj = cb_context:doc(Context), - DocId = kz_doc:id(JObj), - Contents = kz_json:get_value(<<"contents">>, FileJObj), - CT = kz_json:get_value([<<"headers">>, <<"content_type">>], FileJObj), - Opts = [{'content_type', CT} - ,{'rev', kz_doc:revision(JObj)} - | ?TYPE_CHECK_OPTION(<<"fax">>) - ], - set_pending(crossbar_doc:save_attachment(DocId - ,cb_modules_util:attachment_name(Filename, CT) - ,Contents - ,Context - ,Opts - ) - ,DocId - ). - --spec set_pending(cb_context:context(), binary()) -> cb_context:context(). -set_pending(Context, DocId) -> - Ctx1 = crossbar_doc:load(DocId, Context), - KVs = [{<<"pvt_job_status">>, <<"pending">>} - ,{<<"pvt_modified">>, kz_time:now_s()} - ], - crossbar_doc:save(cb_context:set_doc(Ctx1, kz_json:set_values(KVs, cb_context:doc(Ctx1)))). + case kz_json:get_value(<<"document">>, JObj) of + 'undefined' -> + save_multipart_attachment(Context, cb_context:req_files(Context)); + _Document -> + prepare_attachment(Context, JObj, 'undefined', 'undefined') + end. + +-spec save_multipart_attachment(cb_context:context(), req_files()) -> cb_context:context(). +save_multipart_attachment(Context, []) -> Context; +save_multipart_attachment(Context, [{_Filename, FileJObj} | _Others]) -> + Content = kz_json:get_value(<<"contents">>, FileJObj), + ContentType = kz_json:get_value([<<"headers">>, <<"content_type">>], FileJObj), + prepare_attachment(Context, cb_context:doc(Context), ContentType, Content). + +-spec prepare_attachment(cb_context:context() + ,kz_json:object() + ,kz_term:api_binary() + ,kz_term:api_binary()) -> cb_context:context(). +prepare_attachment(Context, Doc, ContentType, Content) -> + case kzd_fax:save_outbound_fax(?KZ_FAXES_DB, Doc, Content, ContentType) of + {'ok', NewDoc} -> + KVs = [{<<"pvt_job_status">>, <<"pending">>} + ,{<<"pvt_modified">>, kz_time:now_s()} + ], + crossbar_doc:save(cb_context:set_doc(Context, kz_json:set_values(KVs, NewDoc))); + {'error', Error} -> + lager:error("failed to process fax document with error: ~p", [Error]), + cb_context:add_system_error(<<"error processing fax file">>, Context) + end. -spec do_put_action(cb_context:context(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> cb_context:context(). do_put_action(Context, ?OUTBOX, ?OUTBOX_ACTION_RESUBMIT, Id) -> diff --git a/applications/fax/priv/couchdb/views/faxes.json b/applications/fax/priv/couchdb/views/faxes.json index ce008717e60..366d55f1a04 100644 --- a/applications/fax/priv/couchdb/views/faxes.json +++ b/applications/fax/priv/couchdb/views/faxes.json @@ -3,37 +3,180 @@ "language": "javascript", "views": { "crossbar_listing": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted) return; emit(doc._id, {'modified' : doc.pvt_modified} ); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted)", + " return;", + " emit(doc._id, {", + " 'modified': doc.pvt_modified", + " });", + "}" + ] }, "jobs": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || (doc.pvt_job_status != 'pending' && doc.pvt_job_status != 'locked' )) return; key = doc.pvt_modified + (doc.retry_after != null ? doc.retry_after : 0); emit(key, {'id' : doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'to': doc.to_number, 'from': doc.from_number, 'modified' : doc.pvt_modified, 'retry_after' : doc.retry_after, 'tries' : doc.attempts+'/'+doc.retries }); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || (doc.pvt_job_status != 'pending' && doc.pvt_job_status != 'locked' ))", + " return;", + " key = doc.pvt_modified + (doc.retry_after != null ? doc.retry_after : 0);", + " emit(key, {", + " 'id' : doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'modified': doc.pvt_modified,", + " 'retry_after': doc.retry_after,", + " 'tries' : doc.attempts+'/'+doc.retries ", + " });", + "}" + ] }, "jobs_by_account": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || doc.pvt_job_status != 'pending') return; key = doc.pvt_modified + (doc.retry_after != null ? doc.retry_after : 0); emit([doc.pvt_account_id, key], {'id' : doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'to': doc.to_number, 'from': doc.from_number, 'modified' : doc.pvt_modified, 'retry_after' : doc.retry_after }); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || doc.pvt_job_status != 'pending')", + " return;", + " key = doc.pvt_modified + (doc.retry_after != null ? doc.retry_after : 0);", + " emit([doc.pvt_account_id, key], {", + " 'id': doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'modified': doc.pvt_modified,", + " 'retry_after': doc.retry_after", + " });", + "}" + ] }, "list_by_account": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted) return; emit([doc.pvt_account_id, doc.pvt_created], {'id': doc._id, 'faxbox_id': doc.faxbox_id, 'status': doc.pvt_job_status, 'to': doc.to_number, 'from': doc.from_number, 'attempts': doc.attempts, 'created': doc.pvt_created}); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted)", + " return;", + " emit([doc.pvt_account_id, doc.pvt_created], {", + " 'id': doc._id,", + " 'faxbox_id': doc.faxbox_id,", + " 'status': doc.pvt_job_status,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'attempts': doc.attempts,", + " 'created': doc.pvt_created", + " });", + "}" + ] }, "list_by_account_state": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted) return; emit([doc.pvt_account_id, doc.pvt_job_status, doc.pvt_created], {'id': doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'status': doc.pvt_job_status, 'to': doc.to_number, 'from': doc.from_number, 'created': doc.pvt_created, 'modified' : doc.pvt_modified}); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted)", + " return;", + " emit([doc.pvt_account_id, doc.pvt_job_status, doc.pvt_created], {", + " 'id': doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'status': doc.pvt_job_status,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'created': doc.pvt_created,", + " 'modified': doc.pvt_modified", + " });", + "}" + ] }, "list_by_faxbox": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.faxbox_id) return; emit([doc.faxbox_id, doc.pvt_created], {'id': doc._id, 'faxbox_id': doc.faxbox_id, 'status': doc.pvt_job_status, 'to': doc.to_number, 'from': doc.from_number, 'attempts': doc.attempts, 'created': doc.pvt_created}); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.faxbox_id)", + " return;", + " emit([doc.faxbox_id, doc.pvt_created], {", + " 'id': doc._id,", + " 'faxbox_id': doc.faxbox_id,", + " 'status': doc.pvt_job_status,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'attempts': doc.attempts,", + " 'created': doc.pvt_created", + " });", + "}" + ] }, "list_by_faxbox_state": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.faxbox_id) return; emit([doc.faxbox_id, doc.pvt_job_status, doc.pvt_created], {'id': doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'status': doc.pvt_job_status, 'to': doc.to_number, 'from': doc.from_number, 'created': doc.pvt_created, 'modified' : doc.pvt_modified}); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.faxbox_id)", + " return;", + " emit([doc.faxbox_id, doc.pvt_job_status, doc.pvt_created], {", + " 'id': doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'status': doc.pvt_job_status,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'created': doc.pvt_created,", + " 'modified' : doc.pvt_modified", + " });", + "}" + ] }, "list_by_owner": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.owner_id) return; emit([doc.owner_id, doc.pvt_created], {'id': doc._id, 'status': doc.pvt_job_status, 'to': doc.to_number, 'from': doc.from_number, 'created': doc.pvt_created}); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || !doc.owner_id)", + " return;", + " emit([doc.owner_id, doc.pvt_created], {", + " 'id': doc._id,", + " 'status': doc.pvt_job_status,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'created': doc.pvt_created", + " });", + "}" + ] }, "locked_jobs_by_account": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || doc.pvt_job_status != 'locked') return; emit(doc.pvt_account_id, {'id' : doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'to': doc.to_number, 'from': doc.from_number, 'modified' : doc.pvt_modified, 'retry_after' : doc.retry_after }); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || doc.pvt_job_status != 'locked')", + " return;", + " emit(doc.pvt_account_id, {", + " 'id': doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'modified': doc.pvt_modified,", + " 'retry_after': doc.retry_after", + " });", + "}" + ] }, "processing_by_node": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_job_status != 'processing' || doc.pvt_deleted) return; emit(doc.pvt_job_node, {'node' : doc.pvt_job_node, 'id' : doc._id, 'account_id' : doc.pvt_account_id, 'faxbox_id' : doc.faxbox_id, 'to': doc.to_number, 'from': doc.from_number, 'modified' : doc.pvt_modified} ); }" + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_job_status != 'processing' || doc.pvt_deleted)", + " return;", + " emit(doc.pvt_job_node, {", + " 'node': doc.pvt_job_node,", + " 'id': doc._id,", + " 'account_id': doc.pvt_account_id,", + " 'faxbox_id': doc.faxbox_id,", + " 'to': doc.to_number,", + " 'from': doc.from_number,", + " 'modified': doc.pvt_modified", + " });", + "}" + ] }, "schedule_accounts": { - "map": "function(doc) { if (doc.pvt_type != 'fax' || doc.pvt_deleted || (doc.pvt_job_status != 'pending' && doc.pvt_job_status != 'locked')) return; emit(doc.pvt_account_id, 1); }", + "map": [ + "function(doc) {", + " if (doc.pvt_type != 'fax' || doc.pvt_deleted || (doc.pvt_job_status != 'pending' && doc.pvt_job_status != 'locked'))", + " return;", + " emit(doc.pvt_account_id, 1);", + "}" + ], "reduce": "_sum" } } diff --git a/applications/fax/src/fax.app.src b/applications/fax/src/fax.app.src index 03272954ff0..d1c1f48d258 100644 --- a/applications/fax/src/fax.app.src +++ b/applications/fax/src/fax.app.src @@ -1,11 +1,10 @@ {application,fax, - [{applications,[cowboy,crypto,escalus,exml,gen_smtp,kazoo, - kazoo_amqp,kazoo_apps,kazoo_caches,kazoo_call, - kazoo_config,kazoo_data,kazoo_documents, - kazoo_globals,kazoo_media,kazoo_modb, - kazoo_number_manager,kazoo_oauth,kazoo_schemas, - kazoo_services,kazoo_stdlib,kazoo_web,kernel, - lager,stdlib]}, + [{applications,[cowboy,escalus,exml,gen_smtp,kazoo,kazoo_amqp, + kazoo_apps,kazoo_caches,kazoo_call,kazoo_config, + kazoo_data,kazoo_documents,kazoo_globals, + kazoo_media,kazoo_modb,kazoo_number_manager, + kazoo_oauth,kazoo_schemas,kazoo_services, + kazoo_stdlib,kazoo_web,kernel,lager,stdlib]}, {description,"Fax - Why is everyone faxinated with fax?"}, {env,[{is_kazoo_app,true}]}, {mod,{fax_app,[]}}, diff --git a/applications/fax/src/fax.hrl b/applications/fax/src/fax.hrl index e4da7f1dfbc..08c74c70c52 100644 --- a/applications/fax/src/fax.hrl +++ b/applications/fax/src/fax.hrl @@ -50,11 +50,6 @@ -define(OPENXML_MIME_PREFIX, "application/vnd.openxmlformats-officedocument."). -define(OPENOFFICE_MIME_PREFIX, "application/vnd.oasis.opendocument."). --define(OPENOFFICE_COMPATIBLE(CT) - ,(CT =:= <<"application/msword">> - orelse CT =:= <<"application/vnd.ms-excel">> - orelse CT =:= <<"application/vnd.ms-powerpoint">> - )). -define(DEFAULT_ALLOWED_CONTENT_TYPES, [<<"application/pdf">> ,<<"image/tiff">> @@ -74,46 +69,13 @@ -define(SMTP_CALLBACK_OPTIONS, {'callbackoptions', ['extensions', ?SMTP_EXTENSIONS]}). -define(SMTP_PORT, kapps_config:get_integer(?CONFIG_CAT, <<"smtp_port">>, 19025)). --define(FAX_EXTENSION, <<"tiff">>). -define(FAX_OUTBOUND_SERVER(AccountId), <<"fax_outbound_", AccountId/binary>>). -define(PORT, kapps_config:get_integer(?CONFIG_CAT, <<"port">>, 30950)). --define(DEFAULT_CONVERT_PDF_CMD - ,<<"/usr/bin/gs -q " - "-r204x98 " - "-g1728x1078 " - "-dNOPAUSE " - "-dBATCH " - "-dSAFER " - "-sDEVICE=tiffg3 " - "-sOutputFile=~s -- ~s > /dev/null 2>&1" - "&& echo -n success" - >>). --define(CONVERT_IMAGE_CMD, <<"convert -density 204x98 " - "-units PixelsPerInch " - "-size 1728x1078 ~s ~s > /dev/null 2>&1" - "&& echo -n success" - >>). --define(CONVERT_OO_DOC_CMD, <<"unoconv -c ~s -f pdf --stdout ~s " - "| /usr/bin/gs -q " - "-r204x98 " - "-g1728x1078 " - "-dNOPAUSE " - "-dBATCH " - "-dSAFER " - "-sDEVICE=tiffg3 " - "-sOutputFile=~s - > /dev/null 2>&1" - "&& echo -n success" - >>). - --define(CONVERT_IMAGE_COMMAND - ,kapps_config:get_binary(?CONFIG_CAT, <<"conversion_image_command">>, ?CONVERT_IMAGE_CMD)). --define(CONVERT_OO_COMMAND - ,kapps_config:get_binary(?CONFIG_CAT, <<"conversion_openoffice_document_command">>, ?CONVERT_OO_DOC_CMD)). --define(CONVERT_PDF_COMMAND - ,kapps_config:get_binary(?CONFIG_CAT, <<"conversion_pdf_command">>, ?DEFAULT_CONVERT_PDF_CMD)). +-define(TMP_DIR + ,kapps_config:get_binary(?CONFIG_CAT, <<"file_cache_path">>, <<"/tmp/">>)). -define(FAX_HRL, 'true'). -endif. diff --git a/applications/fax/src/fax_cloud.erl b/applications/fax/src/fax_cloud.erl index 7d4a7377831..ae17cfa2e9b 100644 --- a/applications/fax/src/fax_cloud.erl +++ b/applications/fax/src/fax_cloud.erl @@ -237,7 +237,7 @@ maybe_save_fax_attachment(JObj, JobId, PrinterId, FileURL ) -> {'ok', Authorization} -> case download_file(FileURL,Authorization) of {'ok', CT, FileContents} -> - case fax_util:save_fax_attachment(JObj, FileContents, CT) of + case kzd_fax:save_outbound_fax(?KZ_FAXES_DB, JObj, FileContents, CT) of {'ok', _} -> update_job_status(PrinterId, JobId, <<"IN_PROGRESS">>); {'error', E} -> lager:debug("error saving attachment for JobId ~s : ~p",[JobId, E]) diff --git a/applications/fax/src/fax_init.erl b/applications/fax/src/fax_init.erl index 83aa43759dc..7e96eefd491 100644 --- a/applications/fax/src/fax_init.erl +++ b/applications/fax/src/fax_init.erl @@ -29,6 +29,7 @@ start_link() -> ] ,#{'env' => #{'dispatch' => Dispatch}} ), + fax_maintenance:refresh_views(), 'ignore'. %%------------------------------------------------------------------------------ diff --git a/applications/fax/src/fax_jobs.erl b/applications/fax/src/fax_jobs.erl index eb386bbc925..695401c0d34 100644 --- a/applications/fax/src/fax_jobs.erl +++ b/applications/fax/src/fax_jobs.erl @@ -452,7 +452,7 @@ handle_error(JobId, AccountId, JObj, #{pending := Pending} = Jobs) -> maybe_serialize(Status, Stage, JobId, AccountId, Number, Job, Jobs) end. --spec maybe_serialize(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object(), map()) -> kz_json:objects(). +-spec maybe_serialize(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object(), map()) -> map(). maybe_serialize(<<"not_found">> = Status, <<"acquire">> = Stage, JobId, AccountId, Number, _Job, Jobs) -> lager:debug("dropping job ~s/~s from cache in stage ~s : ~s", [AccountId, JobId, Stage, Status]), #{pending := Pending diff --git a/applications/fax/src/fax_maintenance.erl b/applications/fax/src/fax_maintenance.erl index a27952272f3..bb41ae90b6a 100644 --- a/applications/fax/src/fax_maintenance.erl +++ b/applications/fax/src/fax_maintenance.erl @@ -14,6 +14,7 @@ -export([migrate/0, migrate/1, migrate/2]). -export([migrate_outbound_faxes/0, migrate_outbound_faxes/1]). +-export([refresh_views/0]). -export([flush/0]). -export([restart_job/1 , update_job/2]). @@ -21,7 +22,6 @@ -export([faxbox_jobs/1, faxbox_jobs/2]). -export([pending_jobs/0, active_jobs/0]). -export([load_smtp_attachment/2]). --export([versions_in_use/0]). -define(DEFAULT_MIGRATE_OPTIONS, [{'allow_old_modb_creation', 'true'}]). -define(OVERRIDE_DOCS, ['override_existing_document' @@ -140,7 +140,6 @@ recover_private_media(AccountDb, Doc, _MediaType) -> {'ok', _ } = kz_datamgr:ensure_saved(AccountDb, kz_doc:set_type(Doc, <<"private_media">>)), 'ok'. - -spec migrate_faxes_to_modb(kz_term:ne_binary(), kz_term:proplist()) -> 'ok'. migrate_faxes_to_modb(Account, Options) -> AccountDb = case kz_datamgr:db_exists(Account) of @@ -194,6 +193,17 @@ migrate_fax_to_modb(AccountDb, DocId, JObj, Options) -> {'error', Error} -> io:format("error ~p moving document ~s to ~s~n",[Error, DocId, FaxId]) end. + +%%------------------------------------------------------------------------------ +%% @doc Ensures that the views are updated to enforce the media format migration. +%% @end +%%------------------------------------------------------------------------------ +-spec refresh_views() -> 'ok'. +refresh_views() -> + Views = kapps_util:get_views_json('fax', "views"), + _ = kapps_util:update_views(?KZ_FAXES_DB, Views, 'true'), + 'ok'. + %%------------------------------------------------------------------------------ %% @doc Flush the fax local cache %% @end @@ -434,65 +444,9 @@ load_smtp_attachment(DocId, Filename, FileContents) -> CT = kz_mime:from_filename(Filename), case kz_datamgr:open_cache_doc(?KZ_FAXES_DB, DocId) of {'ok', JObj} -> - case fax_util:save_fax_attachment(JObj, FileContents, CT) of + case kzd_fax:save_outbound_fax(?KZ_FAXES_DB, JObj, FileContents, CT) of {'ok', _Doc} -> io:format("attachment ~s for docid ~s recovered~n", [Filename, DocId]); {'error', E} -> io:format("error attaching ~s to docid ~s : ~p~n", [Filename, DocId, E]) end; {'error', E} -> io:format("error opening docid ~s for attaching ~s : ~p~n", [DocId, Filename, E]) end. - --spec versions_in_use() -> no_return. -versions_in_use() -> - AllCmds = - [?CONVERT_IMAGE_COMMAND - ,?CONVERT_OO_COMMAND - ,?CONVERT_PDF_COMMAND - ], - Executables = find_commands(AllCmds), - lists:foreach(fun print_cmd_version/1, Executables), - no_return. - -print_cmd_version(Exe) -> - Options = [exit_status - ,use_stdio - ,stderr_to_stdout - ,{args, ["--version"]} - ], - Port = open_port({spawn_executable, Exe}, Options), - listen_to_port(Port, Exe). - -listen_to_port(Port, Exe) -> - receive - {Port, {data, Str0}} -> - [Str|_] = string:tokens(Str0, "\n"), - io:format("* ~s:\n\t~s\n", [Exe, Str]), - lager:debug("version for ~s: ~s", [Exe, Str]); - {Port, {exit_status, 0}} -> ok; - {Port, {exit_status, _}} -> no_executable(Exe) - end. - -find_commands(Cmds) -> - Commands = - lists:usort( - [binary_to_list(hd(binary:split(Cmd, <<$\s>>))) - || Cmd <- Cmds - ]), - lists:usort( - [Exe - || Cmd <- Commands, - Exe <- [cmd_to_executable(Cmd)], - Exe =/= false - ]). - -no_executable(Exe) -> - io:format("* ~s:\n\tERROR! missing executable\n", [Exe]), - lager:error("missing executable: ~s", [Exe]). - -cmd_to_executable("/"++_=Exe) -> Exe; -cmd_to_executable(Cmd) -> - case os:find_executable(Cmd) of - false -> - no_executable(Cmd), - false; - Exe -> Exe - end. diff --git a/applications/fax/src/fax_smtp.erl b/applications/fax/src/fax_smtp.erl index e9de55bd550..f90e9e88c91 100644 --- a/applications/fax/src/fax_smtp.erl +++ b/applications/fax/src/fax_smtp.erl @@ -257,18 +257,30 @@ handle_message(#state{filename=Filename ,errors=[] }=State) -> lager:debug("checking file ~s", [Filename]), + ContentType = kz_mime:from_filename(Filename), case file:read_file(Filename) of - {'ok', FileContents} -> - CT = kz_mime:from_filename(Filename), - case fax_util:save_fax_docs([Doc], FileContents, CT) of - 'ok' -> - lager:debug("smtp fax document saved"), - kz_util:delete_file(Filename); - {'error', Error} -> maybe_faxbox_log(State#state{errors=[Error]}) + {'ok', Content} -> + case kzd_fax:save_outbound_fax(?KZ_FAXES_DB, Doc, Content, ContentType) of + {'ok', NewDoc} -> + Updates = [{<<"pvt_job_status">>, <<"pending">>} + ,{<<"pvt_modified">>, kz_time:now_s()} + ], + case kz_datamgr:save_doc(?KZ_FAXES_DB, kz_json:set_values(Updates, NewDoc)) of + {'ok', NewerDoc} -> + lager:debug("fax jobid ~s set to pending", [kz_doc:id(NewerDoc)]); + {'error', Error} -> + lager:debug("error ~p setting fax jobid ~s to pending",[Error, kz_doc:id(NewDoc)]), + maybe_faxbox_log(State#state{errors=[Error]}) + end; + {'error', Error} -> + lager:error("failed converting attachment with error: ~p", [Error]), + Message = kz_term:to_binary(io_lib:format("error converting attachment ~s", [Filename])), + maybe_faxbox_log(State#state{errors=[Message]}) end; - _Else -> - Error = kz_term:to_binary(io_lib:format("error reading attachment ~s", [Filename])), - maybe_faxbox_log(State#state{errors=[Error]}) + {'error', Error} -> + lager:error("failed to read file: ~s with error: ~p", [Filename, Error]), + Message = kz_term:to_binary(io_lib:format("error reading file ~s", [Filename])), + maybe_faxbox_log(State#state{errors=[Message]}) end. -spec maybe_system_report(state()) -> 'ok'. @@ -629,7 +641,7 @@ maybe_faxbox_by_rules([], #state{account_id=AccountId ,from=From ,errors=Errors }=State) -> - Error = <<"no mathing rules in account ", AccountId/binary, " for ", From/binary >>, + Error = <<"no matching rules in account ", AccountId/binary, " for ", From/binary >>, lager:debug(Error), State#state{errors=[Error | Errors]}; maybe_faxbox_by_rules([JObj | JObjs], #state{from=From}=State) -> @@ -745,7 +757,7 @@ process_parts([{Type, SubType, _Headers, Parameters, BodyPart} |Parts ], State) -> {_ , NewState} - = maybe_process_part(fax_util:normalize_content_type(<>) + = maybe_process_part(kz_mime:normalize_content_type(<>) ,Parameters ,BodyPart ,State diff --git a/applications/fax/src/fax_util.erl b/applications/fax/src/fax_util.erl index 8712dd3d67a..5be5605dc51 100644 --- a/applications/fax/src/fax_util.erl +++ b/applications/fax/src/fax_util.erl @@ -8,11 +8,9 @@ -export([fax_properties/1]). -export([collect_channel_props/1]). --export([save_fax_docs/3, save_fax_attachment/3]). -export([notify_email_list/3]). -export([filter_numbers/1]). -export([is_valid_caller_id/2]). --export([normalize_content_type/1]). -include("fax.hrl"). @@ -47,120 +45,6 @@ collect_channel_prop(<<"Hangup-Code">> = Key, JObj) -> collect_channel_prop(Key, JObj) -> {Key, kz_json:get_value(Key, JObj)}. -%%------------------------------------------------------------------------------ -%% @doc Generate an attachment name if one is not provided and ensure -%% it has an extension (for the associated content type) -%% @end -%%------------------------------------------------------------------------------ --spec attachment_name(binary(), kz_term:ne_binary()) -> kz_term:ne_binary(). -attachment_name(Filename, CT) -> - Generators = [fun maybe_generate_random_filename/1 - ,fun(A) -> maybe_attach_extension(A, CT) end - ], - lists:foldl(fun(F, A) -> F(A) end, Filename, Generators). - --spec maybe_generate_random_filename(binary()) -> kz_term:ne_binary(). -maybe_generate_random_filename(A) -> - case kz_term:is_empty(A) of - 'true' -> kz_term:to_hex_binary(crypto:strong_rand_bytes(16)); - 'false' -> A - end. - --spec maybe_attach_extension(kz_term:ne_binary(), kz_term:ne_binary()) -> kz_term:ne_binary(). -maybe_attach_extension(A, CT) -> - case kz_term:is_empty(filename:extension(A)) of - 'false' -> A; - 'true' -> <> - end. - --spec save_fax_docs(kz_json:objects(), binary(), kz_term:ne_binary()) -> - 'ok' | - {'error', any()}. -save_fax_docs([], _FileContents, _CT) -> 'ok'; -save_fax_docs([Doc|Docs], FileContents, CT) -> - case kz_datamgr:save_doc(?KZ_FAXES_DB, Doc) of - {'ok', JObj} -> - case save_fax_attachment(JObj, FileContents, CT) of - {'ok', _} -> save_fax_docs(Docs, FileContents, CT); - Error -> Error - end; - Else -> Else - end. - --spec save_fax_attachment(kz_term:api_object(), binary(), kz_term:ne_binary())-> - {'ok', kz_json:object()} | - {'error', kz_term:ne_binary()}. -save_fax_attachment(JObj, FileContents, CT) -> - MaxStorageRetry = kapps_config:get_integer(?CONFIG_CAT, <<"max_storage_retry">>, 5), - ContentsMD5 = kz_term:to_hex_binary(erlang:md5(FileContents)), - Name = attachment_name(ContentsMD5, CT), - - save_fax_attachment(JObj, FileContents, CT, Name, MaxStorageRetry). - --spec save_fax_attachment(kz_term:api_object(), binary(), kz_term:ne_binary(), kz_term:ne_binary(), non_neg_integer())-> - {'ok', kz_json:object()} | - {'error', kz_term:ne_binary()}. -save_fax_attachment(JObj, _FileContents, _CT, _Name, 0) -> - lager:error("max retry saving attachment ~s on fax id ~s rev ~s" - ,[_Name, kz_doc:id(JObj), kz_doc:revision(JObj)] - ), - {'error', <<"max retry saving attachment">>}; -save_fax_attachment(JObj, FileContents, CT, Name, Count) -> - DocId = kz_doc:id(JObj), - _ = attempt_save(JObj, FileContents, CT, Name), - case check_fax_attachment(DocId, Name) of - {'ok', J} -> save_fax_doc_completed(J); - {'missing', J} -> - lager:warning("missing fax attachment on fax id ~s",[DocId]), - timer:sleep(?RETRY_SAVE_ATTACHMENT_DELAY), - save_fax_attachment(J, FileContents, CT, Name, Count-1); - {'error', _R} -> - lager:debug("error '~p' saving fax attachment on fax id ~s",[_R, DocId]), - timer:sleep(?RETRY_SAVE_ATTACHMENT_DELAY), - {'ok', J} = kz_datamgr:open_doc(?KZ_FAXES_DB, DocId), - save_fax_attachment(J, FileContents, CT, Name, Count-1) - end. - --spec attempt_save(kz_json:object(), binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> - {'ok', kz_json:objcet()} | - kz_datamgr:data_error(). -attempt_save(JObj, FileContents, CT, Name) -> - Opts = [{'content_type', CT} - ], - - kz_datamgr:put_attachment(?KZ_FAXES_DB, kz_doc:id(JObj), Name, FileContents, Opts). - --spec check_fax_attachment(kz_term:ne_binary(), kz_term:ne_binary())-> - {'ok', kz_json:object()} | - {'missing', kz_json:object()} | - {'error', any()}. -check_fax_attachment(DocId, Name) -> - case kz_datamgr:open_doc(?KZ_FAXES_DB, DocId) of - {'ok', JObj} -> - case kz_doc:attachment(JObj, Name) of - 'undefined' -> {'missing', JObj}; - _Else -> {'ok', JObj} - end; - {'error', _}=E -> E - end. - --spec save_fax_doc_completed(kz_json:object())-> - {'ok', kz_json:object()} | - {'error', any()}. -save_fax_doc_completed(JObj)-> - DocId = kz_doc:id(JObj), - Updates = [{<<"pvt_job_status">>, <<"pending">>} - ,{<<"pvt_modified">>, kz_time:now_s()} - ], - case kz_datamgr:save_doc(?KZ_FAXES_DB, kz_json:set_values(Updates, JObj)) of - {'ok', Doc} -> - lager:debug("fax jobid ~s set to pending", [DocId]), - {'ok', Doc}; - {'error', E} -> - lager:debug("error ~p setting fax jobid ~s to pending",[E, DocId]), - {'error', E} - end. - -spec notify_email_list(kz_term:api_binary(), kz_term:api_binary(), kz_term:ne_binary() | list()) -> list(). notify_email_list(From, OwnerEmail, Email) when is_binary(Email) -> notify_email_list(From, OwnerEmail, [Email]); @@ -191,20 +75,3 @@ is_digit(N) when is_integer(N), N >= $0, N =< $9 -> true; is_digit(_) -> false. - --spec normalize_content_type(kz_term:text()) -> kz_term:ne_binary(). -normalize_content_type(<<"image/tif">>) -> <<"image/tiff">>; -normalize_content_type(<<"image/x-tif">>) -> <<"image/tiff">>; -normalize_content_type(<<"image/tiff">>) -> <<"image/tiff">>; -normalize_content_type(<<"image/x-tiff">>) -> <<"image/tiff">>; -normalize_content_type(<<"application/tif">>) -> <<"image/tiff">>; -normalize_content_type(<<"apppliction/x-tif">>) -> <<"image/tiff">>; -normalize_content_type(<<"apppliction/tiff">>) -> <<"image/tiff">>; -normalize_content_type(<<"apppliction/x-tiff">>) -> <<"image/tiff">>; -normalize_content_type(<<"application/pdf">>) -> <<"application/pdf">>; -normalize_content_type(<<"application/x-pdf">>) -> <<"application/pdf">>; -normalize_content_type(<<"text/pdf">>) -> <<"application/pdf">>; -normalize_content_type(<<"text/x-pdf">>) -> <<"application/pdf">>; -normalize_content_type(<<_/binary>> = Else) -> Else; -normalize_content_type(CT) -> - normalize_content_type(kz_term:to_binary(CT)). diff --git a/applications/fax/src/fax_worker.erl b/applications/fax/src/fax_worker.erl index 0af7b741d5a..7222ac6aa3d 100644 --- a/applications/fax/src/fax_worker.erl +++ b/applications/fax/src/fax_worker.erl @@ -84,7 +84,6 @@ -define(DEFAULT_RETRY_COUNT, kapps_config:get_integer(?CONFIG_CAT, <<"default_retry_count">>, 3)). -define(DEFAULT_COMPARE_FIELD, kapps_config:get_binary(?CONFIG_CAT, <<"default_compare_field">>, <<"result_cause">>)). --define(COUNT_PAGES_CMD, <<"echo -n `tiffinfo ~s | grep 'Page Number' | grep -c 'P'`">>). -define(CALLFLOW_LIST, <<"callflows/listing_by_number">>). -define(ENSURE_CID_KEY, <<"ensure_valid_caller_id">>). @@ -229,6 +228,7 @@ handle_cast({'fax_status', <<"pageresult">>, JobId, JObj} handle_cast({'fax_status', <<"result">>, JobId, JObj} ,#state{job_id=JobId ,job=Job + ,file=Filepath }=State ) -> Data = kz_call_event:application_data(JObj), @@ -240,6 +240,7 @@ handle_cast({'fax_status', <<"result">>, JobId, JObj} send_status(State, <<"Error sending fax">>, ?FAX_ERROR, Data), release_failed_job('fax_result', JObj, Job) end, + _ = file:delete(Filepath), gen_server:cast(self(), 'stop'), {'noreply', State#state{job=Doc, resp = Resp}}; handle_cast({'fax_status', Event, JobId, _}, State) -> @@ -288,52 +289,20 @@ handle_cast('prepare_job', #state{job_id=JobId ,job=JObj }=State) -> send_status(State, <<"fetching document to send">>, ?FAX_PREPARE, 'undefined'), - case fetch_document(JObj) of - {'ok', 200, RespHeaders, RespContent} -> - send_status(State, <<"preparing document to send">>, ?FAX_PREPARE, 'undefined'), - case prepare_contents(JobId, RespHeaders, RespContent) of - {'error', Cause} -> - send_error_status(State, Cause), - {Resp, Doc} = release_failed_job('bad_file', Cause, JObj), - gen_server:cast(self(), 'stop'), - {'noreply', State#state{job=Doc, resp = Resp}}; - {'ok', OutputFile} -> - gen_server:cast(self(), 'count_pages'), - {'noreply', State#state{file=OutputFile}} - end; - {'ok', Status, _, _} -> - lager:debug("failed to fetch file for job: http response ~p", [Status]), - _ = send_error_status(State, integer_to_binary(Status)), - {Resp, Doc} = release_failed_job('fetch_failed', Status, JObj), + case write_document(JObj, JobId) of + {'ok', Filepath, Doc} -> + send_status(State, <<"prepared document for send">>, ?FAX_PREPARE, 'undefined'), + gen_server:cast(self(), 'send'), + {'noreply', State#state{job=Doc + ,file=Filepath + ,pages=kz_json:get_integer_value(<<"pvt_pages">>, Doc) + }}; + {'error', Message} -> + send_error_status(State, kz_term:to_binary(Message)), + {Resp, Doc} = release_failed_job('bad_file', Message, JObj), gen_server:cast(self(), 'stop'), - {'noreply', State#state{job=Doc, resp = Resp}}; - {'error', Reason} -> - lager:debug("failed to fetch file for job: ~p", [Reason]), - send_error_status(State, <<"failed to fetch file for job">>), - {Resp, Doc} = release_failed_job('fetch_error', Reason, JObj), - gen_server:cast(self(), 'stop'), - {'noreply', State#state{job=Doc, resp = Resp}} + {'noreply', State#state{job=Doc, resp=Resp}} end; -handle_cast('count_pages', #state{file=File - ,job=JObj - }=State) -> - {NumberOfPages, FileSize} = get_sizes(File), - Values = [{<<"pvt_pages">>, NumberOfPages} - ,{<<"pvt_size">>, FileSize} - ], - NewState = case NumberOfPages of - Num when Num == 0 -> - State#state{job = kz_json:set_values(Values, JObj) - ,pages = Num - ,status = <<"unknown">> - }; - _ -> - State#state{job=kz_json:set_values(Values, JObj) - ,pages=NumberOfPages - } - end, - gen_server:cast(self(), 'send'), - {'noreply', NewState}; handle_cast('send', #state{job_id=JobId ,job=JObj ,queue_name=Q @@ -483,19 +452,6 @@ attempt_to_acquire_job(JObj, _Q, Status) -> {'error', 'job_not_available'}. -spec release_failed_job(atom(), any(), kz_json:object()) -> release_ret(). -release_failed_job('fetch_failed', Status, JObj) -> - Msg = <<"could not retrieve file, http response ~p", (integer_to_binary(Status))/binary>>, - Result = [{<<"success">>, 'false'} - ,{<<"result_code">>, 0} - ,{<<"result_text">>, Msg} - ,{<<"pages_sent">>, 0} - ,{<<"time_elapsed">>, elapsed_time(JObj)} - ,{<<"fax_bad_rows">>, 0} - ,{<<"fax_speed">>, 0} - ,{<<"fax_receiver_id">>, <<>>} - ,{<<"fax_error_correction">>, 'false'} - ], - release_job(Result, JObj); release_failed_job('bad_file', Msg, JObj) -> Result = [{<<"success">>, 'false'} ,{<<"result_code">>, 0} @@ -508,44 +464,6 @@ release_failed_job('bad_file', Msg, JObj) -> ,{<<"fax_error_correction">>, 'false'} ], release_job(Result, JObj); -release_failed_job('fetch_error', {'conn_failed', _}, JObj) -> - Result = [{<<"success">>, 'false'} - ,{<<"result_code">>, 0} - ,{<<"result_text">>, <<"could not connect to document URL">>} - ,{<<"pages_sent">>, 0} - ,{<<"time_elapsed">>, elapsed_time(JObj)} - ,{<<"fax_bad_rows">>, 0} - ,{<<"fax_speed">>, 0} - ,{<<"fax_receiver_id">>, <<>>} - ,{<<"fax_error_correction">>, 'false'} - ], - release_job(Result, JObj); -release_failed_job('fetch_error', {Cause, _}, JObj) -> - Msg = kz_term:to_binary(io_lib:format("could not connect to document URL: ~s", [Cause])), - Result = [{<<"success">>, 'false'} - ,{<<"result_code">>, 0} - ,{<<"result_text">>, Msg} - ,{<<"pages_sent">>, 0} - ,{<<"time_elapsed">>, elapsed_time(JObj)} - ,{<<"fax_bad_rows">>, 0} - ,{<<"fax_speed">>, 0} - ,{<<"fax_receiver_id">>, <<>>} - ,{<<"fax_error_correction">>, 'false'} - ], - release_job(Result, JObj); -release_failed_job('fetch_error', Error, JObj) -> - Msg = kz_term:to_binary(io_lib:format("could not connect to document URL: ~s", [Error])), - Result = [{<<"success">>, 'false'} - ,{<<"result_code">>, 0} - ,{<<"result_text">>, Msg} - ,{<<"pages_sent">>, 0} - ,{<<"time_elapsed">>, elapsed_time(JObj)} - ,{<<"fax_bad_rows">>, 0} - ,{<<"fax_speed">>, 0} - ,{<<"fax_receiver_id">>, <<>>} - ,{<<"fax_error_correction">>, 'false'} - ], - release_job(Result, JObj); release_failed_job('tx_resp', Resp, JObj) -> Msg = kz_json:get_first_defined([<<"Error-Message">>, <<"Response-Message">>], Resp), <<"sip:", Code/binary>> = kz_json:get_value(<<"Response-Code">>, Resp, <<"sip:500">>), @@ -812,127 +730,18 @@ elapsed_time(JObj) -> Created = kz_doc:created(JObj, Now), Now - Created. --spec fetch_document(kz_json:object()) -> kz_http:ret(). -fetch_document(JObj) -> - case kz_doc:attachment_names(JObj) of - [] -> fetch_document_from_url(JObj); - AttachmentNames -> fetch_document_from_attachment(JObj, AttachmentNames) - end. - --spec fetch_document_from_attachment(kz_json:object(), kz_term:ne_binaries()) -> kz_http:ret(). -fetch_document_from_attachment(JObj, [AttachmentName|_]) -> - DefaultContentType = kz_mime:from_extension(filename:extension(AttachmentName)), - ContentType = kz_doc:attachment_content_type(JObj, AttachmentName, DefaultContentType), - Props = [{"content-type", ContentType}], - {'ok', Contents} = kz_datamgr:fetch_attachment(?KZ_FAXES_DB, kz_doc:id(JObj), AttachmentName), - {'ok', 200, Props, Contents}. - --spec fetch_document_from_url(kz_json:object()) -> kz_http:ret(). -fetch_document_from_url(JObj) -> - FetchRequest = kz_json:get_value(<<"document">>, JObj), - Url = kz_json:get_string_value(<<"url">>, FetchRequest), - Method = kz_term:to_atom(kz_json:get_value(<<"method">>, FetchRequest, <<"get">>), 'true'), - Headers = props:filter_undefined( - [{"Host", kz_json:get_string_value(<<"host">>, FetchRequest)} - ,{"Referer", kz_json:get_string_value(<<"referer">>, FetchRequest)} - ,{"User-Agent", kz_json:get_string_value(<<"user_agent">>, FetchRequest, kz_term:to_list(node()))} - ,{"Content-Type", kz_json:get_string_value(<<"content_type">>, FetchRequest, <<"text/plain">>)} - ]), - Body = kz_json:get_string_value(<<"content">>, FetchRequest, ""), - lager:debug("making ~s request to '~s'", [Method, Url]), - kz_http:req(Method, Url, Headers, Body). - --spec prepare_contents(kz_term:ne_binary(), kz_term:proplist(), kz_term:ne_binary()) -> - {'ok', kz_term:ne_binary()} | - {'error', kz_term:ne_binary()}. -prepare_contents(JobId, RespHeaders, RespContent) -> - lager:debug("preparing fax contents"), - CT = props:get_value("content-type", RespHeaders, <<"application/octet-stream">>), - ContentType = fax_util:normalize_content_type(CT), - TmpDir = kapps_config:get_binary(?CONFIG_CAT, <<"file_cache_path">>, <<"/tmp/">>), - case prepare_contents(ContentType, JobId, RespContent, TmpDir) of - {'ok', OutputFile} -> validate_tiff(OutputFile); - {'error', _}=Error -> Error - end. - --spec prepare_contents(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> - {'ok', kz_term:ne_binary()} | - {'error', kz_term:ne_binary()}. -prepare_contents(<<"image/tiff">>, JobId, RespContent, TmpDir) -> - OutputFile = list_to_binary([TmpDir, JobId, ".tiff"]), - kz_util:write_file(OutputFile, RespContent), - {'ok', OutputFile}; - -prepare_contents(<<"application/pdf">>, JobId, RespContent, TmpDir) -> - InputFile = list_to_binary([TmpDir, JobId, ".pdf"]), - OutputFile = list_to_binary([TmpDir, JobId, ".tiff"]), - kz_util:write_file(InputFile, RespContent), - Cmd = io_lib:format(?CONVERT_PDF_COMMAND, [OutputFile, InputFile]), - lager:debug("attempting to convert pdf: ~s", [Cmd]), - try "success" = os:cmd(Cmd) of - "success" -> {'ok', OutputFile} - catch - Type:Exception -> - lager:debug("could not covert file: ~p:~p", [Type, Exception]), - {'error', <<"can not convert file, try uploading a tiff">>} - end; - -prepare_contents(<<"image/", SubType/binary>>, JobId, RespContent, TmpDir) -> - InputFile = list_to_binary([TmpDir, JobId, ".", SubType]), - OutputFile = list_to_binary([TmpDir, JobId, ".tiff"]), - kz_util:write_file(InputFile, RespContent), - Cmd = io_lib:format(?CONVERT_IMAGE_COMMAND, [InputFile, OutputFile]), - lager:debug("attempting to convert ~s: ~s", [SubType, Cmd]), - try "success" = os:cmd(Cmd) of - "success" -> {'ok', OutputFile} - catch - Type:Exception -> - lager:debug("could not covert file: ~p:~p", [Type, Exception]), - {'error', <<"can not convert file, try uploading a tiff">>} - end; - -prepare_contents(<> = CT, JobId, RespContent, TmpDir) -> - convert_openoffice_document(CT, TmpDir, JobId, RespContent); - -prepare_contents(CT, JobId, RespContent, TmpDir) - when ?OPENOFFICE_COMPATIBLE(CT) -> - convert_openoffice_document(CT, TmpDir, JobId, RespContent); - -prepare_contents(CT, _JobId, _RespContent, _TmpDir) -> - lager:debug("unsupported file type: ~p", [CT]), - {'error', list_to_binary(["file type '", CT, "' is unsupported"])}. - --spec convert_openoffice_document(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> - {'ok', kz_term:ne_binary()} | - {'error', kz_term:ne_binary()}. -convert_openoffice_document(CT, TmpDir, JobId, RespContent) -> - Extension = kz_mime:to_extension(CT), - InputFile = list_to_binary([TmpDir, JobId, ".", Extension]), - OutputFile = list_to_binary([TmpDir, JobId, ".tiff"]), - kz_util:write_file(InputFile, RespContent), - OpenOfficeServer = kapps_config:get_binary(?CONFIG_CAT, <<"openoffice_server">>, <<"'socket,host=localhost,port=2002;urp;StarOffice.ComponentContext'">>), - Cmd = io_lib:format(?CONVERT_OO_COMMAND, [OpenOfficeServer, InputFile, OutputFile]), - lager:debug("attemting to convert openoffice document: ~s", [Cmd]), - try "success" = os:cmd(Cmd) of - "success" -> {'ok', OutputFile} - catch - Type:Exception -> - lager:debug("could not covert file: ~p:~p", [Type, Exception]), - {'error', <<"can not convert file, try uploading a tiff">>} +-spec write_document(kz_json:object(), kz_term:ne_binary()) -> + {'ok', kz_term:ne_binary(), kz_json:object()} | + {'error', any()}. +write_document(JObj, JobId) -> + case kzd_fax:fetch_faxable_attachment(?KZ_FAXES_DB, JObj) of + {'ok', Content, _ContentType, Doc} -> + Filepath = filename:join(?TMP_DIR, <>), + kz_util:write_file(Filepath, Content), + {'ok', Filepath, Doc}; + Error -> Error end. --spec get_sizes(kz_term:ne_binary()) -> {integer(), non_neg_integer()}. -get_sizes(OutputFile) when is_binary(OutputFile) -> - CmdCount = kapps_config:get_binary(?CONFIG_CAT, <<"count_pages_command">>, ?COUNT_PAGES_CMD), - Cmd = io_lib:format(CmdCount, [OutputFile]), - NumberOfPages = try Result = os:cmd(kz_term:to_list(Cmd)), - kz_term:to_integer(Result) - catch - _:_ -> 0 - end, - FileSize = filelib:file_size(kz_term:to_list(OutputFile)), - {NumberOfPages, FileSize}. - -spec send_fax(kz_term:ne_binary(), kz_json:object(), kz_term:ne_binary()) -> 'ok'. send_fax(JobId, JObj, Q) -> SendFax = fun() -> send_fax(JobId, JObj, Q, get_did(JObj)) end, @@ -1129,36 +938,3 @@ send_control_error(JobId, CtrlQ, Stage, Reason) -> ], Publisher = fun(P) -> kapi_fax:publish_targeted_status(CtrlQ, P) end, kz_amqp_worker:cast(Payload, Publisher). - --spec validate_tiff(kz_term:ne_binary()) -> {'ok', kz_term:ne_binary()} | {'error', kz_term:ne_binary()}. -validate_tiff(Filename) -> - case file:read_file_info(Filename) of - {'ok', _} -> - lager:info("file ~s exists, validating", [Filename]), - validate_tiff_content(Filename); - {'error', Reason} -> - lager:info("could not get file info for ~s : ~p", [Filename, Reason]), - {'error', <<"could not convert input file">>} - end. - --spec validate_tiff_content(kz_term:ne_binary()) -> {'ok', kz_term:ne_binary()} | {'error', kz_term:ne_binary()}. -validate_tiff_content(Filename) -> - case os:find_executable("tiff2pdf") of - 'false' -> - lager:info("tiff2pdf not found when trying to validate tiff file, assuming ok."), - {'ok', Filename}; - Exe -> - Dir = filename:dirname(Filename), - OutputFile = filename:join(Dir, <<(kz_binary:rand_hex(16))/binary, ".pdf">>), - Cmd = io_lib:format("~s -o ~s ~s", [Exe, OutputFile, Filename]), - catch(os:cmd(Cmd)), - case file:read_file_info(OutputFile) of - {'ok', _} -> - lager:info("tiff check succeeded converting to pdf"), - catch(file:delete(OutputFile)), - {'ok', Filename}; - {'error', _} -> - lager:info("tiff check failed to convert to pdf"), - {'error', <<"tiff check failed to convert to pdf">>} - end - end. diff --git a/applications/notify/src/notify_fax_inbound_to_email.erl b/applications/notify/src/notify_fax_inbound_to_email.erl index 23ec9036159..e145cb8b964 100644 --- a/applications/notify/src/notify_fax_inbound_to_email.erl +++ b/applications/notify/src/notify_fax_inbound_to_email.erl @@ -129,7 +129,7 @@ build_and_send_email(TxtBody, HTMLBody, Subject, To, Props) -> Service = props:get_value(<<"service">>, Props), From = props:get_value(<<"send_from">>, Service), - {ContentType, AttachmentFileName, AttachmentBin} = notify_fax_util:get_attachment(?MOD_CONFIG_CAT, Props), + {ContentType, AttachmentFileName, AttachmentBin} = notify_fax_util:get_attachment(Props), [ContentTypeA,ContentTypeB] = binary:split(ContentType,<<"/">>), {ContentTypeParams, CharsetString} = notify_util:get_charset_params(Service), diff --git a/applications/notify/src/notify_fax_outbound_to_email.erl b/applications/notify/src/notify_fax_outbound_to_email.erl index 4e9533e7b27..98fd0465bde 100644 --- a/applications/notify/src/notify_fax_outbound_to_email.erl +++ b/applications/notify/src/notify_fax_outbound_to_email.erl @@ -143,7 +143,7 @@ build_and_send_email(TxtBody, HTMLBody, Subject, To, Props, AccountDb) -> Service = props:get_value(<<"service">>, Props), From = props:get_value(<<"send_from">>, Service), - {ContentType, AttachmentFileName, AttachmentBin} = notify_fax_util:get_attachment(AccountDb, ?MOD_CONFIG_CAT, Props), + {ContentType, AttachmentFileName, AttachmentBin} = notify_fax_util:get_attachment(AccountDb, Props), [ContentTypeA,ContentTypeB] = binary:split(ContentType,<<"/">>), {ContentTypeParams, CharsetString} = notify_util:get_charset_params(Service), diff --git a/applications/notify/src/notify_fax_util.erl b/applications/notify/src/notify_fax_util.erl index 20d93756a95..8a16deaf5a7 100644 --- a/applications/notify/src/notify_fax_util.erl +++ b/applications/notify/src/notify_fax_util.erl @@ -6,18 +6,17 @@ %%%----------------------------------------------------------------------------- -module(notify_fax_util). --export([get_attachment/2, get_attachment/3]). +-export([get_attachment/1, get_attachment/2]). -include("notify.hrl"). --define(TIFF_TO_PDF_CMD, <<"tiff2pdf -o ~s ~s &> /dev/null && echo -n \"success\"">>). - +-define(CONVERT_CONFIG_CAT, <<"kazoo_convert">>). %%------------------------------------------------------------------------------ %% @doc create a friendly file name %% @end %%------------------------------------------------------------------------------ --spec get_file_name(kz_term:proplist(), string()) -> kz_term:ne_binary(). +-spec get_file_name(kz_term:proplist(), kz_term:ne_binary()) -> kz_term:ne_binary(). get_file_name(Props, Ext) -> Fax = props:get_value(<<"fax">>, Props), CallerID = case {props:get_value(<<"caller_id_name">>, Fax), props:get_value(<<"caller_id_number">>, Fax)} of @@ -33,26 +32,21 @@ get_file_name(Props, Ext) -> %% @doc %% @end %%------------------------------------------------------------------------------ --spec get_attachment(kz_term:ne_binary(), kz_term:proplist()) -> +-spec get_attachment(kz_term:proplist()) -> {kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()} | {'error', any()}. -get_attachment(Category, Props) -> +get_attachment(Props) -> UseDb = props:get_value(<<"account_db">>, Props, ?KZ_FAXES_DB), - get_attachment(UseDb, Category, Props). + get_attachment(UseDb, Props). --spec get_attachment(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:proplist()) -> +-spec get_attachment(kz_term:ne_binary(), kz_term:proplist()) -> {kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()} | {'error', any()}. -get_attachment(UseDb, Category, Props) -> +get_attachment(UseDb, Props) -> Fax = props:get_value(<<"fax">>, Props), FaxId = props:get_first_defined([<<"fax_jobid">>, <<"fax_id">>], Fax), - - {'ok', AttachmentBin, ContentType} = raw_attachment_binary(UseDb, FaxId), - - case kapps_config:get_binary(Category, <<"attachment_format">>, <<"pdf">>) of - <<"pdf">> -> convert_to_pdf(AttachmentBin, Props, ContentType); - _Else -> convert_to_tiff(AttachmentBin, Props, ContentType) - end. + {'ok', Content, ContentType} = raw_attachment_binary(UseDb, FaxId), + {ContentType, get_file_name(Props, kz_mime:to_extension(ContentType)), Content}. %%------------------------------------------------------------------------------ %% @doc @@ -73,58 +67,14 @@ raw_attachment_binary(Db, FaxId, Retries) when Retries > 0 -> {'error','not_found'} when Db =/= ?KZ_FAXES_DB -> raw_attachment_binary(?KZ_FAXES_DB, FaxId, Retries); {'ok', FaxJObj} -> - case kz_doc:attachment_names(FaxJObj) of - [AttachmentId | _] -> - ContentType = kz_doc:attachment_content_type(FaxJObj, AttachmentId, <<"image/tiff">>), - {'ok', AttachmentBin} = kz_datamgr:fetch_attachment(Db, FaxId, AttachmentId), - {'ok', AttachmentBin, ContentType}; - [] -> - lager:debug("failed to find the attachment, retrying ~b more times", [Retries]), + Format = kapps_config:get_ne_binary(?CONVERT_CONFIG_CAT, [<<"fax">>, <<"attachment_format">>], <<"pdf">>), + case kzd_fax:fetch_attachment_format(Format, ?KZ_FAXES_DB, FaxJObj) of + {'ok', Content, ContentType, _Doc} -> + {'ok', Content, ContentType}; + {'error', Error} -> + lager:debug("failed to find the attachment with error ~p, retrying ~b more times", [Error, Retries]), timer:sleep(?MILLISECONDS_IN_MINUTE * 5), raw_attachment_binary(Db, FaxId, Retries) end end. -%%------------------------------------------------------------------------------ -%% @doc -%% @end -%%------------------------------------------------------------------------------ --spec convert_to_tiff(kz_term:ne_binary(), kz_term:proplist(), kz_term:ne_binary()) -> - {kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()}. -convert_to_tiff(AttachmentBin, Props, _ContentType) -> - {<<"image/tiff">>, get_file_name(Props, "tiff"), AttachmentBin}. - -%%------------------------------------------------------------------------------ -%% @doc -%% @end -%%------------------------------------------------------------------------------ --spec convert_to_pdf(kz_term:ne_binary(), kz_term:proplist(), kz_term:ne_binary()) -> - {kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()} | - {'error', any()}. -convert_to_pdf(AttachmentBin, Props, <<"application/pdf">>) -> - {<<"application/pdf">>, get_file_name(Props, "pdf"), AttachmentBin}; -convert_to_pdf(AttachmentBin, Props, _ContentType) -> - TiffFile = tmp_file_name(<<"tiff">>), - PDFFile = tmp_file_name(<<"pdf">>), - kz_util:write_file(TiffFile, AttachmentBin), - ConvertCmd = kapps_config:get_binary(<<"notify.fax">>, <<"tiff_to_pdf_conversion_command">>, ?TIFF_TO_PDF_CMD), - Cmd = io_lib:format(ConvertCmd, [PDFFile, TiffFile]), - lager:debug("running command: ~s", [Cmd]), - _ = os:cmd(Cmd), - kz_util:delete_file(TiffFile), - case file:read_file(PDFFile) of - {'ok', PDFBin} -> - kz_util:delete_file(PDFFile), - {<<"application/pdf">>, get_file_name(Props, "pdf"), PDFBin}; - {'error', _R}=E -> - lager:debug("unable to convert tiff: ~p", [_R]), - E - end. - -%%------------------------------------------------------------------------------ -%% @doc -%% @end -%%------------------------------------------------------------------------------ --spec tmp_file_name(kz_term:ne_binary()) -> string(). -tmp_file_name(Ext) -> - kz_term:to_list(<<"/tmp/", (kz_binary:rand_hex(10))/binary, "_notify_fax.", Ext/binary>>). diff --git a/applications/teletype/src/teletype_fax_util.erl b/applications/teletype/src/teletype_fax_util.erl index 5f24f0bf8f0..5159990a441 100644 --- a/applications/teletype/src/teletype_fax_util.erl +++ b/applications/teletype/src/teletype_fax_util.erl @@ -13,8 +13,7 @@ -include("teletype.hrl"). --define(FAX_CONFIG_CAT, <<(?NOTIFY_CONFIG_CAT)/binary, ".fax">>). --define(TIFF_TO_PDF_CMD, <<"tiff2pdf -o ~s ~s &> /dev/null && echo -n \"success\"">>). +-define(CONVERT_CONFIG_CAT, <<"kazoo_convert">>). -spec add_data(kz_json:object()) -> kz_json:object(). add_data(DataJObj) -> @@ -213,48 +212,18 @@ maybe_fetch_attachments(_, _, _, 'true') -> maybe_fetch_attachments(DataJObj, FaxJObj, Macros, 'false') -> FaxId = kz_doc:id(FaxJObj), Db = kz_doc:account_db(FaxJObj), - [AttachmentName] = kz_doc:attachment_names(FaxJObj), - - lager:debug("accessing fax attachment ~s at ~s / ~s", [AttachmentName, Db, FaxId]), + lager:debug("accessing fax attachment ~s at ~s", [Db, FaxId]), teletype_util:send_update(DataJObj, <<"pending">>), - - case kz_datamgr:fetch_attachment(Db, {kzd_fax:type(), FaxId}, AttachmentName) of - {'ok', Bin} -> - ContentType = kz_doc:attachment_content_type(FaxJObj, AttachmentName), - maybe_convert_attachment(DataJObj, Macros, ContentType, Bin); + Format = kapps_config:get_ne_binary(?CONVERT_CONFIG_CAT, [<<"fax">>, <<"attachment_format">>], <<"pdf">>), + Filename = get_file_name(Macros, <<".", Format/binary>>), + case kzd_fax:fetch_attachment_format(Format, Db, FaxJObj) of + {'ok', Content, ContentType, _Doc} -> + [{ContentType, Filename, Content}]; {'error', _E} -> - lager:debug("failed to fetch attachment ~s: ~p", [AttachmentName, _E]), + lager:debug("failed to fetch attachment: ~p", [_E]), [] end. --spec maybe_convert_attachment(kz_json:object(), kz_term:proplist(), kz_term:api_binary(), binary()) -> attachments(). -maybe_convert_attachment(DataJObj, Macros, ContentType, Bin) -> - ToFormat = kapps_config:get_ne_binary(?FAX_CONFIG_CAT, <<"attachment_format">>, <<"pdf">>), - FromFormat = from_format_from_content_type(ContentType), - - case convert(DataJObj, FromFormat, ToFormat, Bin) of - {'ok', Converted} -> - Filename = get_file_name(Macros, ToFormat), - lager:debug("adding attachment as ~s", [Filename]), - [{content_type_from_extension(Filename), Filename, Converted}]; - {'error', Reason} -> - lager:debug("error converting attachment with reason : ~p", [Reason]), - [] - end. - --spec from_format_from_content_type(kz_term:ne_binary()) -> kz_term:ne_binary(). -from_format_from_content_type(<<"application/pdf">>) -> <<"pdf">>; -from_format_from_content_type(<<"image/tiff">>) -> <<"tif">>; -from_format_from_content_type('undefined') -> <<"tif">>; -from_format_from_content_type(ContentType) -> - [_Type, SubType] = binary:split(ContentType, <<"/">>), - SubType. - --spec content_type_from_extension(kz_term:ne_binary()) -> kz_term:ne_binary(). -content_type_from_extension(Ext) -> - {Type, SubType, _} = cow_mimetypes:all(Ext), - <>. - -spec get_file_name(kz_term:proplist(), kz_term:ne_binary()) -> kz_term:ne_binary(). get_file_name(Macros, Ext) -> CallerIdMacros = props:get_value(<<"caller_id">>, Macros), @@ -271,48 +240,3 @@ get_file_name(Macros, Ext) -> FName = list_to_binary([CallerID, "_", kz_time:pretty_print_datetime(LocalDateTime), ".", Ext]), re:replace(kz_term:to_lower_binary(FName), <<"\\s+">>, <<"_">>, [{'return', 'binary'}, 'global']). --spec convert(kz_json:object(), kz_term:ne_binary(), kz_term:ne_binary(), binary()) -> {ok, binary()} | {error, atom() | string()}. -convert(_, FromFormat, FromFormat, Bin) -> - {'ok', Bin}; -convert(DataJObj, FromFormat0, ToFormat0, Bin) -> - lager:debug("converting from ~s to ~s", [FromFormat0, ToFormat0]), - teletype_util:send_update(DataJObj, <<"pending">>), - - FromFormat = valid_format(FromFormat0), - ToFormat = valid_format(ToFormat0), - - Filename = kz_binary:rand_hex(8), - FromFile = <<"/tmp/", Filename/binary, ".", FromFormat/binary>>, - ToFile = <<"/tmp/", Filename/binary, ".", ToFormat/binary>>, - - 'ok' = file:write_file(FromFile, Bin), - - ConvertCmd = kapps_config:get_binary(?FAX_CONFIG_CAT, <<"tiff_to_pdf_conversion_command">>, ?TIFF_TO_PDF_CMD), - Cmd = io_lib:format(ConvertCmd, [ToFile, FromFile]), - - Response = run_convert_command(Cmd, FromFile, ToFile), - _ = kz_util:delete_file(FromFile), - _ = kz_util:delete_file(ToFile), - Response. - --spec run_convert_command(string(), kz_term:ne_binary(), kz_term:ne_binary()) -> {ok, binary()} | {error, atom() | string()}. -run_convert_command(Cmd, FromFile, ToFile) -> - lager:debug("running conversion command: ~s", [Cmd]), - case os:cmd(Cmd) of - "success" -> - case file:read_file(ToFile) of - {'ok', PDF} -> - lager:debug("convert file ~s to ~s succeeded", [FromFile, ToFile]), - {'ok', PDF}; - {'error', _R}=E -> - lager:debug("unable to read converted file ~s : ~p", [ToFile, _R]), - E - end; - Else -> - lager:debug("could not convert file ~s : ~p", [FromFile, Else]), - {'error', Else} - end. - --spec valid_format(kz_term:ne_binary()) -> kz_term:ne_binary(). -valid_format(<<"tiff">>) -> <<"tif">>; -valid_format(Format) -> Format. diff --git a/core/kazoo/src/kz_mime.erl.src b/core/kazoo/src/kz_mime.erl.src index 9c1ad226bcd..e0eaba95326 100644 --- a/core/kazoo/src/kz_mime.erl.src +++ b/core/kazoo/src/kz_mime.erl.src @@ -9,6 +9,7 @@ -export([from_extension/1]). -export([from_filename/1]). -export([to_filename/1]). +-export([normalize_content_type/1]). %%------------------------------------------------------------------------------ %% @doc Transform a mimetype to an extension @@ -58,6 +59,37 @@ from_filename(Path) -> to_filename(CT) -> list_to_binary([kz_binary:rand_hex(16), ".", hd(to_extensions(CT))]). + +%%------------------------------------------------------------------------------ +%% @doc normalize the content types. +%% +%% Example: +%% +%% ``` +%% 1> kz_mime:normalize_content_type(<<"image/tiff">>). +%% <<"image/tiff">> +%% ''' +%% @todo make this work for all the content typs in applications/crossbar/src/crossbar_types.hrl +%% +%% @end +%%------------------------------------------------------------------------------ +-spec normalize_content_type(kz_term:text()) -> kz_term:ne_binary(). +normalize_content_type(<<"image/tif">>) -> <<"image/tiff">>; +normalize_content_type(<<"image/x-tif">>) -> <<"image/tiff">>; +normalize_content_type(<<"image/tiff">>) -> <<"image/tiff">>; +normalize_content_type(<<"image/x-tiff">>) -> <<"image/tiff">>; +normalize_content_type(<<"application/tif">>) -> <<"image/tiff">>; +normalize_content_type(<<"apppliction/x-tif">>) -> <<"image/tiff">>; +normalize_content_type(<<"apppliction/tiff">>) -> <<"image/tiff">>; +normalize_content_type(<<"apppliction/x-tiff">>) -> <<"image/tiff">>; +normalize_content_type(<<"application/pdf">>) -> <<"application/pdf">>; +normalize_content_type(<<"application/x-pdf">>) -> <<"application/pdf">>; +normalize_content_type(<<"text/pdf">>) -> <<"application/pdf">>; +normalize_content_type(<<"text/x-pdf">>) -> <<"application/pdf">>; +normalize_content_type(<<_/binary>> = Else) -> Else; +normalize_content_type(CT) -> + normalize_content_type(kz_term:to_binary(CT)). + %%------------------------------------------------------------------------------ %% @doc Return the mime type for any file by looking at its extension. %% diff --git a/core/kazoo_config/src/kapps_config.erl b/core/kazoo_config/src/kapps_config.erl index 6c9cd82be3f..91b80a5da67 100644 --- a/core/kazoo_config/src/kapps_config.erl +++ b/core/kazoo_config/src/kapps_config.erl @@ -1022,6 +1022,9 @@ fetch_category(Category, 'false') -> ,{{<<"fax">>, <<"conversion_command">>} ,{<<"fax">>, <<"conversion_pdf_command">>} } + ,{{<<"fax">>, <<"file_cache_path">>} + ,{<<"kazoo_convert">>, <<"file_cache_path">>} + } ,{{<<"media">>, <<"tts_cache">>} ,{<<"speech">>, <<"tts_cache">>} diff --git a/core/kazoo_convert/Makefile b/core/kazoo_convert/Makefile new file mode 100644 index 00000000000..db238deeac0 --- /dev/null +++ b/core/kazoo_convert/Makefile @@ -0,0 +1,8 @@ +ROOT = ../.. +PROJECT = kazoo_convert + +SOURCES = src/gen_kz_converter.erl $(wildcard src/*.erl) $(wildcard src/*/*.erl) + +all: compile + +include $(ROOT)/make/kz.mk diff --git a/core/kazoo_convert/doc/README.md b/core/kazoo_convert/doc/README.md new file mode 100644 index 00000000000..16a144210c9 --- /dev/null +++ b/core/kazoo_convert/doc/README.md @@ -0,0 +1,33 @@ +# Kazoo File Format Converter Library + +The Kazoo converter provides a core library for converting file formats. + +## Modules + +The converters used to execute file conversions are modular, modules can be enabled via configuration. This core library is intended to be extended to include multipe types of conversions and formats and be easily extendable by supporting selection of which modules to use via the `system_config/kazoo_convert` document. Currently only fax conversions are done via the converter. But there are many other types of file conversions going on in Kazoo. Stay tuned... + +#### Fax Converter + +The fax converter module by default use the module `fax_converter`. For a description of how the default fax converter `fax_converter` works, and information about the system commands used in fax file conversions, see [the fax converter documentation.](fax_converter.md) + +###Configuration + +The `v2/system_configs/kazoo_convert` configuration parameters are used to enable features and define commands to use for conversion operations. + +Key | Description | Type | Default | Required | Support Level +--- | ----------- | ---- | ------- | -------- | ------------- +`fax.convert_command_timeout` | The timeout value for file conversion | `integer()` | `120000` | `false` | +`fax.convert_image_command` | The command to resample a tiff file to a fax compatible format or convert a supported image/* format to a tiff | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.convert_openoffice_command` | The command to convert open office documents to pdf | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.convert_pdf_command` | The command to convert pdf documents to tiff | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.convert_tiff_command` | The command to convert a tiff file to PDF | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.large_tiff_command` | The command to convert an oversized tiff file to a fax compatible format | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.small_tiff_command` | The command to convert an undersized tiff file to a fax compatible format | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.enable_openoffice` | Enables the conversion of openoffice compatible documents | `boolean()` | `true` | `false` | +`fax_converter` | Module to use for fax related file conversions | `string()` | `fax_converter` | `false` | +`fax.serialize_openoffice` | Serializes openoffice compatible document conversions | `boolean()` | `true` | `false` | +`fax.validate_pdf_command` | Verifies a PDF file is valid | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`fax.validate_tiff_command` | Verifies a TIFF file is valid | `string()` | [see fax_converter doc](fax_converter.md) | `false` | +`file_cache_path` | The working directory to use when converting files | `string()` | `/tmp/` | `false` | +`attachment_format` | Format to use for receipt email messages and api responses | `string()` | `pdf` | `false` | + diff --git a/core/kazoo_convert/doc/fax_converter.md b/core/kazoo_convert/doc/fax_converter.md new file mode 100644 index 00000000000..353c9d559b1 --- /dev/null +++ b/core/kazoo_convert/doc/fax_converter.md @@ -0,0 +1,278 @@ +# Kazoo Fax Converter + +The fax converter writes files to a configurable working directory and converts them using system commands. The commands used to convert configurations are user configurable with defaults being set to recommended commands. The module is enabled as the default fax file converter. + + +### Fax Compatible Documents + +Fax machines negotiate image parameters via the `T.30` protocol (sometimes overlaid via T.38 for supposedly better transmissions on VoIP networks). If a receiving fax machine is unable to handle the requested image, the call will fail with an error like `Far end cannot receive at the resolution of the image`. This ultimately means the fax is undeliverable, even if retries are enabled, it will not be able to deliver the document to the far end. + +Ultimately, faxes are just an antiquated method to exchange tiff files, which are then printed (or otherwise handled) on the receiving side. On transmission information is exchanged like the acceptable dimensions of the tiff file and file resolution. On receipt, most fax machines will shrink a larger format document to fit on one page, or print multiple pages, but some will simply reject larger images. Generally this negotiation is based on the page sizes that are supported, we are talking about actual paper here now... yeah. + +### Tiff File Fomrat + +In order to ensure maximum compatibility, and a higher chance of a successful delivery of a fax, by default, the `fax_converter` will format all files received into tiff files following a standards compliant fax compatible format. All documents are converted using `CCITT Group 4 compression`, with dimensions of `1728x1078` at a resolution of `204x98` PPI. This is done on every conversion to tiff format. This behavior is configurable via the converter commands configurations. + +Because a user submitted tiff file could be in any format, the converter will check tiff files and read their metadata when conversion from tiff to tiff is requested. The converter will check the resolution and dimensions values, as well as the compression method and do a conversion conditionally on the state of the file. This conversion will guarantee a normalized fax compatible tiff is returned. This means if the resolution is in legal size (8.5 x 14 in.) or has any oversized dimension, it will be resized to fit on an A4 (8.5 x 11 in.) page using the `Large Tiff Command`. If a document is smaller than an A4 document, its dimensions will be preserved will be centered on an A4 page using the `Small Tiff Command`. If the PPI is larger than 204x98, it will be resampled. If the compression is not group3 or group4, it will be resampled using the `Tiff Resample Command`. + +## Default Convert Commands + +The converter uses system commands to do the conversions and writes and converts files in the configured `tmp_dir`, every command the converter uses is customizable via the `v2/system_configs/kazoo_convert` Api. + +## Environment variables provided to all commands + +Three environment variables are provided to every command to ensure ordering of arguments can be provided in any order. + +| Variable | Description | +| --- | --- | +| `$FROM` | The source filename for the conversion | +| `$TO` | The destination filename for the conversion | +| `$WORKDIR` | The working directory for the conversion | + +The `$TO` and `$FROM` environment variables are generally used in most converter commands, but some commands which are intended to operate in batch modes require a work directory instead of a destination file name. If a converter command uses batch mode, the destination directory for the conversion is provided by `work_dir`. + +## Migrating To Fax Converter Commands + +All of the functionality for conversions was extracted from the `fax` and `teletype` apps, however the conversion commands executed did not survive the journey. Unlike the convert commands in the `fax` app, the `fax_converter` module uses the `exit status` returned by the system, to determine if a convert command was successful or failed. This means if you have customized your converter commands from the system defaults in `fax`, when you migrate to use the kz_convert, you should ensure the exit status returned is `0` when the convert command you use is successful. + +So for example, if your command to convert PDF to TIFF formats was: + +```bash +/usr/bin/gs -q \ + -r204x98 \ + -g1728x1078 \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -sDEVICE=tiffg3 \ + -sOutputFile=~s -- ~s \ + > /dev/null 2>&1 \ + && echo -n success +``` + +the equivalent `fax_converter` command would be: + +```bash +/usr/bin/gs -q \ + -r204x98 \ + -g1728x1078 \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -sDEVICE=tiffg4 \ + -sOutputFile=$TO -- $FROM +``` + +Which also means, if the converter you are using for a specific purpose is a `jerk` and always returns `exit_status: 0`, you need to handle this in your convert command. Something like this could be appended to the end of the your custom command to handle this case. This example searches for matches to the patterns `parser error` and `error`in the output and emits exit_status 1 (error) if those matches are found, otherwise emits exit_status 0 (ok). + +```bash +|egrep 'parser error|Error' && exit 1 || exit 0" +``` + +Most converters are nice about exit status, but if you customize your commands, you should definitely test your command in failure cases to ensure you don't end up sending bad faxes or weird notification emails. + + +### Tiff Resample Command + +The configuration parameter for this command is `fax.convert_image_command`. This command is invoked when a conversion from any `image/*` to `image/tiff` is requested. + +This is most commonly used to resample an otherwise validly formatted tiff to ensure it is using the standard format for faxing. + +The default command is: + +```bash +convert $FROM \ + -resample 204x98 \ + -units PixelsPerInch \ + -size 1728x1078 \ + -compress group4 $TO +``` + +### Large Tiff Command + +The configuration parameter for this command is `fax.large_tiff_command`. This command is invoked when a conversion from `image/*` to `image/tiff` is requested. + +This is used when a tiff is larger than 1728x1078 to resize it to fit on the page. + +The default command is: + +```bash +convert $FROM \ + -resample 204x98 \ + -units PixelsPerInch \ + -resize 1728\!x1078 \ + -compress group4 $TO +``` + +### Small Tiff Command + +The configuration parameter for this command is `fax.small_tiff_command`. This command is invoked when a conversion from `image/*` to `image/tiff` is requested. + +Convert command to handle case where the tiff is smaller than 1728x1078 ensure it is in the standard format for faxing. + +The default command is: + +```bash +convert $FROM \ + -gravity center \ + -resample 204x98 \ + -units PixelsPerInch \ + -extent 1728x1078 \ + -compress group4 $TO +``` + +#### Requirements + +These command requires the system support the `convert` command, this is installed via the package `ImageMagick` in Centos7 and Debian8. + +## Tiff to PDF + +The configuration parameter for this command is `fax.convert_tiff_command`. This command is invoked when a conversion from `image/tiff` to `application/pdf` is requested. + +The default command is: + +```bash +tiff2pdf -o $FROM $TO +``` + +#### Requirements + +This command requires `tiff2pdf` be installed, this is installed via the package `libtifftools` in Centos7 and `libtiff-tools` in Debian8. + +## Pdf to Tiff + +The configuration parameter for this command is `fax.convert_pdf_command`. This command is invoked when conversion from `application/pdf` to `image/tiff` is requested. + +The default command is: + +```bash +/usr/bin/gs \ + -q \ + -r204x98 \ + -g1728x1078 \ + -dNOPAUSE \ + -dBATCH \ + -dSAFER \ + -sDEVICE=tiffg4 \ + -sOutputFile=$TO \ + -- $FROM +``` + +#### Requirements + +This command requires `ghostscript` be installed, this is installed via the package `ghostscript` in Centos7 and Debian8. + + +## OpenOffice compatible to PDF + +The configuration for this command is `fax.convert_openoffice_command`. This command is invoked when conversion from any openoffice compatible format is requested. For this feature to be used, `fax.enable_openoffice` must be set in the `kazoo_convert` configuration. If openoffice compatible format conversions are enabled, by default openoffice conversions are serialized, this can be changed by setting `fax.serialize_openoffice` to false. + +mimetypes that will use this converter include: + + - `application/vnd.openxmlformats-officedocument.*` + - `application/vnd.oasis.opendocument.*` + - `application/msword` + - `application/vnd.ms-excel` + - `application/vnd.ms-powerpoint` + +The default use of `unoconv` has been deprecated in fax_converter as libreoffice can be invoked directly using the `--convert-to pdf` argument without requiring a constantly running openoffice server. `Libreoffice`, provides useful error output if a command fails but does not provide an error exit status in the event of a conversion failure, so success must be determined from the command output. + +```bash +libreoffice \ + --headless \ + --convert-to pdf $FROM \ + --outdir $WORKDIR \ + 2>&1 \ + |egrep 'parser error|Error' && exit 1 || exit 0 +``` + +### Requirements + +This command requires `libreoffice` via package `libreoffice-core` in Centos7 and `libreoffice-common` in Debian8. + +## Default Validate Commands + +### Environment variables provided to all validate commands +Three environment variables are provided to every command to ensure ordering of arguments can be provided in any order. + +| Variable | Description | +| --- | --- | +| `$FROM` | The source filename to validate | +| `$TO` | The destination filename for the conversion if a converter is used to validate the command | +| `$WORKDIR` | The working directory for the conversion if a conversion is used to validate the command | +| `$FILE` | Another name for the FROM value, used for clarity when only the target file is needed for validation | + +### Validate Tiff Command + +The configuration parameter for this command is `fax.verify_tiff_command`. This command is invoked after a files is converted to tiff. + +The default command is: + +```bash +tiffinfo $FILE +``` + +#### Requirements + +This command requires `tiffinfo` be installed, this is installed via the package `libtifftools` in Centos7 and `libtiff-tools` in Debian8. + +### Validate PDF Command + +The configuration parameter for this command is `fax.verify_tiff_command`. This command is invoked when a conversion from `image/tiff` to `application/pdf` is requested. + +The default command is: + +```bash +gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FILE +``` + +#### Requirements + +This command requires `ghostscript` be installed, this is installed via the package `ghostscript` in Centos7 and Debian8. + +### Sup Commands + +Fax conversion commands can be tested via sup, this is useful when debugging issues where a custom conversion command is not working properly. + + +#### Test file converters + +Converts a file specified in the `path/to/file` and allows conversions to the formats `pdf` and `tiff`. + +``` +sup kazoo_convert_maintenance convert_fax_file {path/to/file} {to_file_type} +``` + +Work directory can be added for batch type conversions. + +``` +sup kazoo_convert_maintenance convert_fax_file {path/to/file} {to_file_type} {work_directory} +``` + +A destination filename can also be added. + +``` +sup kazoo_convert_maintenance convert_fax_file {path/to/file} {to file type} {work_directory} {destination_filename} +``` + +#### Audit System Commands + +ensure all the converters required for the conversion operations are installed. If installed, this command attempts to display their versions. + +``` +sup kazoo_convert_maintenance versions_in_use +``` + +#### Read File Metadata + +Used to read the metadata of a file in the file system, if the file type is tiff, it will read the page count as well. + +``` +sup kazoo_convert_maintenance read_metadata {/path/to/a/file} +``` + +Used to read the compression, image size, and resolution of a tiff file in the file system. + +``` +sup kazoo_convert_maintenance read_tiff_info {/path/to/a/file} +``` diff --git a/core/kazoo_convert/include/kz_convert.hrl b/core/kazoo_convert/include/kz_convert.hrl new file mode 100644 index 00000000000..ccf84d97e73 --- /dev/null +++ b/core/kazoo_convert/include/kz_convert.hrl @@ -0,0 +1,25 @@ +-ifndef(KZ_CONVERT_HRL). + +-include_lib("kazoo_stdlib/include/kz_types.hrl"). + +-define(CHUNKSIZE, 24576). +-define(APP_NAME, <<"kazoo_convert">>). +-define(APP_VERSION, <<"1.0.0">>). +-define(CONFIG_CAT, ?APP_NAME). + +-define(TIFF_MIME, <<"image/tiff">>). +-define(PDF_MIME, <<"application/pdf">>). +-define(IMAGE_MIME_PREFIX, <<"image/">>). +-define(OPENXML_MIME_PREFIX, "application/vnd.openxmlformats-officedocument."). +-define(OPENOFFICE_MIME_PREFIX, "application/vnd.oasis.opendocument."). +-define(OPENOFFICE_COMPATIBLE(CT) + ,(CT =:= <<"application/msword">> + orelse CT =:= <<"application/vnd.ms-excel">> + orelse CT =:= <<"application/vnd.ms-powerpoint">> + )). + +-define(TMP_DIR + ,kapps_config:get_binary(?CONFIG_CAT, <<"file_cache_path">>, <<"/tmp/">>)). + +-define(KZ_CONVERT_HRL, 'true'). +-endif. diff --git a/core/kazoo_convert/src/gen_kz_converter.erl b/core/kazoo_convert/src/gen_kz_converter.erl new file mode 100644 index 00000000000..5493a28f680 --- /dev/null +++ b/core/kazoo_convert/src/gen_kz_converter.erl @@ -0,0 +1,33 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2011-2018, 2600Hz +%%% @doc Behavior for File converter modules. +%%% +%%% Defines the behavior for file format converters in the kazoo_convert library. +%%% +%%% Converters: +%%%
    +%%%
  • The behavior `gen_kz_converter' specifies the interface of the functions convert/4 and read_metadata/1, and define their return types.
  • +%%%
  • Converter modules should always delete any files created in the process, +%%% including the input file if the {'file', FilePath} `Content' format is specified. If `output_type' is `path' the file converted file will be returned and deletion of this file will be +%%% the responsibility of the caller.
  • +%%%
  • `binary' and `path' formats in the requested `output_type' must be supported.
  • +%%%
  • Input content formats `{file, FilePath}' and a binary containing the files content must be supported.
  • +%%%
  • Any files created in the process should be stored in the tmp_dir parameter, the configured `tmp_dir` or `/tmp' by default.
  • +%%%
+%%% +%%% @end +%%%----------------------------------------------------------------------------- +-module(gen_kz_converter). + +-type converted() :: {'ok', any()}| + {'ok', any(), kz_term:proplist()}| + {'error', any()}. + +-callback convert(kz_term:api_binary() + ,kz_term:api_binary() + ,any() + ,kz_term:proplist()) -> any(). + +-callback read_metadata(kz_term:ne_binary()) -> kz_term:proplist(). + +-export_type([converted/0]). diff --git a/core/kazoo_convert/src/kazoo_convert.app.src b/core/kazoo_convert/src/kazoo_convert.app.src new file mode 100644 index 00000000000..691b68dc0ab --- /dev/null +++ b/core/kazoo_convert/src/kazoo_convert.app.src @@ -0,0 +1,16 @@ +{application, kazoo_convert, + [ + {description, "Kazoo convert provides support for converting document formats to and from TIFF/PDF/OpenOffice formats"}, + {vsn,"4.3.1"}, + {modules, []}, + {registered, [kz_openoffice_server_sup]}, + {applications, [ kernel + , stdlib + , crypto + , kazoo + , kazoo_data + , kazoo_config + , lager + ]}, + {mod, {kazoo_convert_app, []}} + ]}. diff --git a/core/kazoo_convert/src/kazoo_convert_app.erl b/core/kazoo_convert/src/kazoo_convert_app.erl new file mode 100644 index 00000000000..b684dccb659 --- /dev/null +++ b/core/kazoo_convert/src/kazoo_convert_app.erl @@ -0,0 +1,28 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @end +%%%----------------------------------------------------------------------------- +-module(kazoo_convert_app). + +-behaviour(application). + +-include_lib("kazoo_stdlib/include/kz_types.hrl"). + +-export([start/2, stop/1]). + +%%------------------------------------------------------------------------------ +%% @doc Implement the application start behaviour. +%% @end +%%------------------------------------------------------------------------------ +-spec start(application:start_type(), any()) -> kz_types:startapp_ret(). +start(_Type, _Args) -> + kz_openoffice_server_sup:start_link(). + +%%------------------------------------------------------------------------------ +%% @doc Implement the application stop behaviour. +%% @end +%%------------------------------------------------------------------------------ +-spec stop(any()) -> any(). +stop(_State) -> + 'ok'. diff --git a/core/kazoo_convert/src/kazoo_convert_maintenance.erl b/core/kazoo_convert/src/kazoo_convert_maintenance.erl new file mode 100644 index 00000000000..76a63bc3930 --- /dev/null +++ b/core/kazoo_convert/src/kazoo_convert_maintenance.erl @@ -0,0 +1,134 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @end +%%%----------------------------------------------------------------------------- +-module(kazoo_convert_maintenance). + +-export([read_tiff_info/1]). +-export([read_metadata/1]). +-export([convert_fax_file/2, convert_fax_file/3, convert_fax_file/4]). +-export([versions_in_use/0]). + +-include("kz_fax_converter.hrl"). + +-spec read_tiff_info(any()) -> 'ok'. +read_tiff_info(File) when is_binary(File) -> + print_tiff_info(kz_fax_converter:get_tiff_info(File)); +read_tiff_info(File) -> + read_tiff_info(kz_term:to_binary(File)). + +-spec print_tiff_info(map() | {kz_term:ne_binary(), any(), maps:iterator()}) -> 'ok'. +print_tiff_info({'error', Reason, Message}) -> + io:format("Command failed with ~p error: ~s ~n", [Reason, Message]); +print_tiff_info(Metadata) -> + Func = fun (Key, Value, _Acc) when is_binary(Value) -> + io:format("~s: ~s ~n", [Key, Value]); + (Key, Value, _Acc) -> + io:format("~s: ~p ~n", [Key, Value]) + end, + maps:fold(Func, 0, Metadata). + +-spec read_metadata(any()) -> 'ok'. +read_metadata(File) when is_binary(File) -> + print_metadata(kz_fax_converter:read_metadata(File)); +read_metadata(File) -> + read_metadata(kz_term:to_binary(File)). + +-spec print_metadata(kz_term:proplist()) -> 'ok'. +print_metadata(Metadata) -> + _ = lists:foreach(fun({Key, Value}) -> io:format("~s: ~p ~n", [Key, Value]) end, Metadata), + 'ok'. + +-spec convert_fax_file(any(), any(), any(), any()) -> 'ok'. +convert_fax_file(FromFile, ToFormat, WorkDir, ToFilename) -> + Options = [{<<"tmp_dir">>, WorkDir} + ,{<<"to_filename">>, ToFilename} + ], + do_convert(FromFile + ,ToFormat + ,Options + ). + +-spec convert_fax_file(any(), any(), any()) -> 'ok'. +convert_fax_file(FromFile, ToFormat, WorkDir) -> + Options = [{<<"tmp_dir">>, WorkDir}], + do_convert(FromFile + ,ToFormat + ,Options + ). + +-spec convert_fax_file(any(), any()) -> 'ok'. +convert_fax_file(FromFile, ToFormat) -> + convert_fax_file(FromFile, ToFormat, ?TMP_DIR). + +-spec do_convert(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:proplist()) -> + 'ok'|'error'. +do_convert(FromFile, ToFormat, Options) -> + FromMime = kz_mime:from_filename(FromFile), + {'ok', Content} = file:read_file(FromFile), + ToMime = kz_mime:from_extension(ToFormat), + case kz_convert:fax(FromMime, ToMime, Content, Options) of + { 'ok', OutputFile } -> + io:format("Successfully converted ~s to ~s~n", [FromFile, OutputFile]); + { 'error', Msg } -> + io:format("Failed to convert file ~s with error: ~s~n", [FromFile, Msg]) + end. + +-spec versions_in_use() -> 'no_return'. +versions_in_use() -> + AllCmds = + [?CONVERT_IMAGE_COMMAND + ,?CONVERT_OPENOFFICE_COMMAND + ,?CONVERT_PDF_COMMAND + ,?CONVERT_TIFF_COMMAND + ,?VALIDATE_PDF_COMMAND + ], + Executables = find_commands(AllCmds), + lists:foreach(fun print_cmd_version/1, Executables), + 'no_return'. + +print_cmd_version(Exe) -> + Options = ['exit_status' + ,'use_stdio' + ,'stderr_to_stdout' + ,{'args', ["--version"]} + ], + Port = open_port({'spawn_executable', Exe}, Options), + listen_to_port(Port, Exe). + +listen_to_port(Port, Exe) -> + receive + {Port, {'data', Str0}} -> + [Str|_] = string:tokens(Str0, "\n"), + io:format("* ~s:\n\t~s\n", [Exe, Str]), + lager:debug("version for ~s: ~s", [Exe, Str]); + {Port, {'exit_status', 0}} -> 'ok'; + {Port, {'exit_status', _}} -> print_no_executable(Exe) + end. + +find_commands(Cmds) -> + Commands = + lists:usort( + [binary_to_list(hd(binary:split(Cmd, <<$\s>>))) + || Cmd <- Cmds + ]), + lists:usort( + [Exe + || Cmd <- Commands, + Exe <- [cmd_to_executable(Cmd)], + Exe =/= 'false' + ]). + +print_no_executable(Exe) -> + io:format("* ~s:\n\tERROR! missing executable\n", [Exe]), + lager:error("missing executable: ~s", [Exe]). + +cmd_to_executable("/"++_=Exe) -> Exe; +cmd_to_executable(Cmd) -> + case os:find_executable(Cmd) of + 'false' -> + print_no_executable(Cmd), + 'false'; + Exe -> Exe + end. diff --git a/core/kazoo_convert/src/kz_convert.erl b/core/kazoo_convert/src/kz_convert.erl new file mode 100644 index 00000000000..63930c148bd --- /dev/null +++ b/core/kazoo_convert/src/kz_convert.erl @@ -0,0 +1,56 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @end +%%%----------------------------------------------------------------------------- +-module(kz_convert). + +-export([fax/3, fax/4 + ]). + +-include_lib("kazoo_convert/include/kz_convert.hrl"). + +%% @equiv fax(FromFormat, ToFormat, Content, []) +-spec fax(kz_term:api_ne_binary(), kz_term:api_ne_binary(), binary()|{'file', filename:name()}) -> + gen_kz_converter:converted(). +fax(FromFormat, ToFormat, Content) -> + fax(FromFormat, ToFormat, Content, []). + +%%------------------------------------------------------------------------------ +%% @doc A uniform interface for conversion of fax related files. +%% +%% The configured converter module is loaded from system_config/kazoo_convert via +%% the parameter `fax_converter'. The default fax_converter is `kz_fax_converter'. The +%% behaviour for converter modules is defined in `gen_kz_converter'. +%% +%% Arguments Description: +%%
    +%%
  • From: is a mimetype binary that specifies the format of +%% the Content passed in to convert.
  • +%%
  • To: is a mimetype binary that specifies the format the +%% Content is to be converted.
  • +%%
  • Content: content can be file path to the source file or +%% a binary containing the contents of the file to be converted.
  • +%%
  • Options: a proplist of options for the underlying fax_converter.
  • +%%
+%% +%% @end +%%------------------------------------------------------------------------------ +-spec fax(kz_term:api_binary(), kz_term:api_binary(), binary()|{'file', filename:name()}, kz_term:proplist()) -> + gen_kz_converter:converted(). +fax('undefined', _ToFormat, <<>>, _Options) -> + {'error', <<"undefined from format">>}; +fax(_FromFormat, 'undefined', <<>>, _Options) -> + {'error', <<"undefined to format">>}; +fax(_FromFormat, _ToFormat, <<>>, _Options) -> + {'error', <<"empty content">>}; +fax(_FromFormat, _ToFormat, {'file', <<>>}, _Options) -> + {'error', <<"empty filename">>}; +fax(FromFormat, ToFormat, Content, Options) -> + Conversion = kapps_config:get_ne_binary(?CONFIG_CAT, <<"fax_converter">>, <<"fax_converter">>), + Module = convert_to_module(Conversion), + Module:convert(FromFormat, ToFormat, Content, props:insert_value(<<"tmp_dir">>, ?TMP_DIR, Options)). + +-spec convert_to_module(kz_term:ne_binary()) -> atom(). +convert_to_module(Conversion) -> + kz_term:to_atom(<<"kz_", Conversion/binary>>, 'true'). diff --git a/core/kazoo_convert/src/kz_fax_converter.erl b/core/kazoo_convert/src/kz_fax_converter.erl new file mode 100644 index 00000000000..991d64edaa3 --- /dev/null +++ b/core/kazoo_convert/src/kz_fax_converter.erl @@ -0,0 +1,477 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @end +%%%----------------------------------------------------------------------------- +-module(kz_fax_converter). + +-behaviour(gen_kz_converter). + +-export([convert/4 + ,do_openoffice_to_pdf/2 + ,read_metadata/1 + ,get_tiff_info/1 + ]). + +-include("kz_fax_converter.hrl"). + +-type fax_converted() :: {'ok', any()}| + {'error', any()}. + +-type fax_convert_funs() :: [fun((kz_term:ne_binary(), map()) -> fax_converted())]. + +%%------------------------------------------------------------------------------ +%% @doc Converts the data or file specified in `Content' from the `To' mime-type to the +%% `From' mime-type. +%% +%% Arguments Description: +%%
    +%%
  • From: is a mime-type binary that specifies the format of +%% the Content passed in to convert.
  • +%%
  • To: is a mime-type binary that specifies the format the +%% Content is to be converted.
  • +%%
  • Content: content can be filepath to the source file or +%% a binary containing the contents of the file to be converted.
  • +%%
  • Options: a proplist of the converter options
  • +%%
+%% +%% Options: +%%
    +%%
  • job_id: the unique ID of the job (like a fax job_id). +%% Used for naming the output file with the extension derived from the `To' format
  • +%%
  • output_type: return the converted doc as a raw `binary' containing +%% the contents of the file or `path' to receive a path to the converted file in the response. +%% The default is `path'.
  • +%%
  • tmp_dir: the working directory where the conversion will take place.
  • +%%
  • return_metadata:Include a third option in the output tuple which is a Proplist of metadata about the file.
  • +%%
  • to_filename:The user requested destination file name for the converted file, if a full path is provided this will +%% be copied to the specified path, if a relative path is specified, it will be copied to the `tmp_dir' using the file name specified
  • +%%
+%% +%% @end +%%------------------------------------------------------------------------------ +-spec convert(kz_term:ne_binary(), kz_term:ne_binary(), binary()|{'file', kz_term:ne_binary()}, kz_term:proplist()) -> + gen_kz_converter:converted(). +convert(From, To, Content, Opts) -> + Options = maps:from_list( + [{<<"from_format">>, From} + ,{<<"to_format">>, To} + ,{<<"job_id">>, props:get_value(<<"job_id">>, Opts, kz_binary:rand_hex(12))} + | props:delete_keys([<<"job_id">>], Opts) + ]), + Filename = save_file(Content, Options), + lager:info("converting document ~s from ~s to ~s", [Filename, From, To]), + case run_convert(eval_format(From, To), To, Filename, Options) of + {'ok', _}=Ok -> + lager:info("successfully converted file: ~s to format: ~s", [Filename, To]), + Ok; + {'ok', _, _}=Ok -> + lager:info("successfully converted file: ~s to format: ~s", [Filename, To]), + Ok; + {'error', Message}=Error -> + lager:error("conversion failed with error: ~p", [Message]), + Error + end. + +%%------------------------------------------------------------------------------ +%% @doc Collects the fax related metadata from a file +%% +%% Properties returned: +%%
    +%%
  • page_count: the count of pages if file is a tiff.
  • +%%
  • size: the file size in bytes.
  • +%%
  • mime-type: the files mime-type
  • +%%
  • filetype: the files extension
  • +%%
+%% @end +%%------------------------------------------------------------------------------ +-spec read_metadata(kz_term:ne_binary()) -> kz_term:proplist(). +read_metadata(Filename) -> + read_metadata(Filename, kz_mime:from_filename(Filename)). + +%%%============================================================================= +%%% conversion functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc +%% @end +%%------------------------------------------------------------------------------ +-spec eval_format(kz_term:ne_binary(), kz_term:ne_binary()) -> fax_convert_funs() | {'error', kz_term:ne_binary()}. +eval_format(<<"image/", _SubType/binary>>, ?TIFF_MIME) -> + [fun image_to_tiff/2 + ]; +eval_format(Format, Format) -> + []; +eval_format(?TIFF_MIME, ?PDF_MIME) -> + [fun tiff_to_pdf/2 + ]; +eval_format(?PDF_MIME, ?TIFF_MIME) -> + [fun pdf_to_tiff/2 + ]; +eval_format(<>, ?TIFF_MIME) -> + [fun openoffice_to_pdf/2 + ,fun pdf_to_tiff/2 + ]; +eval_format(CT, ?TIFF_MIME) when ?OPENOFFICE_COMPATIBLE(CT) -> + [fun openoffice_to_pdf/2 + ,fun pdf_to_tiff/2 + ]; +eval_format(<>, ?TIFF_MIME) -> + [fun openoffice_to_pdf/2 + ,fun pdf_to_tiff/2 + ]; +eval_format(<>, ?PDF_MIME) -> + [fun openoffice_to_pdf/2 + ]; +eval_format(<>, ?PDF_MIME) -> + [fun openoffice_to_pdf/2 + ]; +eval_format(CT, ?PDF_MIME) when ?OPENOFFICE_COMPATIBLE(CT) -> + [fun openoffice_to_pdf/2 + ]; +eval_format(FromFormat, ToFormat) -> + {'error', <<"invalid conversion requested: ", FromFormat/binary, " to: ", ToFormat/binary>>}. + +-spec run_convert({'error', kz_term:ne_binary()} | fax_convert_funs() + ,kz_term:ne_binary() + ,kz_term:ne_binary() + ,map()) -> gen_kz_converter:converted(). +run_convert({'error', _}=Error, _ToFormat, FilePath, _Options) -> + _ = file:delete(FilePath), + Error; +run_convert([Operation|Operations], ToFormat, FilePath, Options) -> + case Operation(FilePath, Options) of + {'ok', OutputPath} -> + maybe_delete_previous_file(FilePath, OutputPath), + run_convert(Operations, ToFormat, OutputPath, Options); + Error -> + _ = file:delete(FilePath), + Error + end; +run_convert([], ToFormat, FilePath, Options) -> + case validate_output(ToFormat, FilePath, Options) of + {'ok', _} -> + format_response(ToFormat, FilePath, Options); + Error -> + _ = file:delete(FilePath), + Error + end. + +-spec image_to_tiff(kz_term:ne_binary(), map()) -> fax_converted(). +image_to_tiff(FromPath, #{<<"from_format">> := <<"image/tiff">>, <<"tmp_dir">> := TmpDir, <<"job_id">> := JobId }=Options) -> + Info = get_tiff_info(FromPath), + case select_tiff_command(Info) of + 'noop' -> + rename_file(FromPath, filename:join(TmpDir, <>)); + {'convert', Command} -> + convert_file(Command, FromPath, <<".tiff">>, Options) + end; +image_to_tiff(FromPath, Options) -> + convert_file(?CONVERT_IMAGE_COMMAND, FromPath, <<".tiff">>, Options). + +-spec tiff_to_pdf(kz_term:ne_binary(), map()) -> fax_converted(). +tiff_to_pdf(FromPath, Options) -> + convert_file(?CONVERT_TIFF_COMMAND, FromPath, <<".pdf">>, Options). + +-spec pdf_to_tiff(kz_term:ne_binary(), map()) -> fax_converted(). +pdf_to_tiff(FromPath, Options) -> + convert_file(?CONVERT_PDF_COMMAND, FromPath, <<".tiff">>, Options). + +-spec openoffice_to_pdf(kz_term:ne_binary(), map()) -> fax_converted(). +openoffice_to_pdf(FromPath, Options) -> + case ?ENABLE_OPENOFFICE of + 'true' -> + case ?SERIALIZE_OPENOFFICE of + 'true' -> kz_openoffice_server:add(FromPath, Options); + 'false' -> do_openoffice_to_pdf(FromPath, Options) + end; + 'false' -> + {'error', <<"openoffice compatible conversion is unsupported">>} + end. + +-spec do_openoffice_to_pdf(kz_term:ne_binary(), map()) -> fax_converted(). +do_openoffice_to_pdf(FromPath, Options) -> + convert_file(?CONVERT_OPENOFFICE_COMMAND, FromPath, <<".pdf">>, Options). + +-spec convert_file(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), map()) -> fax_converted(). +convert_file(Command, FromPath, Ext, #{<<"job_id">> := JobId, <<"tmp_dir">> := TmpDir}) -> + ToPath = filename:join(TmpDir, <>), + BatchPath = filename:join(TmpDir, <<(filename:rootname(filename:basename(FromPath)))/binary, Ext/binary>>), + case run_convert_command(Command, FromPath, ToPath, TmpDir) of + {'ok', _} -> maybe_rename_file(BatchPath, ToPath); + Else -> Else + end. + +-spec run_convert_command(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> + fax_converted(). +run_convert_command(Command, FromPath, ToPath, TmpDir) -> + lager:debug("converting file with command: ~s", [Command]), + Args = [{<<"FROM">>, FromPath} + ,{<<"TO">>, ToPath} + ,{<<"WORKDIR">>, TmpDir} + ], + Options = [{<<"timeout">>, ?CONVERT_TIMEOUT} + ,{<<"absolute_timeout">>, ?CONVERT_TIMEOUT} + ], + case kz_os:cmd(Command, Args, Options) of + {'ok', _} -> + {'ok', ToPath}; + {'error', Reason, Msg} -> + lager:debug("failed to convert file with reason: ~p, output: ~p", [Reason, Msg]), + _ = file:delete(ToPath), + {'error', <<"convert command failed">>} + end. + + +-spec select_tiff_command(map()) -> + {'convert', kz_term:ne_binary()} | + 'noop'. +select_tiff_command(#{<<"length">> := Height}) when Height > 1078 -> + lager:debug("file is too long, resizing"), + {'convert', ?LARGE_TIFF_COMMAND}; +select_tiff_command(#{<<"width">> := Width}) when Width > 1728 -> + lager:debug("file is too wide, resizing"), + {'convert', ?LARGE_TIFF_COMMAND}; +select_tiff_command(#{<<"width">> := Width}) when Width < 1728 -> + lager:debug("file is smaller than page, centering"), + {'convert', ?SMALL_TIFF_COMMAND}; +select_tiff_command(#{<<"res_x">> := X, <<"res_y">> := Y}) when X > 204 + orelse Y > 98 -> + lager:debug("file is wrong dpi, resampling"), + {'convert', ?CONVERT_IMAGE_COMMAND}; +select_tiff_command(#{<<"scheme">> := <<"CCITT Group 3">>, <<"has_pages">> := 'true'}) -> + 'noop'; +select_tiff_command(#{<<"scheme">> := <<"CCITT Group 4">>, <<"has_pages">> := 'true'}) -> + 'noop'; +select_tiff_command(#{}) -> + lager:debug("file has no pages, resampling to fix"), + {'convert', ?CONVERT_IMAGE_COMMAND}. + +%%%============================================================================= +%%% validate functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc +%% @end +%%------------------------------------------------------------------------------ +-spec validate_output(kz_term:ne_binary(), kz_term:ne_binary(), map()) -> + fax_converted(). +validate_output(?TIFF_MIME, Filename, #{<<"tmp_dir">> := TmpDir}) -> + OutputFile = filename:join(TmpDir, <<(kz_binary:rand_hex(16))/binary, ".pdf">>), + run_validate_command(?VALIDATE_TIFF_COMMAND, Filename, OutputFile, TmpDir); +validate_output(?PDF_MIME, Filename, #{<<"tmp_dir">> := TmpDir}) -> + OutputFile = filename:join(TmpDir, <<(kz_binary:rand_hex(16))/binary, ".pdf">>), + run_validate_command(?VALIDATE_PDF_COMMAND, Filename, OutputFile, TmpDir); +validate_output(Mime, _FilePath, _Options) -> + {'ok', <<"unsupported mime type", Mime/binary>>}. + +-spec run_validate_command(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> + fax_converted(). +run_validate_command(Command, FromPath, ToPath, TmpDir) -> + lager:debug("validating file with command: ~s", [Command]), + Args = [{<<"FROM">>, FromPath} + ,{<<"TO">>, ToPath} + ,{<<"WORKDIR">>, TmpDir} + ,{<<"FILE">>, FromPath} + ], + Options = [{<<"timeout">>, ?CONVERT_TIMEOUT} + ,{<<"absolute_timeout">>, ?CONVERT_TIMEOUT} + ], + case kz_os:cmd(Command, Args, Options) of + {'ok', _}=Ok -> + _ = file:delete(ToPath), + Ok; + {'error', Reason, Msg} -> + lager:debug("failed to validate file: ~s with reason: ~s error: ~p", [FromPath, Reason, Msg]), + _ = file:delete(ToPath), + {'error', <<"file validation failed">>} + end. + +%%%============================================================================= +%%% output formatting functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc +%% @end +%%------------------------------------------------------------------------------ +-spec format_response(kz_term:ne_binary(), kz_term:ne_binary(), map()) -> + gen_kz_converter:converted(). +format_response(ToFormat, FilePath, #{<<"output_type">> := 'binary'}=Options) -> + Metadata = maybe_read_metadata(ToFormat, FilePath, Options), + case format_output(FilePath, Options) of + {'ok', Content} when Metadata =/= [] -> + {'ok', Content, Metadata}; + {'ok', _}=Ok -> Ok; + Error -> Error + end; +format_response(ToFormat, FilePath, Options) -> + case maybe_user_filename(FilePath, Options) of + {'ok', NewPath} -> + Metadata = maybe_read_metadata(ToFormat, NewPath, Options), + case format_output(NewPath, Options) of + {'ok', Content} when Metadata =/= [] -> + {'ok', Content, Metadata}; + {'ok', _}=Ok -> Ok; + Error -> Error + end; + Error -> Error + end. + +-spec maybe_user_filename(kz_term:ne_binary(), map()) -> + {'ok', kz_term:ne_binary()} | + {'error', kz_term:ne_binary()}. +maybe_user_filename(FilePath, #{<<"to_filename">> := UserPath, <<"tmp_dir">> := TmpDir}) -> + case filename:pathtype(UserPath) of + 'absolute' -> + rename_file(FilePath, UserPath); + 'relative' -> + rename_file(FilePath, filename:join(TmpDir, UserPath)); + _ -> + {'error', <<"invalid filename ", UserPath/binary>>} + end; +maybe_user_filename(FilePath, _Options) -> + {'ok', FilePath}. + +-spec format_output(kz_term:ne_binary(), map()) -> + {'ok', binary()} | + {'error', kz_term:ne_binary()}. +format_output(FilePath, #{<<"output_type">> := 'binary'}) -> + case file:read_file(FilePath) of + {'ok', _}=Ok -> + kz_util:delete_file(FilePath), + Ok; + {'error', Reason} -> + lager:debug("failed to format output file with reason ~p", [Reason]), + {'error', <<"failed to format output file">>} + end; +format_output(FilePath, #{<<"output_type">> := 'path'}) -> + {'ok', FilePath}; +format_output(FilePath, _Options) -> + {'ok', FilePath}. + +%%%============================================================================= +%%% metadata functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc read the diff metadata to help with conversion command selection +%% @end +%%------------------------------------------------------------------------------ +-spec get_tiff_info(kz_term:ne_binary()) -> map()|{'error', any(), kz_term:ne_binary()}. +get_tiff_info(FilePath) -> + Args = [{<<"FILE">>, FilePath} + ], + case kz_os:cmd(?TIFF_INFO_CMD, Args) of + {'ok', Data} -> + parse_tiff_info([L || L <- binary:split(Data, <<"\n">>, ['global']), L =/= <<>>], #{<<"has_pages">> => 'false'}); + Error -> Error + end. + +parse_tiff_info([], Acc) -> + Acc; +parse_tiff_info([Line|Rest], Acc) -> + case Line of + <<"Width: ", Width/binary>> -> + parse_tiff_info(Rest, Acc#{<<"width">> => kz_term:to_integer(Width)}); + <<"Length: ", Length/binary>> -> + parse_tiff_info(Rest, Acc#{<<"length">> => kz_term:to_integer(Length)}); + <<"X: ", X/binary>> -> + parse_tiff_info(Rest, Acc#{<<"res_x">> => kz_term:to_integer(X)}); + <<"Y: ", Y/binary>> -> + parse_tiff_info(Rest, Acc#{<<"res_y">> => kz_term:to_integer(Y)}); + <<"Compression Scheme: ", Scheme/binary>> -> + parse_tiff_info(Rest, Acc#{<<"scheme">> => Scheme}); + <<"Page Number: ", _/binary>> -> + Acc#{<<"has_pages">> => 'true'}; + _Else -> + parse_tiff_info(Rest, Acc) + end. +%%------------------------------------------------------------------------------ +%% @doc read metadata about a file to provide information like size and page count +%% @end +%%------------------------------------------------------------------------------ +-spec maybe_read_metadata(kz_term:ne_binary(), kz_term:ne_binary(), map()) -> kz_term:proplist(). +maybe_read_metadata(MimeType, FilePath, #{<<"read_metadata">> := 'true'}) -> + read_metadata(FilePath, MimeType); +maybe_read_metadata(_, _, _) -> + []. + +-spec read_metadata(kz_term:ne_binary(), kz_term:ne_binary()) -> kz_term:proplist(). +read_metadata(Filename, MimeType) -> + [{<<"page_count">>, count_pages_command(MimeType, Filename)} + ,{<<"size">>, filelib:file_size(kz_term:to_list(Filename))} + ,{<<"mimetype">>, MimeType} + ,{<<"filetype">>, filetype_from_filename(Filename)} + ]. + +-spec count_pages_command(kz_term:ne_binary(), kz_term:ne_binary()) -> integer(). +count_pages_command(?TIFF_MIME, Filename) -> + Options = [{<<"timeout">>, ?CONVERT_TIMEOUT} + ,{<<"absolute_timeout">>, ?CONVERT_TIMEOUT} + ,{<<"read_mode">>, 'stream'} + ], + case kz_os:cmd(?COUNT_TIFF_PAGES_CMD, [{<<"FILE">>, Filename}], Options) of + {'ok', Result} -> + kz_term:to_integer(Result); + _ -> 0 + end; +count_pages_command(_MimeType, _Filename) -> 0. + +-spec filetype_from_filename(kz_term:ne_binary()) -> kz_term:ne_binary(). +filetype_from_filename(Filename) -> + filetype_from_extension(filename:extension(Filename)). + +-spec filetype_from_extension(kz_term:ne_binary()) -> kz_term:ne_binary(). +filetype_from_extension(<<$., Ext/binary>>) -> Ext. + +%%%============================================================================= +%%% util functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc +%% @end +%%------------------------------------------------------------------------------ +-spec save_file({'file', kz_term:ne_binary()}|kz_term:ne_binary(), map()) -> kz_term:ne_binary(). +save_file({'file', FilePath}, _Options) -> + FilePath; +save_file(Content, #{<<"tmp_dir">> := TmpDir + ,<<"job_id">> := JobId + ,<<"from_format">> := FromFormat + }) -> + Ext = kz_mime:to_extension(FromFormat), + FilePath = filename:join(TmpDir, <>), + kz_util:write_file(FilePath, Content), + FilePath. + +-spec maybe_delete_previous_file(kz_term:ne_binary(), kz_term:ne_binary()) -> 'ok'. +maybe_delete_previous_file(Filename, Filename) -> + 'ok'; +maybe_delete_previous_file(OldFilename, _NewFilename) -> + kz_util:delete_file(OldFilename). + +-spec maybe_rename_file(kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_term:ne_binary()}| + {'error', kz_term:ne_binary()}. +maybe_rename_file(TmpPath, NewPath) -> + case filelib:is_file(NewPath) of + 'true' -> {'ok', NewPath}; + 'false' -> rename_file(TmpPath, NewPath) + end. + +-spec rename_file(kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_term:ne_binary()}| + {'error', kz_term:ne_binary()}. +rename_file(FromPath, ToPath) -> + lager:info("renaming file from ~s to ~s", [FromPath, ToPath]), + case filelib:is_file(FromPath) of + 'true' -> + case file:rename(FromPath, ToPath) of + 'ok' -> {'ok', ToPath}; + {'error', _} -> {'error', <<"failed to rename file to ", ToPath/binary>>} + end; + 'false' -> {'error', <<"cannot rename from file: ", FromPath/binary, ", file does not exist">>} + end. diff --git a/core/kazoo_convert/src/kz_fax_converter.hrl b/core/kazoo_convert/src/kz_fax_converter.hrl new file mode 100644 index 00000000000..c21b199c139 --- /dev/null +++ b/core/kazoo_convert/src/kz_fax_converter.hrl @@ -0,0 +1,90 @@ +-ifndef(KZ_FAX_CONVERTER_HRL). + +-include_lib("kazoo_convert/include/kz_convert.hrl"). + +-define(TIFF_TO_PDF_CMD, <<"tiff2pdf -o $TO $FROM">>). +-define(CONVERT_PDF_CMD + ,<<"/usr/bin/gs -q " + "-r204x98 " + "-g1728x1078 " + "-dNOPAUSE " + "-dBATCH " + "-dSAFER " + "-sDEVICE=tiffg4 " + "-sOutputFile=$TO -- $FROM" + >> + ). +-define(CONVERT_IMAGE_CMD, <<"convert $FROM " + "-resample 204x98 " + "-units PixelsPerInch " + "-size 1728x1078 " + "-compress group4 $TO" + >> + ). +-define(RESIZE_TIFF_CMD, <<"convert $FROM " + "-resample 204x98 " + "-units PixelsPerInch " + "-resize 1728\\!x1078 " + "-compress group4 $TO" + >> + ). +-define(EMBIGGEN_TIFF_CMD, <<"convert $FROM " + "-gravity center " + "-resample 204x98 " + "-units PixelsPerInch " + "-extent 1728x1078 " + "-compress group4 $TO" + >> + ). + +-define(CONVERT_OPENOFFICE_CMD, <<"libreoffice " + "--headless " + "--convert-to pdf $FROM " + "--outdir $WORKDIR " + " 2>&1 " + "|egrep 'parser error|Error' " + "&& exit 1 || exit 0" + >> + ). + +-define(TIFF_INFO_CMD, <<"tiffinfo $FILE " + "|egrep 'Page|Width|Resolution|Compression' " + "|sed 's|Image Width: \\([0-9]*\\) Image Length: \\([0-9]*\\)|Width: \\1\\nLength: \\2\\n|g' " + "|sed 's|Resolution: \\([0-9]*\\), \\([0-9]*\\)|X: \\1\\nY: \\2\\n|g' " + "|sed 's/^[ \\t]*//g'" + >>). + +-define(COUNT_TIFF_PAGES_CMD, <<"echo -n `tiffinfo $FILE | grep 'Page Number' | grep -c 'P'`">>). + +-define(VALIDATE_PDF_CMD, <<"gs -dNOPAUSE -dBATCH -sDEVICE=nullpage $FILE">>). + +-define(VALIDATE_TIFF_CMD, <<"tiffinfo $FILE">>). + +-define(CONVERT_IMAGE_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"convert_image_command">>], ?CONVERT_IMAGE_CMD)). +-define(LARGE_TIFF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"large_tiff_command">>], ?RESIZE_TIFF_CMD)). +-define(SMALL_TIFF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"small_tiff_command">>], ?EMBIGGEN_TIFF_CMD)). +-define(CONVERT_PDF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"convert_pdf_command">>], ?CONVERT_PDF_CMD)). +-define(VALIDATE_PDF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"validate_pdf_command">>], ?VALIDATE_PDF_CMD)). +-define(CONVERT_TIFF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"convert_tiff_command">>], ?TIFF_TO_PDF_CMD)). +-define(VALIDATE_TIFF_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"validate_tiff_command">>], ?VALIDATE_TIFF_CMD)). +-define(CONVERT_OPENOFFICE_COMMAND + ,kapps_config:get_binary(?CONFIG_CAT, [<<"fax">>, <<"convert_openoffice_command">>], ?CONVERT_OPENOFFICE_CMD)). + +-define(SERIALIZE_OPENOFFICE + ,kapps_config:get_is_true(?CONFIG_CAT, [<<"fax">>, <<"serialize_openoffice">>], true)). + +-define(ENABLE_OPENOFFICE + ,kapps_config:get_is_true(?CONFIG_CAT, [<<"fax">>, <<"enable_openoffice">>], true)). + +-define(CONVERT_TIMEOUT + ,kapps_config:get_integer(?CONFIG_CAT, [<<"fax">>, <<"convert_command_timeout">>], 120 * ?MILLISECONDS_IN_SECOND)). + +-define(KZ_FAX_CONVERTER_HRL, 'true'). +-endif. diff --git a/core/kazoo_convert/src/kz_openoffice_server.erl b/core/kazoo_convert/src/kz_openoffice_server.erl new file mode 100644 index 00000000000..862655bba4c --- /dev/null +++ b/core/kazoo_convert/src/kz_openoffice_server.erl @@ -0,0 +1,162 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @author Sean Wysor +%%% @end +%%%----------------------------------------------------------------------------- +-module(kz_openoffice_server). + +-behaviour(gen_server). + +%% API +-export([start_link/0 + ,add/2 + ]). + +%% gen_server callbacks +-export([init/1 + ,handle_call/3 + ,handle_cast/2 + ,handle_info/2 + ,terminate/2 + ,code_change/3 + ]). + +-include_lib("kazoo_convert/include/kz_convert.hrl"). +-include_lib("kazoo_stdlib/include/kz_types.hrl"). + +-define(SERVER, {'local', ?MODULE}). + +-define(TIMEOUT_LIFETIME, ?MILLISECONDS_IN_SECOND). +-define(TIMEOUT_CANCEL_JOB, 120 * ?MILLISECONDS_IN_SECOND). +-define(TIMEOUT_DEQUEUE, 'dequeue'). +-define(TIMEOUT_CANCEL, 'cancel_job'). + +-record(state, {queue = queue:new() :: queue:queue() + ,timer_ref :: reference() + }). +-type state() :: #state{}. + +%%%============================================================================= +%%% API +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Starts the server. +%% @end +%%------------------------------------------------------------------------------ +-spec start_link() -> kz_types:startlink_ret(). +start_link() -> + gen_server:start_link(?SERVER, ?MODULE, [], []). + + +%%------------------------------------------------------------------------------ +%% @doc convert an openoffice document, respecting the one document at a time +%% constraint. +%% +%% Openoffice does not permit simultanious conversions, so this gen_server is a +%% serialization mechanism for conversions from openoffice formats. +%% @end +%%------------------------------------------------------------------------------ +-spec add(kz_term:ne_binary(), map()) -> {'ok', kz_term:ne_binary()}|{'error', kz_term:ne_binary()}. +add(Source, Options) -> + gen_server:call(?MODULE, {'add', Source, Options}). + +%%%============================================================================= +%%% gen_server callbacks +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Initializes the server. +%% @end +%%------------------------------------------------------------------------------ +-spec init(list()) -> {'ok', state()} | + {'stop', any()}. +init([]) -> + {'ok', #state{ + 'timer_ref' = start_timer(?TIMEOUT_DEQUEUE) + }}. + +%%------------------------------------------------------------------------------ +%% @doc Handling call messages. +%% @end +%%------------------------------------------------------------------------------ +-spec handle_call({atom(), {'file', kz_term:ne_binary()}, map()}, kz_term:pid_ref(), state()) -> + kz_types:handle_call_ret_state(state()). +handle_call('stop', _From, #state{} = State) -> + {'stop', 'normal', 'ok', State}; +handle_call({'add', Content, Options}, From, #state{queue=Queue}=State) -> + DropTimerRef = start_timer(?TIMEOUT_CANCEL), + {'noreply', State#state{queue=queue:in({DropTimerRef, From, Content, Options}, Queue)}}. + +%%------------------------------------------------------------------------------ +%% @doc Handling cast messages. +%% @end +%%------------------------------------------------------------------------------ +-spec handle_cast(any(), state()) -> kz_types:handle_cast_ret_state(state()). +handle_cast(_Msg, State) -> + {'noreply', State}. + +%%------------------------------------------------------------------------------ +%% @doc Handling all non call/cast messages. +%% @end +%%------------------------------------------------------------------------------ +-spec handle_info(any(), state()) -> kz_types:handle_info_ret_state(state()). +handle_info({'timeout', TRef, ?TIMEOUT_DEQUEUE}, #state{queue=Queue, timer_ref=TRef}=State) -> + case queue:out(Queue) of + {'empty', _} -> + {'noreply', State#state{timer_ref=start_timer(?TIMEOUT_DEQUEUE)}}; + {{'value', {DropTimerRef, From, Content, Options}}, NewQueue} -> + _ = erlang:cancel_timer(DropTimerRef), + gen_server:reply(From, kz_fax_converter:do_openoffice_to_pdf(Content, Options)), + {'noreply', State#state{timer_ref=start_timer(?TIMEOUT_DEQUEUE), queue=NewQueue}} + end; +handle_info({'timeout', DropTimerRef, ?TIMEOUT_CANCEL}, #state{queue=Queue}=State) -> + Fun = fun({TimerRef, _, _, _}) when DropTimerRef =:= TimerRef -> false; + (_) -> true + end, + {'noreply', State#state{queue=queue:filter(Fun, Queue)}}; +handle_info(_Info, State) -> + lager:debug("unhandled message: ~p", [_Info]), + {'noreply', State, 'hibernate'}. + +%%------------------------------------------------------------------------------ +%% @doc This function is called by a `gen_server' when it is about to +%% terminate. It should be the opposite of `Module:init/1' and do any +%% necessary cleaning up. When it returns, the `gen_server' terminates +%% with Reason. The return value is ignored. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec terminate(any(), state()) -> 'ok'. +terminate(_Reason, #state{timer_ref=TimerRef}) -> + _ = stop_timer(TimerRef), + lager:debug("openoffice_server going down: ~p", [_Reason]). + +%%------------------------------------------------------------------------------ +%% @doc Convert process state when code is changed. +%% @end +%%------------------------------------------------------------------------------ +-spec code_change(any(), state(), any()) -> {'ok', state()}. +code_change(_OldVsn, State, _Extra) -> + {'ok', State}. + +%%%============================================================================= +%%% Internal functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc +%% @end +%%------------------------------------------------------------------------------ +-spec start_timer(atom()) -> reference(). +start_timer(?TIMEOUT_DEQUEUE) -> + erlang:start_timer(?TIMEOUT_LIFETIME, self(), ?TIMEOUT_DEQUEUE); +start_timer(?TIMEOUT_CANCEL) -> + erlang:start_timer(?TIMEOUT_CANCEL_JOB, self(), ?TIMEOUT_CANCEL). + + +-spec stop_timer(reference()) -> integer() | boolean() | 'ok'. +stop_timer(Ref) -> + erlang:cancel_timer(Ref). + diff --git a/core/kazoo_convert/src/kz_openoffice_server_sup.erl b/core/kazoo_convert/src/kz_openoffice_server_sup.erl new file mode 100644 index 00000000000..f0e9cf9396d --- /dev/null +++ b/core/kazoo_convert/src/kz_openoffice_server_sup.erl @@ -0,0 +1,66 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2012-2018, 2600Hz +%%% @doc +%%% @author Sean Wysor +%%% @end +%%%----------------------------------------------------------------------------- +-module(kz_openoffice_server_sup). + +-behaviour(supervisor). + +%% API +-export([start_link/0]). + +%% Supervisor callbacks +-export([init/1]). + +-include("kz_convert.hrl"). + +-define(SERVER, {'local', ?MODULE}). + +-define(CHILDREN, [ + #{id => kz_openoffice_server + ,start => { kz_openoffice_server, start_link, []} + ,restart => permanent + ,shutdown => 5000 + ,type => worker + ,modules => [kz_openoffice_server] + } + ]). + +%%%============================================================================= +%%% API functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Starts the supervisor. +%% @end +%%------------------------------------------------------------------------------ +-spec start_link() -> kz_types:startlink_ret(). +start_link() -> + supervisor:start_link(?SERVER, ?MODULE, []). + +%%%============================================================================= +%%% Supervisor callbacks +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Whenever a supervisor is started using `supervisor:start_link/[2,3]', +%% this function is called by the new process to find out about +%% restart strategy, maximum restat rt frequency and child +%% specifications. +%% @end +%%------------------------------------------------------------------------------ +-spec init(any()) -> kz_types:sup_init_ret(). +init([]) -> + RestartStrategy = 'one_for_one', + MaxRestarts = 10, + MaxSecondsBetweenRestarts = 10, + + SupFlags = {RestartStrategy, MaxRestarts, MaxSecondsBetweenRestarts}, + + {'ok', {SupFlags, ?CHILDREN}}. + +%%%============================================================================= +%%% Internal functions +%%%============================================================================= diff --git a/core/kazoo_convert/test/kz_convert_tests.erl b/core/kazoo_convert/test/kz_convert_tests.erl new file mode 100644 index 00000000000..5db30269353 --- /dev/null +++ b/core/kazoo_convert/test/kz_convert_tests.erl @@ -0,0 +1,702 @@ +%%%----------------------------------------------------------------------------- +%%% @copyright (C) 2010-2018, 2600Hz +%%% @doc Test fax conversions. +%%% @author Sean Wysor +%%% @end +%%%----------------------------------------------------------------------------- +-module(kz_convert_tests). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("kazoo_convert/include/kz_convert.hrl"). + +fax_test_() -> + {setup + ,fun setup/0 + ,fun cleanup/1 + ,fun(_) -> + [test_tiff_to_pdf_binary() + ,test_tiff_to_pdf_tuple() + ,test_tiff_to_tiff_binary() + ,test_tiff_to_tiff_tuple() + ,test_pdf_to_tiff_binary() + ,test_pdf_to_tiff_tuple() + ,test_openoffice_to_pdf_binary() + ,test_openoffice_to_pdf_tuple() + ,test_openoffice_to_tiff_binary() + ,test_openoffice_to_tiff_tuple() + ,test_tiff_to_pdf_binary_output_binary() + ,test_tiff_to_pdf_tuple_output_binary() + ,test_tiff_to_tiff_binary_output_binary() + ,test_tiff_to_tiff_tuple_output_binary() + ,test_pdf_to_tiff_binary_output_binary() + ,test_pdf_to_tiff_tuple_output_binary() + ,test_openoffice_to_pdf_binary_output_binary() + ,test_openoffice_to_pdf_tuple_output_binary() + ,test_openoffice_to_tiff_binary_output_binary() + ,test_openoffice_to_tiff_tuple_output_binary() + ,test_tiff_to_pdf_binary_invalid() + ,test_tiff_to_pdf_tuple_invalid() + ,test_tiff_to_tiff_binary_invalid() + ,test_tiff_to_tiff_tuple_invalid() + ,test_pdf_to_tiff_binary_invalid() + ,test_pdf_to_tiff_tuple_invalid() + ,test_openoffice_to_pdf_binary_invalid() + ,test_openoffice_to_pdf_tuple_invalid() + ,test_openoffice_to_tiff_binary_invalid() + ,test_openoffice_to_tiff_tuple_invalid() + ,test_tiff_to_pdf_nonexistent_file() + ,test_tiff_to_tiff_nonexistent_file() + ,test_pdf_to_tiff_nonexistent_file() + ,test_openoffice_to_pdf_nonexistent_file() + ,test_openoffice_to_tiff_nonexistent_file() + ,test_invalid_conversion() + ,test_empty_content() + ,test_empty_filename() + ,test_tiff_to_tiff_to_filename() + ,test_pdf_to_tiff_to_filename() + ,test_openoffice_to_tiff_to_filename() + ,test_tiff_to_tiff_read_metadata() + ,test_tiff_to_tiff_small_file_read_metadata() + ,test_tiff_to_tiff_legal_file_read_metadata() + ,test_pdf_to_tiff_read_metadata() + ,test_openoffice_to_tiff_read_metadata() + ,test_read_metadata() + ] + end + }. + +setup() -> + LinkPid = kzd_test_fixtures:setup(), + {'ok', SupPid} = kz_openoffice_server_sup:start_link(), + lager:set_loglevel('lager_console_backend', 'none'), + lager:set_loglevel('lager_file_backend', 'none'), + lager:set_loglevel('lager_syslog_backend', 'none'), + {LinkPid, SupPid}. + +cleanup({LinkPid, SupPid}) -> + kzd_test_fixtures:cleanup(LinkPid), + _ = erlang:exit(SupPid, 'normal'). + +read_test_file(Filename) -> + {'ok', Content} = file:read_file(get_path_to_fixture(Filename)), + Content. + +copy_fixture_to_tmp(Filename) -> + JobId = kz_binary:rand_hex(16), + SrcFile = get_path_to_fixture(Filename), + DstFile = kz_term:to_binary(["/tmp/", JobId, filename:extension(Filename) ]), + {'ok', _} = file:copy(SrcFile, DstFile), + DstFile. + +get_path_to_fixture(Filename) -> + case code:priv_dir('kazoo_fixturedb') of + {'error', 'bad_name'}=Error -> Error; + PrivPath -> + kz_term:to_binary([PrivPath, "/media_files/", Filename]) + end. + +test_tiff_to_pdf_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid-multipage.tiff"), + Expected = <<"/tmp/", JobId/binary, ".pdf" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_pdf_tuple() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid-multipage.tiff"), + Expected = <<"/tmp/", JobId/binary, ".pdf" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.tiff"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_tuple() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.tiff"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_pdf_to_tiff_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.pdf"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>,JobId}] + ) + ) + ]. + +test_pdf_to_tiff_tuple() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.pdf"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_pdf_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + Expected = <<"/tmp/", JobId/binary, ".pdf" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_pdf_tuple() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.docx"), + Expected = <<"/tmp/", JobId/binary, ".pdf" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_tiff_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_tiff_tuple() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.docx"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_pdf_binary_output_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid-multipage.tiff"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_tiff_to_pdf_tuple_output_binary() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid-multipage.tiff"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}, {<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_tiff_to_tiff_binary_output_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.tiff"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}, {<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_tiff_to_tiff_tuple_output_binary() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.tiff"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}, {<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_pdf_to_tiff_binary_output_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.pdf"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId},{<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_pdf_to_tiff_tuple_output_binary() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.pdf"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}, {<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_openoffice_to_pdf_binary_output_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId},{<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_openoffice_to_pdf_tuple_output_binary() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.docx"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId} + ,{<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_openoffice_to_tiff_binary_output_binary() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + [?_assertMatch({'ok', _}, kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"output_type">>, 'binary'}] + ) + ) + ]. + +test_openoffice_to_tiff_tuple_output_binary() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.docx"), + [?_assertMatch({'ok', _} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_pdf_binary_invalid() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("invalid.tiff"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_pdf_tuple_invalid() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("invalid.tiff"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_binary_invalid() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("invalid.tiff"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_tuple_invalid() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("invalid.tiff"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_pdf_to_tiff_binary_invalid() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("invalid.pdf"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_pdf_to_tiff_tuple_invalid() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("invalid.pdf"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_pdf_binary_invalid() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("invalid.docx"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,From + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_pdf_tuple_invalid() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("invalid.docx"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_tiff_binary_invalid() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("invalid.docx"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId}] + ) + )]. + +test_openoffice_to_tiff_tuple_invalid() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("invalid.docx"), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_pdf_nonexistent_file() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,{'file', <<"/tmp/not_a_file.tiff">>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_nonexistent_file() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,{'file', <<"/tmp/not_a_file.tiff">>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_pdf_to_tiff_nonexistent_file() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"convert command failed">>} + ,kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,{'file', <<"not_a_file.pdf">>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_pdf_nonexistent_file() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"cannot rename from file: ", _/binary >>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"application/pdf">> + ,{'file', <<"/tmp/no_a_file.docx">>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_openoffice_to_tiff_nonexistent_file() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"cannot rename from file: ", _/binary >>} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,{'file', <<"/tmp/not_a_file.docx">>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + + +test_invalid_conversion() -> + JobId = kz_binary:rand_hex(16), + Src = copy_fixture_to_tmp("valid.pdf"), + [?_assertMatch({'error', <<"invalid conversion requested:", _/binary>>} + ,kz_convert:fax(<<"application/pdf">> + ,<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,{'file', Src} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_empty_filename() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"empty filename">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,{'file', <<>>} + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_empty_content() -> + JobId = kz_binary:rand_hex(16), + [?_assertMatch({'error', <<"empty content">>} + ,kz_convert:fax(<<"image/tiff">> + ,<<"application/pdf">> + ,<<>> + ,[{<<"job_id">>, JobId}] + ) + ) + ]. + +test_tiff_to_tiff_to_filename() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.tiff"), + Expected = <<"/tmp/", (kz_binary:rand_hex(16))/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected} + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"to_filename">>, Expected} + ] + ) + ) + ]. + + +test_pdf_to_tiff_to_filename() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.pdf"), + Expected = <<"/tmp/", (kz_binary:rand_hex(16))/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected} + ,kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"to_filename">>, Expected} + ] + ) + ) + ]. + +test_openoffice_to_tiff_to_filename() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + Expected = <<"/tmp/", (kz_binary:rand_hex(16))/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected} + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"to_filename">>, Expected} + ] + ) + ) + ]. + +test_tiff_to_tiff_read_metadata() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.tiff"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected + ,[{<<"page_count">>, 1} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + } + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"read_metadata">>, true} + ] + ) + ) + ]. + +test_tiff_to_tiff_small_file_read_metadata() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("small.tiff"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected + ,[{<<"page_count">>, 1} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + } + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"read_metadata">>, true} + ] + ) + ) + ]. + +test_tiff_to_tiff_legal_file_read_metadata() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("legal.tiff"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected + ,[{<<"page_count">>, 2} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + } + ,kz_convert:fax(<<"image/tiff">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"read_metadata">>, true} + ] + ) + ) + ]. + + +test_pdf_to_tiff_read_metadata() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.pdf"), + Expected = <<"/tmp/", JobId/binary, ".tiff" >>, + [?_assertMatch({'ok', Expected + ,[{<<"page_count">>, 1} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + } + ,kz_convert:fax(<<"application/pdf">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"read_metadata">>, true} + ] + ) + ) + ]. + +test_openoffice_to_tiff_read_metadata() -> + JobId = kz_binary:rand_hex(16), + From = read_test_file("valid.docx"), + Expected = <<"/tmp/", JobId/binary, ".tiff">>, + [?_assertMatch({'ok', Expected + ,[{<<"page_count">>, 1} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + } + ,kz_convert:fax(<<"application/vnd.openxmlformats-officedocument.wordprocessingml.document">> + ,<<"image/tiff">> + ,From + ,[{<<"job_id">>, JobId} + ,{<<"read_metadata">>, true} + ] + ) + ) + ]. + +test_read_metadata() -> + Src = copy_fixture_to_tmp("valid.tiff"), + [?_assertMatch([{<<"page_count">>, 1} + ,{<<"size">>, _} + ,{<<"mimetype">>, <<"image/tiff">>} + ,{<<"filetype">>, <<"tiff">>} + ] + ,kz_fax_converter:read_metadata(Src) + ) + ]. diff --git a/core/kazoo_documents/src/kzd_fax.erl b/core/kazoo_documents/src/kzd_fax.erl index 5eae9ca498e..29726024550 100644 --- a/core/kazoo_documents/src/kzd_fax.erl +++ b/core/kazoo_documents/src/kzd_fax.erl @@ -28,6 +28,21 @@ ,size/1, size/2 ,pages/1, pages/2 ,retry_after/1, retry_after/2 + ] + ). + +-export([save_outbound_fax/4]). + +-export([fetch_attachment_format/3, fetch_faxable_attachment/2, fetch_faxable_attachment/3 + ,fetch_pdf_attachment/2, fetch_pdf_attachment/3 + ,fetch_original_attachment/2, fetch_original_attachment/3 + ,fetch_legacy_attachment/2, fetch_legacy_attachment/3 + ,fetch_attachment_url/1 + ]). + +-export([save_fax_attachments/3 + ,save_fax_doc/5 + ,save_fax_attachment/5 ]). -include("kz_documents.hrl"). @@ -60,6 +75,14 @@ -define(PVT_TYPE, <<"fax">>). +-define(ORIGINAL_FILE_PREFIX, "original_file"). +-define(FAX_FILENAME, <<"fax_file.tiff">>). +-define(PDF_FILENAME, <<"pdf_file.pdf">>). + +-define(RETRY_SAVE_ATTACHMENT_DELAY, 5000). + +-define(FAX_CONFIG_CAT, <<"fax">>). + -spec new() -> doc(). new() -> kz_json:from_list([{<<"pvt_type">>, type()}]). @@ -226,3 +249,422 @@ retry_after(FaxDoc) -> -spec retry_after(doc(), Default) -> integer() | Default. retry_after(FaxDoc, Default) -> kz_json:get_integer_value(?KEY_RETRY_AFTER, FaxDoc, Default). + +%%%============================================================================= +%%% attachment handling functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Faxes pre-convert attachment documents to tiff/pdf on ingress and save +%% all these formats to the db as attachments. +%% +%% If configured, the fax document for outbound faxes will contain a copy of the +%% post conversion fax tiff file and a pdf representation of this file. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec save_outbound_fax(kz_term:ne_binary(), kz_json:object(), kz_term:api_binary(), kz_term:api_binary()) -> + {'ok', kz_json:object()} | + {'error', any()}. +save_outbound_fax(Db, Doc, 'undefined', _) -> + case fetch_attachment_url(Doc) of + {'ok', Content, ContentType} -> + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_url_document">>, true) of + 'true' -> save_outbound_fax(Db, Doc, Content, ContentType); + 'false' -> {'ok', Doc} + end; + Error -> Error + end; +save_outbound_fax(Db, Doc, Original, ContentType) -> + Id = kz_doc:id(Doc), + Name = <>, + Att = {Original, ContentType, Name}, + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_fax_tiff">>, true) of + 'true' -> + case convert_to_fax(ContentType, Original, Id) of + {'ok', Tiff, Props} -> + NewDoc = update_fax_props(Doc, Props), + save_fax_attachments(Db, NewDoc, [Att, {Tiff, ContentType, ?FAX_FILENAME}|maybe_convert_to_pdf(Tiff, Id)]); + Error -> Error + end; + 'false' -> + save_fax_attachments(Db, Doc, [Att]) + end. + +-spec update_fax_props(kz_json:object(), kz_term:proplist()) -> kz_json:object(). +update_fax_props(Doc, Props) -> + kz_json:set_values([{<<"pvt_pages">>, props:get_value(<<"page_count">>, Props, 0)} + ,{<<"pvt_size">>, props:get_value(<<"size">>, Props, 0)} + ] + ,Doc + ). + +-spec maybe_convert_to_pdf(kz_term:ne_binary(), kz_term:ne_binary()) -> + [{kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()}] | []. +maybe_convert_to_pdf(Content, Id) -> + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_fax_pdf">>, true) of + 'true' -> + case convert_fax_to_pdf(Content, Id) of + {'ok', Pdf} -> [{Pdf, <<"application/pdf">>, ?PDF_FILENAME}]; + _Error -> [] + end; + 'false' -> [] + end. + +-spec convert_to_fax(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_term:ne_binary(), kz_term:proplist()} | + {'error', any()}. +convert_to_fax(FromFormat, File, Id) -> + Options = [{<<"output_type">>, 'binary'} + ,{<<"job_id">>, Id} + ,{<<"read_metadata">>, 'true'} + ], + kz_convert:fax(FromFormat, <<"image/tiff">>, File, Options). + +-spec convert_fax_to_pdf(kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_term:ne_binary()} | + {'error', any()}. +convert_fax_to_pdf(Content, Id) -> + Options = [{<<"output_type">>, 'binary'} + ,{<<"job_id">>, Id} + ], + kz_convert:fax(<<"image/tiff">>, <<"application/pdf">>, Content, Options). + +%%%============================================================================= +%%% attachment getter functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing attachments suitable for faxing. +%% +%% Saves the document if it is not present and store_fax_tiff is true. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_attachment_format(kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_attachment_format(<<"original">>, Db, Doc) -> + fetch_original_attachment(Db, Doc); +fetch_attachment_format(<<"pdf">>, Db, Doc) -> + fetch_pdf_attachment(Db, Doc); +fetch_attachment_format(<<"tiff">>, Db, Doc) -> + fetch_faxable_attachment(Db, Doc); +fetch_attachment_format(_, _, _) -> + {'error', <<"invalid format for attachment">>}. + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing attachments suitable for faxing. +%% +%% Saves the document if it is not present and store_fax_tiff is true. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_faxable_attachment(kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', any()}. +fetch_faxable_attachment(Db, Doc) -> + fetch_faxable_attachment(Db, Doc, kz_doc:attachment_names(Doc)). + +-spec fetch_faxable_attachment(kz_term:ne_binary(), kz_json:object(), list()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_faxable_attachment(Db, Doc, [?FAX_FILENAME|_]) -> + case kz_datamgr:fetch_attachment(Db, kz_doc:id(Doc), ?FAX_FILENAME) of + {'ok', Content} -> {'ok', Content, <<"image/tiff">>, Doc}; + Error -> Error + end; +fetch_faxable_attachment(Db, Doc, [_|Attachments]) -> + fetch_faxable_attachment(Db, Doc, Attachments); +fetch_faxable_attachment(Db, Doc, []) -> + case fetch_original_attachment(Db, Doc) of + {'ok', Content, ContentType, Doc} -> + case convert_to_fax(ContentType, Content, kz_doc:id(Doc)) of + {'ok', Tiff, Props} -> + NewDoc = update_fax_props(Doc, Props), + NewerDoc = maybe_save_faxable(Db, NewDoc, Content), + {'ok', Tiff, <<"image/tiff">>, NewerDoc}; + Error -> Error + end; + Error -> Error + end. + +-spec maybe_save_faxable(kz_term:ne_binary(), kz_json:object(), kz_term:ne_binary()) -> kz_json:object(). +maybe_save_faxable(Db, Doc, Content) -> + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_fax_tiff">>, true) of + 'true' -> + case save_fax_doc(Db, Doc, Content, <<"image/tiff">>, ?FAX_FILENAME) of + {'ok', NewDoc} -> NewDoc; + _Else -> + lager:error("failed to save fax tiff attachment for document ~p", [kz_doc:id(Doc)]), + Doc + end; + 'false' -> Doc + end. + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing/creating pdf attachment suitable for email/api response. +%% +%% Saves the document if it is not present and store_fax_pdf is true. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_pdf_attachment(kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_pdf_attachment(Db, Doc) -> + fetch_pdf_attachment(Db, Doc, kz_doc:attachment_names(Doc)). + +-spec fetch_pdf_attachment(kz_term:ne_binary(), kz_json:object(), list()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_pdf_attachment(Db, Doc, [?PDF_FILENAME|_]) -> + case kz_datamgr:fetch_attachment(Db, kz_doc:id(Doc), ?PDF_FILENAME) of + {'ok', Content} -> {'ok', Content, <<"application/pdf">>, Doc}; + Error -> Error + end; +fetch_pdf_attachment(Db, Doc, [_|Attachments]) -> + fetch_pdf_attachment(Db, Doc, Attachments); +fetch_pdf_attachment(Db, Doc, []) -> + case fetch_faxable_attachment(Db, Doc) of + {'ok', Content, _, NewDoc} -> + case convert_fax_to_pdf(Content, kz_doc:id(NewDoc)) of + {'ok', Pdf} -> + NewerDoc = maybe_save_pdf(Db, Pdf, NewDoc), + {'ok', Pdf, <<"application/pdf">>, NewerDoc}; + Error -> Error + end; + Error -> Error + end. + +-spec maybe_save_pdf(kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()) -> + kz_json:object(). +maybe_save_pdf(Db, Pdf, Doc) -> + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_fax_pdf">>, true) of + 'true' -> + case save_fax_doc(Db, Doc, Pdf, <<"application/pdf">>, ?PDF_FILENAME) of + {'ok', NewDoc} -> NewDoc; + _Else -> + lager:error("failed to save pdf attachment for document ~p", [kz_doc:id(Doc)]), + Doc + end; + 'false' -> Doc + end. + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing/creating original attachment. +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_original_attachment(kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_original_attachment(Db, Doc) -> + fetch_original_attachment(Db, Doc, kz_doc:attachment_names(Doc)). + +-spec fetch_original_attachment(kz_term:ne_binary(), kz_json:object(), list()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_original_attachment(Db, Doc, [{<>=Name}|_]) -> + case kz_datamgr:fetch_attachment(Db, kz_doc:id(Doc), Name) of + {'ok', Content} -> {'ok', Content, kz_doc:attachment_content_type(Doc, Name), Doc}; + Error -> Error + end; +fetch_original_attachment(Db, Doc, [_|Attachments]) -> + fetch_original_attachment(Db, Doc, Attachments); +fetch_original_attachment(Db, Doc, []) -> + fetch_legacy_attachment(Db, Doc). + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing a legacy attachment. +%% +%% Checks if any attachments are present on the document. If none our found +%% attempts to fetch an attachment url if present on the doc +%% +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_legacy_attachment(kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_legacy_attachment(Db, Doc) -> + fetch_legacy_attachment(Db, Doc, kz_doc:attachment_names(Doc)). + +-spec fetch_legacy_attachment(kz_term:ne_binary(), kz_json:object(), list()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary(), kz_json:object()} | + {'error', kz_term:ne_binary()}. +fetch_legacy_attachment(Db, Doc, [Name|_]) -> + case kz_datamgr:fetch_attachment(Db, kz_doc:id(Doc), Name) of + {'ok', Content} -> + {'ok', Content, kz_doc:attachment_content_type(Doc, Name), Doc}; + Error -> Error + end; +fetch_legacy_attachment(Db, Doc, []) -> + case fetch_attachment_url(Doc) of + {'ok', Content, ContentType} -> + NewDoc = maybe_store_url_attachment(Db, Doc, Content, ContentType), + {'ok', Content, ContentType, NewDoc}; + Error -> Error + end. + +%%------------------------------------------------------------------------------ +%% @doc Helper function for accessing a url attachment document. +%% +%% Saves the document if it is not present and store_url_doc is true. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec fetch_attachment_url(kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary()} | + {'error', kz_term:ne_binary()}. +fetch_attachment_url(Doc) -> + case document(Doc) of + [] -> + lager:info("no attachment found on doc ~s", [kz_doc:id(Doc)]), + {'error', <<"no attachment found">>}; + FetchRequest -> + fetch_attachment_url(document_url(Doc), FetchRequest) + end. + +-spec fetch_attachment_url(kz_term:api_binary(), kz_json:object()) -> + {'ok', kz_term:ne_binary(), kz_term:ne_binary()} | + {'error', kz_term:ne_binary()}. +fetch_attachment_url('undefined', _) -> + {'error', <<"attachment not found">>}; +fetch_attachment_url(Url, FetchRequest) -> + Method = kz_term:to_atom(kz_json:get_value(<<"method">>, FetchRequest, <<"get">>), 'true'), + Headers = props:filter_undefined( + [{"Host", kz_json:get_string_value(<<"host">>, FetchRequest)} + ,{"Referer", kz_json:get_string_value(<<"referer">>, FetchRequest)} + ,{"Content-Type", kz_json:get_string_value(<<"content_type">>, FetchRequest, <<"text/plain">>)} + ]), + Body = kz_json:get_string_value(<<"content">>, FetchRequest, ""), + lager:debug("making ~s request to '~s'", [Method, Url]), + case kz_http:req(Method, Url, Headers, Body) of + {'ok', 200, RespHeaders, Contents} -> + DefaultCt = kz_mime:from_filename(Url), + CT = props:get_value("Content-Type", RespHeaders, DefaultCt), + ContentType = kz_mime:normalize_content_type(CT), + {'ok', Contents, ContentType}; + {'ok', Status, _, _} -> + lager:error("failed to fetch file for job from: ~s, http response: ~b", [Url, Status]); + {'error', Reason} -> + lager:error("failed to fetch file from: ~s for job: ~p", [Url, Reason]) + end. + +-spec maybe_store_url_attachment(kz_term:ne_binary(), kz_json:object(), kz_term:ne_binary(), kz_term:ne_binary()) -> + kz_json:object(). +maybe_store_url_attachment(Db, Doc, Content, ContentType) -> + case kapps_config:get_is_true(?FAX_CONFIG_CAT, <<"store_url_document">>, true) of + true -> + case save_outbound_fax(Db, Doc, Content, ContentType) of + {'ok', NewDoc} -> + NewDoc; + {'error', Msg} -> + lager:info("failed to save url doc with error ~p", [Msg]), + Doc + end; + false -> + Doc + end. + +%%%============================================================================= +%%% attachment save functions +%%%============================================================================= + +%%------------------------------------------------------------------------------ +%% @doc Common method for the safe saving of attachments. +%% +%% Bigcouch sometimes has issues where it returns a 409 when attaching files +%% it then actually attaches the file. When this happens it increments the rev +%% without indicating this in the response. To avoid this condition. If a save +%% fails, check the doc for an attachment and return success response if the +%% attachment is found. +%% +%% @end +%%------------------------------------------------------------------------------ +-spec save_fax_attachments(kz_term:ne_binary(), kz_json:object(), list()) -> + {'ok', kz_json:object()} | + {'error', any()}. +save_fax_attachments(Db, Doc, [{Content, CT, Name}|Files]) -> + case save_fax_doc(Db, Doc, Content, CT, Name) of + {'ok', NewDoc} -> + save_fax_attachments(Db, NewDoc, Files); + Error -> Error + end; +save_fax_attachments(_, Doc, []) -> + {'ok', Doc}. + +-spec maybe_save_fax_doc(kz_term:ne_binary(), kz_json:object()) -> + {'ok', kz_json:object()} | + {'error', any()}. +maybe_save_fax_doc(Db, Doc) -> + case kz_doc:revision(Doc) of + 'undefined' -> + lager:debug("saving fax doc with id ~s and rev ~s", [kz_doc:id(Doc), kz_doc:revision(Doc)]), + kz_datamgr:save_doc(Db, Doc); + _ -> {'ok', Doc} + end. + +-spec save_fax_doc(kz_term:ne_binary(), kz_json:object(), kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_json:object()} | + {'error', any()}. +save_fax_doc(Db, Doc, Content, CT, Name) -> + case maybe_save_fax_doc(Db, Doc) of + {'error', _}=Error -> Error; + {'ok', NewDoc} -> save_fax_attachment(Db, NewDoc, Content, CT, Name) + end. + +-spec save_fax_attachment(kz_term:ne_binary(), kz_term:api_object(), binary(), kz_term:ne_binary(), kz_term:ne_binary())-> + {'ok', kz_json:object()} | + {'error', kz_term:ne_binary()}. +save_fax_attachment(Db, Doc, Content, CT, Name) -> + MaxStorageRetry = kapps_config:get_integer(?FAX_CONFIG_CAT, <<"max_storage_retry">>, 5), + lager:debug("saving fax attachment ~s to ~s", [Name, kz_doc:id(Doc)]), + save_fax_attachment(Db, Doc, Content, CT, Name, MaxStorageRetry). + +-spec save_fax_attachment(kz_term:ne_binary(), kz_term:api_object(), binary(), kz_term:ne_binary(), kz_term:ne_binary(), non_neg_integer())-> + {'ok', kz_json:object()} | + {'error', kz_term:ne_binary()}. +save_fax_attachment(_, Doc, _Content, _CT, _Name, 0) -> + lager:error("max retry saving attachment ~s on fax id ~s rev ~s" + ,[_Name, kz_doc:id(Doc), kz_doc:revision(Doc)] + ), + {'error', <<"max retry saving attachment">>}; +save_fax_attachment(Db, Doc, Content, CT, Name, Count) -> + DocId = kz_doc:id(Doc), + _ = attempt_save(Db, Doc, Content, CT, Name), + case check_fax_attachment(Db, DocId, Name) of + {'ok', _}=Ok -> Ok; + {'missing', NewDoc} -> + lager:warning("missing fax attachment on fax id ~s",[DocId]), + timer:sleep(?RETRY_SAVE_ATTACHMENT_DELAY), + save_fax_attachment(Db, NewDoc, Content, CT, Name, Count-1); + {'error', _R} -> + lager:debug("error '~p' saving fax attachment on fax id ~s",[_R, DocId]), + timer:sleep(?RETRY_SAVE_ATTACHMENT_DELAY), + {'ok', NewDoc} = kz_datamgr:open_doc(Db, DocId), + save_fax_attachment(Db, NewDoc, Content, CT, Name, Count-1) + end. + +-spec attempt_save(kz_term:ne_binary(), kz_json:object(), binary(), kz_term:ne_binary(), kz_term:ne_binary()) -> + {'ok', kz_json:object()} | + kz_datamgr:data_error(). +attempt_save(Db, Doc, Content, CT, Name) -> + Opts = [{'content_type', CT} + ], + kz_datamgr:put_attachment(Db, kz_doc:id(Doc), Name, Content, Opts). + +-spec check_fax_attachment(kz_term:ne_binary(), kz_term:ne_binary(), kz_term:ne_binary())-> + {'ok', kz_json:object()} | + {'missing', kz_json:object()} | + {'error', any()}. +check_fax_attachment(Db, DocId, Name) -> + case kz_datamgr:open_doc(Db, DocId) of + {'ok', Doc} -> + case kz_doc:attachment(Doc, Name) of + 'undefined' -> {'missing', Doc}; + _Else -> {'ok', Doc} + end; + {'error', _}=E -> E + end. + + diff --git a/core/kazoo_fixturedb/priv/media_files/huge.pdf b/core/kazoo_fixturedb/priv/media_files/huge.pdf new file mode 100755 index 0000000000000000000000000000000000000000..fdc2a732901c33eda9198b64952ecd1534f119a7 GIT binary patch literal 580049 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