From 4e01a68bd946af3794c3d9c23d791d8668757ff5 Mon Sep 17 00:00:00 2001
From: Carsten  Rose <carsten.rose@math.uzh.ch>
Date: Thu, 20 Apr 2017 17:32:04 +0200
Subject: [PATCH] #3218 / download.php / export Implemented download.php to
 offer SIP protected downloads for single files (any filetype) as well as
 concatenated PDF files and converted HTML pages. download.php: API Interface
 DownloadException.php: New exception class for downloads - might be extended
 for better error handling. OnArray.php: new function
 getArrayItemKeyNameStartWith() to filter for specific elements in an array.
 New function arrayEscapeshellarg() to escape args Download.php: Main class.
 Link.php, Report.php: implemented new link type 'd' (=download)

---
 extension/Documentation/Manual.rst            | 115 ++++++++
 extension/qfq/api/download.php                |  29 ++
 extension/qfq/qfq/Constants.php               |  32 +-
 .../qfq/qfq/exceptions/DownloadException.php  |  31 ++
 extension/qfq/qfq/helper/OnArray.php          |  47 +++
 extension/qfq/qfq/report/Download.php         | 276 ++++++++++++++++++
 extension/qfq/qfq/report/Link.php             |  32 +-
 extension/qfq/qfq/report/Report.php           | 130 ++++++++-
 extension/qfq/tests/phpunit/OnArrayTest.php   |  20 ++
 9 files changed, 698 insertions(+), 14 deletions(-)
 create mode 100644 extension/qfq/api/download.php
 create mode 100644 extension/qfq/qfq/exceptions/DownloadException.php
 create mode 100644 extension/qfq/qfq/report/Download.php

diff --git a/extension/Documentation/Manual.rst b/extension/Documentation/Manual.rst
index a4cc52f55..902ed2079 100644
--- a/extension/Documentation/Manual.rst
+++ b/extension/Documentation/Manual.rst
@@ -3244,6 +3244,8 @@ Special column names
 +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 |_pageX or _PageX        |Shortcut version of the link interface for fast creation of internal links. The column name is composed of the string *page*/*Page* and a optional character to specify the type of the link.|
 +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+|_download or _Download  |Shortcut version of the link interface for fast creation of download links. Used to offer single file download or to concatenate severral PDFs and printout of websites to one PDF file.     |
++------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 |_sendmail               |Send emails.                                                                                                                                                                                 |
 +------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
 |_exec                   |Run batch files or executables on the webserver.                                                                                                                                             |
@@ -3279,6 +3281,8 @@ Column: _link
 +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 |x  |   |Page          |p:<pageId>                         |p:impressum                |Prepend '?' or '?id=', no hostname qualifier (automatically set by browser), default link class: internal, default value: {{pageId}}    |
 +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
+|x  |   |Download      |d                                  |d                          |If an image is specified, it will be rendered inside the link, default link class: internal. Link points to `api/download.php`          |
++---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 |   |   |Text          |t:<text>                           |t:Firstname Lastname       |-                                                                                                                                       |
 +---+---+--------------+-----------------------------------+---------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 |   |   |Render        |r:<mode>                           |r:[0-5]                    |See: `render-mode`_, Default: 0                                                                                                         |
@@ -3386,6 +3390,8 @@ Link Examples
 +-----------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 |SELECT "U:form=Person&r=123|x|t:Delete" as _link                       |<a href="typo3conf/ext/qfq/qfq/api/delete.php?s=badcaffee1234">Delete</a>                                                               |
 +-----------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
+|SELECT "d:U:1_pageId=req&1_id=12&1_mode=html2pdf"                      |<a href="typo3conf/ext/qfq/qfq/api/download.php?s=badcaffee1234">Download</a>                                                           |
++-----------------------------------------------------------------------+----------------------------------------------------------------------------------------------------------------------------------------+
 
 .. _question:
 
@@ -3435,6 +3441,78 @@ Examples:
 | SELECT "p:form_person|q:Edit Person:::10:0" AS _link       | The Alert will be shown 10 seconds and is not modal.                      |
 +------------------------------------------------------------+---------------------------------------------------------------------------+
 
+Download
+^^^^^^^^
+
+Download links can be used to offer:
+
+* to download a single file, or
+* to concatenate several files and/or web pages (=HTML to PDF) into one output file, or
+* to create an `Excel` export.
+
+The downloads are SIP protected. Only the current user can use the link to download files.
+
+By using the `_link` columnname:
+* the option `d` enables the download mode
+* setting `s` (or `s=1`) is mandatory for download mode
+
+By using `_download` or `_Download` as columnname the options `d` and `s` will be set by automatically.
+
+All files will be read by PHP - therefore the directory might be protected against direct web access. This way is the
+preferred way to offer secure downloads via QFQ.
+
+In case the download needs a persistant URL (no SIP, no user session), a regular
+link, pointing directly to a file, have to be used - the download described here won't help.
+
+.. _download-parameter-files:
+
+Specify file(s)/web page(s)
+'''''''''''''''''''''''''''
+
+The following parameter have to be specified as 'U' parameter. The SIP encoded parameter remains on the server and
+are not transferred to the client. This prohibits parameter manipulation.
+
+* *Link parameter 'U:.....'* :
+
+ * *exportFilename* = <filename for save as> - Name, offered in the 'File save as' browser dialog. Default: 'output.pdf'.
+      The user typically expect meaningfull and distinct filenames for different downloads.
+
+ * *mode* = <file | pdf | excel> - This parameter is optional and can be skipped in most situations.
+
+      * If `mode=file`, the mimetype is derived dynamically from the specified file. All others are fix (PDF or Excel).
+      * In case of multiple files and/or web pages, only `pdf` is supported.
+      * `excel` is not implemented now.
+      * The **default** depends on the number of specified `elements` (=file or web page). If only one `file` is specifed,
+        the default is `file`. If there is a) a page defined or b) multiple elements, the default is `pdf`.
+
+ * For `web page` or `excel` export, all pages have to be specified with the necessary parameters (might
+    vary per page). The preceeding number `<i>` aggregates all parameter to one element (=`file` or `page`). The ordering has to start with `1`
+      and has to be consecutive.
+
+   * *<i>_id* = <Typo3 pageId> - `id` is fix and mandatory for `web page` and `excel`.
+   * *<i>_<keyname>* = <value key i> - <keyname> is free of choice except `id` and `file`.
+
+ * For `file`
+
+   * *<i>_file* = <pathfilename> - `file` is fix and mandatory for direct `file` access.
+
+
+Most of the other Link-Class attributes can be used to customize the link.
+
+Example: ::
+
+	# single `file`
+	SELECT "d|s|t:PDF|U:exportFilename=final.pdf&1_file=fileadmin/pdf/test.pdf" AS _link
+	# single `file`, with mode
+	SELECT "d|s|t:PDF|U:exportFilename=final.pdf&mode=file&1_file=fileadmin/pdf/test.pdf" AS _link
+
+	# two pages (1_id, 2_id) and one file (3_file)
+	SELECT "d|s|t:PDF|U:exportFilename=final.pdf&1_id=exportP1&2_id=123&3_file=fileadmin/pdf/test.pdf" AS _link
+	# two pages (1_id, 2_id) and one file (3_file) with mode
+	SELECT "d|s|t:PDF|U:exportFilename=final.pdf&mode=pdf&1_id=exportP1&2_id=123&3_file=fileadmin/pdf/test.pdf" AS _link
+
+..
+
 Columns: _page[X]
 ^^^^^^^^^^^^^^^^^
 
@@ -3831,6 +3909,43 @@ Runs batch files or executables on the webserver. In case of an error, returncod
 ..
 
 
+Column: _download
+^^^^^^^^^^^^^^^^^
+
+Most of the other Link-Class attributes can be used to customize the link. Most usefull:
+::
+
+    SELECT "[options]" AS _download
+
+    with: [options] = U:<params>[t:<text>]|[o:<tooltip>]|[c:<class>]|[g:<target>]|[r:<render mode>]
+
+
+* Parameter are position independent.
+* *<params>*: see ref:`download-parameter-files`
+* Example: ::
+
+		SELECT "U:exportFilename=file.pdf&mode=pdf&1_id=xRequest&2_id=123&2_r=456&3_file=fileadmin/test.pdf|t:PDF download|o:Click to view the PDF" AS _download
+
+..
+
+Column: _Download
+^^^^^^^^^^^^^^^^^
+
+A limited set of attributes is supported: ::
+
+    SELECT "[options]" AS _Download
+
+    with: [options] =<params>|[<text>]|[<tooltip>]|[<class>]|[<target>]|[<render mode>]
+
+* Parameter are position dependent and therefore without a qualifier!
+* *<params>*: see ref:`download-parameter-files`
+* Example: ::
+
+		SELECT "exportFilename=file.pdf&mode=pdf&1_id=xRequest&2_id=123&2_r=456&3_file=fileadmin/test.pdf|PDF download|Click to view the PDF" AS _download
+
+..
+
+
 Column: _F
 ^^^^^^^^^^
 
diff --git a/extension/qfq/api/download.php b/extension/qfq/api/download.php
new file mode 100644
index 000000000..fd5b35784
--- /dev/null
+++ b/extension/qfq/api/download.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: crose
+ * Date: 4/17/17
+ * Time: 5:51 PM
+ */
+
+namespace qfq;
+
+use qfq;
+
+require_once(__DIR__ . '/../qfq/report/Download.php');
+//require_once(__DIR__ . '/../qfq/store/Store.php');
+require_once(__DIR__ . '/../qfq/Constants.php');
+
+try {
+    $download = new \qfq\Download();
+
+    // If all is fine - this function never returns! The output file is delivered and PHP is stopped after that.
+    $data = $download->process();
+
+
+} catch (\Exception $e) {
+    $data = "Exception: " . $e->getMessage();
+}
+
+echo $data;
+
diff --git a/extension/qfq/qfq/Constants.php b/extension/qfq/qfq/Constants.php
index 11b66fe74..6f9ff0aa9 100644
--- a/extension/qfq/qfq/Constants.php
+++ b/extension/qfq/qfq/Constants.php
@@ -201,11 +201,18 @@ const ERROR_UPLOAD = 1500;
 const ERROR_UNKNOWN_ACTION = 1502;
 const ERROR_NO_TARGET_PATH_FILE_NAME = 1503;
 
+// LDAP
 const ERROR_LDAP_CONNECT = 1600;
 const ERROR_MISSING_TYPE_AHEAD_LDAP_SEARCH = 1601;
 const ERROR_MISSING_TYPE_AHEAD_LDAP_SEARCH_PREFETCH = 1602;
 const ERROR_LDAP_BIND = 1603;
 
+// Download
+const ERROR_DOWNLOAD_CREATE_NEW_FILE = 1700;
+const ERROR_DOWNLOAD_NO_FILES = 1701;
+const ERROR_DOWNLOAD_NOTHING_TO_DO = 1702;
+const ERROR_DOWNLOAD_UNEXPECTED_MIMETYPE = 1703;
+
 // KeyValueParser
 const ERROR_KVP_VALUE_HAS_NO_KEY = 1900;
 
@@ -374,7 +381,7 @@ const MSG_ERROR_CODE = 'errorCode';
 const SIP_TOKEN_LENGTH = 13; // length of string returned by `uniqid()`
 const SIP_SIP = CLIENT_SIP;  // s
 const SIP_RECORD_ID = CLIENT_RECORD_ID; // r
-const SIP_TARGET_URL= '_targetUrl'; // URL where to jump after delete()
+const SIP_TARGET_URL = '_targetUrl'; // URL where to jump after delete()
 const SIP_MODE_ANSWER = '_modeAnswer'; // Mode how delete() will answer to client: MODE_HTML, MODE_JSON
 const SIP_FORM = CLIENT_FORM;
 const SIP_TABLE = 'table'; // delete a record from 'table'
@@ -459,6 +466,7 @@ const MODE_LDAP_MULTI = 'ldapMulti';
 
 // api/save.php, api/delete.php, api/load.php
 const API_DELETE_PHP = 'delete.php';
+const API_DOWNLOAD_PHP = 'download.php';
 
 const API_STATUS = 'status';
 const API_MESSAGE = 'message';
@@ -822,6 +830,7 @@ const COLUMN_PPAGEH = "Pageh";
 const COLUMN_PPAGEI = "Pagei";
 const COLUMN_PPAGEN = "Pagen";
 const COLUMN_PPAGES = "Pages";
+const COLUMN_DDOWNLOAD = "Download";
 
 const COLUMN_PAGE = "page";
 const COLUMN_PAGEC = "pagec";
@@ -831,8 +840,27 @@ const COLUMN_PAGEH = "pageh";
 const COLUMN_PAGEI = "pagei";
 const COLUMN_PAGEN = "pagen";
 const COLUMN_PAGES = "pages";
+const COLUMN_DOWNLOAD = "download";
 
 const FORM_NAME_FORM = 'form';
 const FORM_NAME_FORM_ELEMENT = 'formElement';
 
-const PENALTY_TIME_BROKEN_SIP = 5;
\ No newline at end of file
+const PENALTY_TIME_BROKEN_SIP = 5;
+
+// DOWNLOAD
+const DOWNLOAD_MODE = 'mode';
+const DOWNLOAD_MODE_FILE = 'file';
+const DOWNLOAD_MODE_PDF = 'pdf';
+const DOWNLOAD_MODE_EXCEL = 'excel';
+const DOWNLOAD_EXPORT_FILENAME = 'exportFilename';
+const DOWNLOAD_PAGE_ID = 'id';
+const DOWNLOAD_FILE = 'file';
+const DOWNLOAD_FILE_PREFIX = 'qfq.temp';
+const DOWNLOAD_OUTPUT_PDF = 'output.pdf';
+const DOWNLOAD_PARAMETER_DELIMITER = '_';
+
+// HTML2PDF
+const HTML2PDF_PAGEID = 'id';
+const HTML2PDF_PARAM_GET = 'paramGet';
+const HTML2PDF_URL_PRINT = 'urlPrint';
+
diff --git a/extension/qfq/qfq/exceptions/DownloadException.php b/extension/qfq/qfq/exceptions/DownloadException.php
new file mode 100644
index 000000000..8a89123a8
--- /dev/null
+++ b/extension/qfq/qfq/exceptions/DownloadException.php
@@ -0,0 +1,31 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: crose
+ * Date: 4/17/17
+ * Time: 9:20 PM
+ */
+
+namespace qfq;
+
+require_once(__DIR__ . '/AbstractException.php');
+
+/**
+ * Class DownloadException
+ *
+ * Thrown by Download
+ *
+ * @package qfq\exceptions
+ */
+class DownloadException extends AbstractException {
+
+    /*
+      * @return string HTML formatted error string
+      */
+    public function formatMessage() {
+
+        $this->messageArray['Type'] = 'Download Exception';
+
+        return parent::formatException();
+    }
+}
\ No newline at end of file
diff --git a/extension/qfq/qfq/helper/OnArray.php b/extension/qfq/qfq/helper/OnArray.php
index b60e6fe32..be9fa339e 100644
--- a/extension/qfq/qfq/helper/OnArray.php
+++ b/extension/qfq/qfq/helper/OnArray.php
@@ -228,6 +228,33 @@ class OnArray {
         return $new;
     }
 
+    /**
+     * Copies all items whose keyNames starts with $keyName.
+     * Remove Prefix '$keyName' from all keys.
+     * Return $new
+     *
+     * E.g. [ 'mode' => 'pdf', '1_pageId' => 123, '2_file' => 'example.pdf' ], with $keyName='1_' >> [ 'pageId' => 123 ]
+     *
+     * @param array $src
+     * @param string $keyName
+     * @return array
+     */
+    public static function getArrayItemKeyNameStartWith(array $src, $keyName) {
+        $new = array();
+        $length = strlen($keyName);
+
+        // Extract necessary elements
+        foreach ($src as $key => $value) {
+            $keyTmp = substr($key, 0, $length);
+            if ($keyTmp == $keyName) {
+                $newKey = substr($key, $length);
+                $new[$newKey] = $value;
+            }
+        }
+
+        return $new;
+    }
+
     /**
      * Iterates over an array and replaces all occurences of $search with $replace. Returns the new array.
      *
@@ -245,4 +272,24 @@ class OnArray {
 
         return $new;
     }
+
+    /**
+     * Performs escapeshellarg() on all elements of an array.
+     *
+     * @param array $src
+     * @return array
+     */
+    public static function arrayEscapeshellarg(array $src) {
+        $new = array();
+
+        foreach ($src as $key => $value) {
+            if (is_array($value)) {
+                $new[$key] = self::arrayEscapeshellarg($value);
+            } else {
+                $new[$key] = escapeshellarg($value);
+            }
+        }
+
+        return $new;
+    }
 }
\ No newline at end of file
diff --git a/extension/qfq/qfq/report/Download.php b/extension/qfq/qfq/report/Download.php
new file mode 100644
index 000000000..0c0330cfb
--- /dev/null
+++ b/extension/qfq/qfq/report/Download.php
@@ -0,0 +1,276 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: crose
+ * Date: 4/17/17
+ * Time: 11:32 AM
+ */
+
+namespace qfq;
+
+use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
+
+require_once(__DIR__ . '/../Constants.php');
+require_once(__DIR__ . '/../store/Session.php');
+require_once(__DIR__ . '/../store/Store.php');
+require_once(__DIR__ . '/../helper/OnArray.php');
+require_once(__DIR__ . '/../report/Html2Pdf.php');
+//require_once(__DIR__ . '/Link.php');
+//require_once(__DIR__ . '/Sendmail.php');
+require_once(__DIR__ . '/../exceptions/DownloadException.php');
+//require_once(__DIR__ . '/../Evaluate.php');
+//require_once(__DIR__ . '/../helper/KeyValueStringParser.php');
+//
+
+/**
+ * Class Download
+ *
+ * Param: i=1..n
+ *   <i>_mode=direct | html2pdf
+ *   <i>_id=<pageId>
+ *   <i>_<key i>=<value i>
+ *
+ * @package qfq
+ */
+class Download {
+
+    /**
+     * @var Store
+     */
+    private $store = null;
+
+    /**
+     * @var Session
+     */
+    private $session = null;
+
+    /**
+     * @var Database
+     */
+    private $db = null;
+
+    /**
+     * @var Html2Pdf
+     */
+    private $html2pdf = null;
+
+    /**
+     * @param bool|false $phpUnit
+     */
+    public function __construct($phpUnit = false) {
+
+        $this->session = Session::getInstance($phpUnit);
+        $this->store = Store::getInstance('', $phpUnit);
+        $this->db = new Database();
+        $this->html2pdf = new Html2Pdf($this->store->getStore(STORE_SYSTEM));
+    }
+
+    /**
+     * Collect all elements, separated per element.
+     * Skip parameter which do not start with '<i>_' (i=integer).
+     * Create a numbered and sorted (by key) array, with all parameter belonging to one element in one subarray.
+     *
+     * E.g.: exportFilename=final.pdf&mode=pdf&1_id=exportP1&2_id=123&3_file=fileadmin/pdf/test.pdf&1_grId=78
+     * result [
+     *          [1] => [ 'id' => 'exportP1', 'grId' = '78' ],
+     *          [2] => [ 'id' => '123' ],
+     *          [3] => [ 'file' => 'fileadmin/pdf/test.pdf' ],
+     *          [mode] => 'pdf|file|excel',
+     *          [exportFilename] => '....'
+     *        ]
+     *
+     * @param array $vars
+     * @return array
+     */
+    private function collectElement(array $vars) {
+        $final = array();
+
+        foreach ($vars as $key => $value) {
+            $splitArr = explode(DOWNLOAD_PARAMETER_DELIMITER, $key, 2);
+            $idx = $splitArr[0];
+            if ((count($splitArr) == 2) && is_numeric($idx) && !isset($final[$idx])) {
+                $arr = OnArray::getArrayItemKeyNameStartWith($vars, $idx . DOWNLOAD_PARAMETER_DELIMITER);
+                if (count($arr) > 0) {
+                    $final[$idx] = $arr;
+                }
+
+            }
+        }
+
+        // sort array
+        ksort($final, SORT_NATURAL);
+
+        return $final;
+    }
+
+    /**
+     * Concatenate all named files to one PDF file. Return name of new full PDF.
+     *
+     * @param array $files
+     * @return string  - fileName of concatenated file
+     * @throws DownloadException
+     */
+    private function concatPdfFiles(array $files) {
+
+        $concatFile = tempnam(sys_get_temp_dir(), DOWNLOAD_FILE_PREFIX);
+        if (false === $concatFile) {
+            throw new DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE);
+        }
+
+        // Check that all files are of type 'application/pdf'
+        foreach ($files AS $filename) {
+            $mimetype = mime_content_type($filename);
+            if ($mimetype != 'application/pdf') {
+                throw new downloadException("Error concat file $filename. Mimetype 'application/pdf' expected, got: $mimetype", ERROR_DOWNLOAD_UNEXPECTED_MIMETYPE);
+            }
+        }
+
+        $files = OnArray::arrayEscapeshellarg($files);
+
+        $inputFiles = implode(' ', $files);
+        if (trim($inputFiles) == '') {
+            throw new DownloadException('No files to concatenate.', ERROR_DOWNLOAD_NO_FILES);
+        }
+
+        $cmd = "pdftk $inputFiles cat output $concatFile";
+
+        exec($cmd, $output, $rc);
+
+        if ($rc != 0) {
+            throw new DownloadException ("<p>Failed: RC=$rc   $cmd</p>" . implode("<br>", $output));
+        }
+
+        return $concatFile;
+    }
+
+    /**
+     * Set header type and output $filename. Be careful not to send any additional characters.
+     *
+     * @param $filename
+     * @param $outputFilename
+     */
+    private function outputFile($filename, $outputFilename) {
+
+        $mimetype = mime_content_type($filename);
+        $length = filesize($filename);
+
+        header("Content-type: $mimetype");
+        header("Content-Length: $length");
+        header("Content-Disposition: inline; filename=$outputFilename");
+
+        print file_get_contents($filename);
+    }
+
+    /**
+     * Iterate over array $files. Delete all named files which exist in DOWNLOAD_TMP_DIR.
+     *
+     * @param array $files
+     */
+    private function cleanTempFiles(array $files) {
+        $prefix = sys_get_temp_dir() . '/' . DOWNLOAD_FILE_PREFIX;
+        $len = strlen($prefix);
+
+        foreach ($files as $file) {
+            if (substr($file, 0, $len) == $prefix) {
+                unlink($file);
+            }
+        }
+    }
+
+    /**
+     * @param $element
+     * @return mixed
+     * @throws DownloadException
+     * @throws \exception
+     */
+    private function getFile($element) {
+//        $first = each($element);
+//        $key = $first['key'];
+//        $arr = explode(DOWNLOAD_PARAMETER_DELIMITER, $key, 2);
+//        $idx = $arr[0];
+
+        if (isset($element[DOWNLOAD_FILE])) {
+
+            $filename = $element[DOWNLOAD_FILE];
+
+        } elseif (isset($element[DOWNLOAD_PAGE_ID])) {
+
+            $filename = $this->html2pdf->page2pdf($element);
+
+        } else {
+            throw new DownloadException('Neither found: ' . DOWNLOAD_FILE . ', ' . DOWNLOAD_PAGE_ID, ERROR_MISSING_REQUIRED_PARAMETER);
+        }
+
+        return $filename;
+    }
+
+    /**
+     * exportFilename=<new filename>
+     * mode=file | pdf | excel - default is 'file' in case of only one or 'pdf' in case of multiple sources.
+     * HTML to PDF | Excel
+     *   <i>_id=<Typo3 pageId>
+     *   <i>_<key>=<value i>
+     * Direct
+     * <i>_file=<filename>
+     *
+     * @param array $elements
+     * @throws DownloadException
+     */
+    private function doElements(array $elements, array $vars) {
+
+        $tmpFiles = array();
+
+        $workDir = $this->store->getVar(SYSTEM_SITE_PATH, STORE_SYSTEM);
+        if (!chdir($workDir)) {
+            throw new DownloadException ("Error chdir($workDir)", ERROR_IO_CHDIR);
+        }
+
+        $exportFilename = isset($vars[DOWNLOAD_EXPORT_FILENAME]) ? $vars[DOWNLOAD_EXPORT_FILENAME] : '';
+
+        foreach ($elements as $element) {
+            $tmpFiles[] = $this->getFile($element);
+        }
+
+        switch (count($tmpFiles)) {
+            case 0:
+                throw new DownloadException('Nothing to do.', ERROR_DOWNLOAD_NOTHING_TO_DO);
+                break;
+
+            case 1:
+                $filename = $tmpFiles[0];
+                if ($exportFilename == '') {
+                    $exportFilename = strrchr($filename, '/');
+                    if ($exportFilename == false) {
+                        $exportFilename = DOWNLOAD_OUTPUT_PDF;
+                    }
+                }
+                break;
+            default:
+                $filename = $this->concatPdfFiles($tmpFiles);
+                if ($exportFilename == '') {
+                    $exportFilename = DOWNLOAD_OUTPUT_PDF;
+                }
+                break;
+        }
+
+        $this->outputFile($filename, $exportFilename);
+
+        $tmpFiles[] = $filename;
+        $this->cleanTempFiles($tmpFiles);
+    }
+
+    /**
+     * @return string
+     * @throws CodeException
+     * @throws UserFormException
+     */
+    public function process() {
+
+        $vars = $this->store->getStore(STORE_SIP);
+        $elements = $this->collectElement($vars);
+        $this->doElements($elements, $vars);
+    }
+}
+
+
+
diff --git a/extension/qfq/qfq/report/Link.php b/extension/qfq/qfq/report/Link.php
index 0126d8f4f..a09e54445 100644
--- a/extension/qfq/qfq/report/Link.php
+++ b/extension/qfq/qfq/report/Link.php
@@ -68,6 +68,7 @@ const NAME_URL = 'url';
 const NAME_MAIL = 'mail';
 const NAME_PAGE = 'page';
 const NAME_TEXT = 'text';
+const NAME_DOWNLOAD = 'download';
 const NAME_ALT_TEXT = 'altText';
 const NAME_TOOL_TIP = 'toolTip';
 const NAME_TOOL_TIP_JS = 'toolTipJs';
@@ -99,6 +100,7 @@ const TOKEN_URL = 'u';
 const TOKEN_MAIL = 'm';
 const TOKEN_PAGE = 'p';
 const TOKEN_TEXT = 't';
+const TOKEN_DOWNLOAD = 'd';
 const TOKEN_ALT_TEXT = 'a';
 const TOKEN_TOOL_TIP = 'o';
 const TOKEN_PICTURE = 'P';
@@ -184,6 +186,7 @@ class Link {
         TOKEN_URL => 'buildUrl',
         TOKEN_MAIL => 'buildMail',
         TOKEN_PAGE => 'buildPage',
+        TOKEN_DOWNLOAD => 'buildDownload',
         TOKEN_TOOL_TIP => 'buildToolTip',
         TOKEN_PICTURE => 'buildPicture',
         TOKEN_BULLET => 'buildBullet',
@@ -201,6 +204,7 @@ class Link {
         TOKEN_URL => NAME_URL,
         TOKEN_MAIL => NAME_MAIL,
         TOKEN_PAGE => NAME_PAGE,
+        TOKEN_DOWNLOAD => NAME_DOWNLOAD,
         TOKEN_TEXT => NAME_TEXT,
         TOKEN_ALT_TEXT => NAME_ALT_TEXT,
         TOKEN_TOOL_TIP => NAME_TOOL_TIP,
@@ -228,6 +232,7 @@ class Link {
         TOKEN_URL => LINK_ANCHOR,
         TOKEN_MAIL => LINK_ANCHOR,
         TOKEN_PAGE => LINK_ANCHOR,
+        TOKEN_DOWNLOAD => LINK_ANCHOR,
         TOKEN_PICTURE => LINK_PICTURE,
         TOKEN_BULLET => LINK_PICTURE,
         TOKEN_CHECK => LINK_PICTURE,
@@ -421,7 +426,7 @@ class Link {
             $keyName = $this->tableVarName[$key]; // convert token to name
 
             if ($value === '') {
-                $value = $this->checkEmptyValue($key, $value);
+                $value = $this->checkEmptyValue($key);
             }
             $value = $this->checkValue($key, $value);
 
@@ -501,14 +506,15 @@ class Link {
     }
 
     /**
-     * Verify Empty values. If appropriate, set defaults, if not throw anexception.
+     * Verify Empty values. If appropriate, set defaults, if not throw an exception.
      *
-     * @param $key
-     * @param $value
+     * @param string $key
      * @return string
      * @throws UserReportException
      */
-    private function checkEmptyValue($key, $value) {
+    private function checkEmptyValue($key) {
+        $value = '';
+
         switch ($key) {
             case TOKEN_URL:
                 throw new UserReportException ("Missing value for token '$key'", ERROR_MISSING_VALUE);
@@ -1049,6 +1055,22 @@ EOF;
         return $vars;
     }
 
+    /**
+     * Called by $this->callTable
+     *
+     * @param $vars
+     * @param $value
+     * @return array
+     * @throws UserReportException
+     */
+    private function buildDownload($vars, $value) {
+
+        $vars[NAME_URL] = API_DIR . '/' . API_DOWNLOAD_PHP;
+        $vars[NAME_LINK_CLASS_DEFAULT] = $this->cssLinkClassInternal;
+
+        return $vars;
+    }
+
     /**
      * Called by $this->callTable
      *
diff --git a/extension/qfq/qfq/report/Report.php b/extension/qfq/qfq/report/Report.php
index d0b0fec2f..c1ebae3b5 100644
--- a/extension/qfq/qfq/report/Report.php
+++ b/extension/qfq/qfq/report/Report.php
@@ -559,7 +559,7 @@ class Report {
         $flagControl = false;
         $flagOutput = true;
 
-        // Empty columnsnames are allowed: check with isset
+        // Empty columnnames are allowed: check with isset
         if (isset($columnName[0]) && $columnName[0] === "_") {
             $flagControl = true;
             $columnName = substr($columnName, 1);
@@ -603,6 +603,17 @@ class Report {
                 $content .= $this->link->renderLink($linkValue);
                 break;
 
+            case COLUMN_DDOWNLOAD:
+                $tokenizedValue = $this->doFixColPosDownload($columnValue);
+                $linkValue = $this->doDownload($tokenizedValue);
+                $content .= $this->link->renderLink($linkValue);
+                break;
+
+            case COLUMN_DOWNLOAD:
+                $linkValue = $this->doDownload($columnValue);
+                $content .= $this->link->renderLink($linkValue);
+                break;
+
             case "bullet":
                 if ($columnValue === '') {
                     break;
@@ -804,13 +815,15 @@ class Report {
 
         $tokenList = "";
 
-        if (empty($columnName))
+        if (empty($columnName)) {
             return '';
+        }
 
         // Split definition
         $allParam = explode('|', $columnValue);
-        if (count($allParam) > 8)
+        if (count($allParam) > 8) {
             throw new SyntaxReportException ("Too many parameter (max=8): $columnValue", ERROR_TOO_MANY_PARAMETER, null, __FILE__, __LINE__, $this->fr_error);
+        }
 
         // First Parameter: Split PageId|PageAlias and  URL Params
         $firstParam = explode('&', $allParam[0], 2);
@@ -866,6 +879,65 @@ class Report {
         return ($tokenList);
     }
 
+    /**
+     * Renders Download: convert position content to token content. Respect default values.
+     *
+     * @param    string $columnValue
+     * @return string rendered link
+     *
+     * $columnValue:
+     * -------------
+     * <params> | [<text>] | [<tooltip>] | [<class>] | [<target>] | [<render mode>]
+     *
+     * param[0]: params
+     * param[1]: text
+     * param[2]: tooltip
+     * param[3]: class
+     * param[4]: target
+     * param[5]: render mode
+     *
+     * @throws SyntaxReportException
+     */
+    private function doFixColPosDownload($columnValue) {
+
+        $tokenList = '';
+
+        if ($columnValue == '') {
+            throw new SyntaxReportException ("Missing parameter for " . COLUMN_DDOWNLOAD, ERROR_MISSING_REQUIRED_PARAMETER, null, __FILE__, __LINE__, $this->fr_error);
+        }
+
+        // Split definition
+        $allParam = explode('|', $columnValue);
+
+        if ($columnValue == '') {
+            throw new SyntaxReportException ("Missing parameter for " . COLUMN_DDOWNLOAD, ERROR_MISSING_REQUIRED_PARAMETER, null, __FILE__, __LINE__, $this->fr_error);
+        }
+
+        $tokenList .= $this->composeLinkPart(TOKEN_URL_PARAM, $allParam[0]);
+
+        if (isset($allParam[1]) && $allParam[1] !== '') {
+            $tokenList .= $this->composeLinkPart(TOKEN_TEXT, $allParam[1]);             // -- Text --
+        }
+
+        if (isset($allParam[2]) && $allParam[2] !== '') {
+            $tokenList .= $this->composeLinkPart(TOKEN_TOOL_TIP, $allParam[2]);         // -- tooltip --
+        }
+
+        if (isset($allParam[3]) && $allParam[3] !== '') {
+            $tokenList .= $this->composeLinkPart(TOKEN_CLASS, $allParam[3]);            // -- class --
+        }
+
+        if (isset($allParam[5]) && $allParam[5] !== '') {
+            $tokenList .= $this->composeLinkPart(TOKEN_TARGET, $allParam[4]);           // -- target --
+        }
+
+        if (isset($allParam[6]) && $allParam[6] !== '') {
+            $tokenList .= $this->composeLinkPart(TOKEN_RENDER, $allParam[5]);           // -- render mode --
+        }
+
+        return ($tokenList);
+    }
+
     /**
      * If there is a value (or a defaultValue): compose it together with qualifier and delimiter.
      *
@@ -877,11 +949,13 @@ class Report {
      */
     private function composeLinkPart($qualifier, $value, $defaultValue = "") {
 
-        if ($value === '')
+        if ($value === '') {
             $value = $defaultValue;
+        }
 
-        if ($value !== '')
+        if ($value !== '') {
             return ($qualifier . ":" . $value . "|");
+        }
 
         return '';
     }
@@ -901,14 +975,14 @@ class Report {
 
         $param = explode('|', $columnValue);
 
-        # get all defaultvalues, depending on the columnname
+        # get all default values, depending on the columnname
         $defaultImage = isset($this->pageDefaults[DEFAULT_ICON][$columnName]) ? $this->pageDefaults[DEFAULT_ICON][$columnName] : '';
         $defaultSip = 's';
         if ($columnName === COLUMN_PAGED) {
             $defaultActionDelete = TOKEN_ACTION_DELETE . ':' . TOKEN_ACTION_DELETE_REPORT;
         }
 
-        # define defaultquestion only, if pagetype needs a question
+        # define default question only, if pagetype needs a question
         if (!empty($this->pageDefaults[DEFAULT_QUESTION][$columnName])) {
             $defaultQuestion = 'q:' . $this->pageDefaults[DEFAULT_QUESTION][$columnName];
         }
@@ -934,6 +1008,7 @@ class Report {
                     break;
                 case TOKEN_ACTION_DELETE:
                     $defaultActionDelete = '';
+                    break;
                 default:
                     break;
             }
@@ -965,6 +1040,47 @@ class Report {
         return ($columnValue);
     }
 
+    /**
+     * Renders _download: extract token and determine if any default value has to be applied
+     *
+     * @param    string $columnValue
+     *
+     * @return    string        rendered link
+     */
+    private function doDownload($columnValue) {
+
+        $param = explode('|', $columnValue);
+
+        # get all default values, depending on the columnname
+        $defaultSip = TOKEN_SIP;
+        $defaultDownload = TOKEN_DOWNLOAD;
+
+        foreach ($param as $key) {
+            switch (substr($key, 0, 1)) {
+                case TOKEN_SIP:
+                    $defaultSip = '';    // if any of the img token is given: no default
+                    break;
+                case TOKEN_DOWNLOAD:
+                    $defaultDownload = '';
+                    break;
+                default:
+                    break;
+            }
+        }
+
+        $columnValue .= "|";
+
+        if ($defaultSip !== '') {
+            $columnValue .= $defaultSip . "|";
+        }
+
+        if ($defaultDownload !== '') {
+            $columnValue .= $defaultDownload . "|";
+        }
+
+        return ($columnValue);
+    }
+
     /**
      * Generate SortArgument
      *
diff --git a/extension/qfq/tests/phpunit/OnArrayTest.php b/extension/qfq/tests/phpunit/OnArrayTest.php
index de04a7ec2..ea1e02687 100644
--- a/extension/qfq/tests/phpunit/OnArrayTest.php
+++ b/extension/qfq/tests/phpunit/OnArrayTest.php
@@ -135,4 +135,24 @@ class OnArrayTest extends \PHPUnit_Framework_TestCase {
         $expected = ['fruit1' => 'Apple', 'fruit2' => 'cherry', 'fruit3' => 'bAnAnA'];
         $this->assertEquals($expected, OnArray::arrayValueReplace($src, 'a', 'A'));
     }
+
+    public function testGetArrayItemKeyNameStartWith() {
+        $this->assertEquals(array(), OnArray::getArrayItemKeyNameStartWith(array(), ''));
+        $this->assertEquals(['a' => 'hello'], OnArray::getArrayItemKeyNameStartWith(['a' => 'hello'], ''));
+        $this->assertEquals(array(), OnArray::getArrayItemKeyNameStartWith(['a' => 'hello'], 'b'));
+        $this->assertEquals([0 => 'hello'], OnArray::getArrayItemKeyNameStartWith(['a' => 'hello'], 'a'));
+        $this->assertEquals(['b' => 'hello'], OnArray::getArrayItemKeyNameStartWith(['ab' => 'hello'], 'a'));
+        $this->assertEquals(array(), OnArray::getArrayItemKeyNameStartWith(['ba' => 'hello'], 'a'));
+        $this->assertEquals(['a' => 'is', 'b' => 'john'], OnArray::getArrayItemKeyNameStartWith(['1_a' => 'my', '1_b' => 'name', '2_a' => 'is', '2_b' => 'john'], '2_'));
+        $this->assertEquals(['a' => 'is', 'b' => 'john'], OnArray::getArrayItemKeyNameStartWith(['1z_a' => 'my', '1z_b' => 'name', '2z_a' => 'is', '2z_b' => 'john'], '2z_'));
+
+    }
+
+    public function testArrayEscapeshellarg() {
+        $this->assertEquals(array(), OnArray::arrayEscapeshellarg(array()));
+        $this->assertEquals(['name' => "'john'"], OnArray::arrayEscapeshellarg(['name' => 'john']));
+        $this->assertEquals(['name' => "'jo\"hn'"], OnArray::arrayEscapeshellarg(['name' => 'jo"hn']));
+        $this->assertEquals(['name' => "'john'", 'surname' => "'doe'"], OnArray::arrayEscapeshellarg(['name' => 'john', 'surname' => 'doe']));
+        $this->assertEquals(['name' => "'john'", 'sub' => ['surname' => "'doe'"]], OnArray::arrayEscapeshellarg(['name' => 'john', 'sub' => ['surname' => 'doe']]));
+    }
 }
-- 
GitLab