Download */ namespace IMATHUZH\Qfq\Core\Report; use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser; use IMATHUZH\Qfq\Core\Store\Session; use IMATHUZH\Qfq\Core\Store\Store; use IMATHUZH\Qfq\Core\Store\Sip; use IMATHUZH\Qfq\Core\Helper\OnArray; use IMATHUZH\Qfq\Core\Helper\OnString; use IMATHUZH\Qfq\Core\Helper\Logger; use IMATHUZH\Qfq\Core\Helper\Sanitize; use IMATHUZH\Qfq\Core\Helper\HelperFile; use IMATHUZH\Qfq\Core\Helper\DownloadPage; use IMATHUZH\Qfq\Core\QuickFormQuery; use IMATHUZH\Qfq\Core\Database\Database; use IMATHUZH\Qfq\Core\Helper\Support; /** * Class Download * * Documentation: PROTOCOL.md >> Download * * Param: i=1..n * _mode=direct | html2pdf * _id= * _= * * @package qfq */ class Download { /** * @var Store */ private $store = null; /** * @var Session */ private $session = null; /** * @var Database */ private $db = null; /** * @var Html2Pdf */ private $html2pdf = null; /** * @var string Filename where to write download Information */ private $downloadDebugLog = ''; /** * @var string DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON */ private $outputFormat = DOWNLOAD_OUTPUT_FORMAT_RAW; /** * @param bool|false $phpUnit * @throws \CodeException * @throws \DbException * @throws \UserFormException * @throws \UserReportException */ public function __construct($phpUnit = false) { #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}" $this->session = Session::getInstance($phpUnit); $this->store = Store::getInstance('', $phpUnit); $this->db = new Database(); $this->html2pdf = new Html2Pdf($this->store->getStore(STORE_SYSTEM), $phpUnit); if (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_DOWNLOAD, $this->store->getVar(SYSTEM_SHOW_DEBUG_INFO, STORE_SYSTEM))) { $this->downloadDebugLog = $this->store->getVar(SYSTEM_SQL_LOG, STORE_SYSTEM); } } /** * Concatenate all named files to one PDF file. Return name of new full PDF. * * @param array $files * * @return string - fileName of concatenated file * @throws \CodeException * @throws \DownloadException * @throws \UserFormException */ private function concatPdfFiles(array $files) { // Remove empty entries. Might happen if there was no upload $files = OnArray::removeEmptyElementsFromArray($files); // Check that all files exist and are readable foreach ($files AS $filename) { if (!is_readable($filename)) { throw new \DownloadException("Error reading file $filename. Not found or no permission", ERROR_DOWNLOAD_FILE_NOT_READABLE); } } if (count($files) === 0) { return ''; } // 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_MIME_TYPE); } } if (count($files) == 1) { return $files[0]; } $files = OnArray::arrayEscapeshellarg($files); $inputFiles = implode(' ', $files); if (trim($inputFiles) == '') { throw new \DownloadException('No files to concatenate.', ERROR_DOWNLOAD_NO_FILES); } // Need to create a separate result file, even if it is just a single file (#6929) $concatFile = HelperFile::tempnam(); if (false === $concatFile) { throw new \DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE); } // $cmd = "pdftk $inputFiles cat output $concatFile 2>&1"; # Outdated. Hard to install on Ubuntu 18. Fails for recent PDFs. // $cmd = "qpdf --empty --pages $inputFiles -- $concatFile 2>&1"; # Fails to merge identical files, if they contain references. $cmd = "pdfunite $inputFiles $concatFile 2>&1"; // Based on poppler. URLs are preserved. Orientation and size are preserved. if ($this->downloadDebugLog != '') { Logger::logMessage("Download: $cmd", $this->downloadDebugLog); } exec($cmd, $output, $rc); if ($rc != 0) { throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to merge PDF file", ERROR_MESSAGE_TO_DEVELOPER => "CMD: " . $cmd . "
RC: $rc
Output: " . implode("
", $output)]) , ERROR_DOWNLOAD_MERGE_FAILED); } return $concatFile; } /** * Get the mimetype of $filename and store them in $rcMimetype. * Checks if the extension of $outputFilename fit's to the mimetype. If not, append the mimetype extension. * * @param string $filename * @param string $outputFilename * @param string $rcMimetype * * @return string possible updated $outputFilename, according the mimetype. */ private function targetFilenameExtension($filename, $outputFilename, &$rcMimetype) { $rcMimetype = mime_content_type($filename); return $outputFilename; } /** * Set header type and output $filename. Be careful not to send any additional characters. * * @param $file * @param $outputFilename * @throws \DownloadException */ private function outputFile($file, $outputFilename) { $json = ''; $flagJson = ($this->getOutputFormat() === DOWNLOAD_OUTPUT_FORMAT_JSON); $outputFilename = $this->targetFilenameExtension($file, $outputFilename, $mimeType); $outputFilename = Sanitize::safeFilename($outputFilename); // be sure that there are no problematic chars in the filename. E.g. MacOS X don't like spaces for downloads. if ($flagJson) { if (false === ($json = json_encode([JSON_TEXT => file_get_contents($file)]))) { throw new \DownloadException(json_encode( [ERROR_MESSAGE_TO_USER => 'Error converting to JSON', ERROR_MESSAGE_TO_DEVELOPER => "json_last_error()=" . json_last_error() . ", File=" . $file]), ERROR_DOWNLOAD_JSON_CONVERT); } $length = strlen($json); $mimeType = 'application/json'; } else { $length = filesize($file); } header("Content-type: $mimeType"); header("Content-Length: $length"); if (!$flagJson) { // If defined as 'attachment': PDFs are not shown inside the browser (if user configured that). Instead, always a 'save as'-dialog appears (Chrome, FF) // header("Content-Disposition: attachment; filename=$outputFilename"); header("Content-Disposition: inline; filename=\"$outputFilename\"; name=\"$outputFilename\""); } header("Pragma: no-cache"); header("Expires: 0"); if ($flagJson) { print $json; } else { readfile($file); } } /** * Interprets $element and fetches corresponding content, either as a file or the content in a variable. * * @param string $element - U:id=myExport&r=12, u:http://www.nzz.ch/issue?nr=21, f:fileadmin/sample.pdf * * @param string $downloadMode - DOWNLOAD_MODE_EXCEL | .... * @param string $rcData - With $downloadMode=DOWNLOAD_MODE_EXCEL, this contains the rendered code from the given T3 page. * @return string filename - already ready or fresh exported. Fresh exported needs to be deleted later. * @throws \CodeException * @throws \DbException * @throws \DownloadException * @throws \UserFormException * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception */ private function getElement($element, $downloadMode, &$rcData) { $filename = ''; $rcArgs = array(); $rcSipEncode = false; $arr = explode(':', $element, 2); if (count($arr) != 2) { $possibleReason = ($element === '') ? 'If this is a download link, did you forget to include s:1?' : ''; throw new \DownloadException("Missing parameter for '$element'. $possibleReason", ERROR_MISSING_REQUIRED_PARAMETER); } $token = $arr[0]; $value = $arr[1]; if ($token === TOKEN_UID) { // extract uid $uidParamsArr = explode('&', $value, 2); $uid = $uidParamsArr[0]; $value = $uidParamsArr[1] ?? ''; // additional params } switch ($token) { case TOKEN_URL: case TOKEN_URL_PARAM: case TOKEN_PAGE: case TOKEN_UID: $urlParam = OnString::splitParam($value, $rcArgs, $rcSipEncode); $urlParamString = KeyValueStringParser::unparse($urlParam, '=', '&'); if ($rcSipEncode) { $sip = new Sip(); $urlParamString = $sip->queryStringToSip($urlParamString, RETURN_URL); } if ($downloadMode == DOWNLOAD_MODE_EXCEL) { if ($token === TOKEN_UID) { $rcData = $this->getEvaluatedBodytext($uid, $urlParam); } else { $baseUrl = $this->store->getVar(SYSTEM_BASE_URL, STORE_SYSTEM); $rcData = DownloadPage::getContent($urlParamString, $baseUrl); } } else { if ($token === TOKEN_UID) { // create tmp html document with bodytext $htmlText = $this->getEvaluatedBodytext($uid, $urlParam); $tmpFilename = HelperFile::tempnam() . '.html'; $tmpFile = fopen($tmpFilename, "w") or die('Cannot create file: ' . $tmpFilename); fwrite($tmpFile, $htmlText); fclose($tmpFile); $rcArgsString = KeyValueStringParser::unparse($rcArgs, '=', '&'); $url = Support::mergeUrlComponents('', $tmpFilename, $rcArgsString); $filename = $this->html2pdf->page2pdf($token, $url); HelperFile::cleanTempFiles([$tmpFilename]); } else { $filename = $this->html2pdf->page2pdf($token, $value); } } break; case TOKEN_FILE: case TOKEN_FILE_DEPRECATED: $filename = $value; break; default: throw new \DownloadException('Unknown token: "' . $token . '"', ERROR_UNKNOWN_TOKEN); break; } return $filename; } /** * @param $uid * @param array $urlParam * * @return string * @throws \CodeException * @throws \DbException * @throws \DownloadException * @throws \UserFormException * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception */ private function getEvaluatedBodyText($uid, $urlParam) { foreach ($urlParam as $key => $paramValue) { $this->store->setVar($key, $paramValue, STORE_SIP); } $dbT3 = $this->store->getVar(SYSTEM_DB_NAME_T3, STORE_SYSTEM); $sql = "SELECT bodytext FROM $dbT3.tt_content WHERE uid = ?"; $tt_content = $this->db->sql($sql, ROW_EXPECT_1, [$uid]); $qfq = new QuickFormQuery([T3DATA_BODYTEXT => $tt_content[T3DATA_BODYTEXT]], false, false); return $qfq->process(); } /** * Creates a ZIP Files of all given $files * * @param array $files * * @return string ZIP filename - has to be deleted later. * @throws \DownloadException */ private function zipFiles(array $files) { $zipFile = HelperFile::tempnam(); if (false === $zipFile) { throw new \DownloadException("Error creating output file.", ERROR_DOWNLOAD_CREATE_NEW_FILE); } $zip = new \ZipArchive(); if ($zip->open($zipFile, \ZipArchive::CREATE) !== true) { throw new \DownloadException("Error creating/opening new empty zip file: $zipFile", ERROR_IO_OPEN); } $len = strlen(TMP_FILE_PREFIX); $ii = 1; foreach ($files AS $filename) { $localName = substr($filename, strrpos($filename, '/') + 1); if (substr($localName, 0, $len) == TMP_FILE_PREFIX) { $localName = 'file-' . $ii; $ii++; } $zip->addFile($filename, $localName); } $zip->close(); return $zipFile; } /** * $vars[DOWNLOAD_EXPORT_FILENAME] - Optional. '' * $vars[DOWNLOAD_MODE] - Optional. file | pdf | excel | thumbnail | monitor - default is a) 'file' in case of only one or b) 'pdf' in case of multiple sources. * HTML to PDF | Excel * _id= * _= * Direct * _file= * * @param array $vars [ DOWNLOAD_EXPORT_FILENAME, DOWNLOAD_MODE, SIP_DOWNLOAD_PARAMETER ] * * @param string $outputMode OUTPUT_MODE_DIRECT | OUTPUT_MODE_FILE | OUTPUT_MODE_COPY_TO_FILE * @return string Filename of the generated file. The filename only points to a real existing filename with $outputMode=OUTPUT_MODE_FILE | OUTPUT_MODE_COPY_TO_FILE * @throws \CodeException * @throws \DbException * @throws \DownloadException * @throws \UserFormException * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception */ private function doElements(array $vars, $outputMode) { $tmpFiles = array(); $workDir = $this->store->getVar(SYSTEM_SITE_PATH, STORE_SYSTEM); HelperFile::chdir($workDir); $downloadMode = $vars[DOWNLOAD_MODE]; if ($downloadMode == DOWNLOAD_MODE_MONITOR) { $monitor = new Monitor(); return $monitor->dump($vars[TOKEN_L_FILE], $vars[TOKEN_L_TAIL], $vars[TOKEN_L_APPEND]); } if ($downloadMode == DOWNLOAD_MODE_THUMBNAIL) { // Fake $vars control array. $pathFilenameThumbnail = $this->doThumbnail($vars[SIP_DOWNLOAD_PARAMETER]); $downloadMode = DOWNLOAD_MODE_FILE; $vars[SIP_DOWNLOAD_PARAMETER] = TOKEN_FILE . ':' . $pathFilenameThumbnail; } $elements = explode(PARAM_DELIMITER, $vars[SIP_DOWNLOAD_PARAMETER]); // Get all files / content $tmpData = array(); foreach ($elements as $element) { $data = ''; $tmpFiles[] = $this->getElement($element, $downloadMode, $data); if (!empty($data)) { $tmpData[] = $data; } } // Export, Concat File(s) switch ($downloadMode) { case DOWNLOAD_MODE_ZIP: $filename = $this->zipFiles($tmpFiles); if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) { $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename); } break; case DOWNLOAD_MODE_EXCEL: $excel = new Excel(); $filename = $excel->process($tmpFiles, $tmpData); if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) { if (HelperFile::isQfqTemp($filename)) { $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".xlsx"; } else { $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename); } } break; case DOWNLOAD_MODE_FILE: $filename = $tmpFiles[0]; if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) { $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename); } break; case DOWNLOAD_MODE_PDF: $filename = $this->concatPdfFiles($tmpFiles); // try to find a meaningful filename if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) { if (count($tmpFiles) > 1) { $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".pdf"; } else { if (HelperFile::isQfqTemp($filename)) { $vars[DOWNLOAD_EXPORT_FILENAME] = DOWNLOAD_OUTPUT_FILENAME . ".pdf"; } else { $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename); } } } break; default: throw new \DownloadException("Unknown downloadMode: $downloadMode", ERROR_UNKNOWN_MODE); break; } if ($filename != '' && !file_exists($filename)) { throw new \DownloadException(json_encode( [ERROR_MESSAGE_TO_USER => 'Can\'t read file', ERROR_MESSAGE_TO_DEVELOPER => "File: $filename"]), ERROR_IO_FILE_EXIST); } switch ($outputMode) { case OUTPUT_MODE_FILE: break; case OUTPUT_MODE_COPY_TO_FILE: HelperFile::copy($filename, $vars[DOWNLOAD_EXPORT_FILENAME]); HelperFile::cleanTempFiles([$filename]); break; case OUTPUT_MODE_DIRECT: $this->outputFile($filename, $vars[DOWNLOAD_EXPORT_FILENAME]); HelperFile::cleanTempFiles([$filename]); $filename = ''; break; default: throw new \CodeException('Unkown mode: ' . $outputMode, ERROR_UNKNOWN_MODE); } return $filename; } /** * @param string $urlParam * @return string * @throws \CodeException * @throws \UserFormException * @throws \UserReportException */ private function doThumbnail($urlParam) { $thumbnail = new Thumbnail(); $pathFilenameThumbnail = $thumbnail->process($urlParam, THUMBNAIL_VIA_DOWNLOAD); return $pathFilenameThumbnail; } /** * Process download as requested in $vars. Output is either directly send to the browser, or a file which has to be deleted later. * * @param string|array $vars - If $config is an array, take it, else get values from STORE_SIP * @param string $outputMode OUTPUT_MODE_DIRECT | OUTPUT_MODE_FILE * * @return string * @throws \CodeException * @throws \DbException * @throws \DownloadException * @throws \UserFormException * @throws \UserReportException * @throws \PhpOffice\PhpSpreadsheet\Exception * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception */ public function process($vars, $outputMode = OUTPUT_MODE_DIRECT) { if (!is_array($vars)) { $vars = $this->store->getStore(STORE_SIP); } $this->setOutputFormat(empty($vars[DOWNLOAD_OUTPUT_FORMAT]) ? DOWNLOAD_OUTPUT_FORMAT_RAW : $vars[DOWNLOAD_OUTPUT_FORMAT]); return $this->doElements($vars, $outputMode); } /** * @param $outputFormat */ private function setOutputFormat($outputFormat) { $this->outputFormat = $outputFormat; } /** * @return string - DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON */ public function getOutputFormat() { return $this->outputFormat; } }