diff --git a/extension/Classes/Core/Constants.php b/extension/Classes/Core/Constants.php index 53885aa27df06085e32308981fec1e9b9bc53484..5ab89fb658317db3d3e1838cdcafda2c1c2ff73e 100644 --- a/extension/Classes/Core/Constants.php +++ b/extension/Classes/Core/Constants.php @@ -679,6 +679,7 @@ const DOWNLOAD_POPUP_REQUEST = 'true'; const DOWNLOAD_POPUP_REPLACE_TEXT = '#downloadPopupReplaceText#'; const DOWNLOAD_POPUP_REPLACE_TITLE = '#downloadPopupReplaceTitle#'; const SYSTEM_DRAG_AND_DROP_JS = 'hasDragAndDropJS'; +const SYSTEM_SQL_DIRECT_DOWNLOAD = 'sqlDirect'; // becomes sqlDirectdownload.php, sqlDirectdl.php, sqlDirectdl2.php, sqlDirectdl3.php const SYSTEM_PARAMETER_LANGUAGE_FIELD_NAME = 'parameterLanguageFieldName'; const CSS_REQUIRED_RIGHT = 'required-right'; diff --git a/extension/Classes/Core/Helper/OnString.php b/extension/Classes/Core/Helper/OnString.php index 2c61ad22309209e2523835479a52796183c15dc5..537be5a9c252c8c61b491b9d13155d02f1ef7921 100644 --- a/extension/Classes/Core/Helper/OnString.php +++ b/extension/Classes/Core/Helper/OnString.php @@ -9,8 +9,6 @@ namespace IMATHUZH\Qfq\Core\Helper; - - /** * Class OnString * @package qfq @@ -237,7 +235,7 @@ class OnString { } // Empty? do nothing - if ($pathInfo == '') { + if ($pathInfo == '') { return ''; } @@ -252,16 +250,16 @@ class OnString { $rcArrId = array(); $rcArrForm = array(); - while (count($param)>0) { + while (count($param) > 0) { - $form= array_shift($param); + $form = array_shift($param); if (!ctype_alnum($form)) { throw new \UserFormException('Expect alphanumeric string', ERROR_BROKEN_PARAMETER); } - $rcArrForm[]=$form; + $rcArrForm[] = $form; $id = array_shift($param); - if (!ctype_digit((string) $id)) { + if (!ctype_digit((string)$id)) { throw new \UserFormException('Expect numerical id', ERROR_BROKEN_PARAMETER); } $rcArrId[] = $id; @@ -270,6 +268,23 @@ class OnString { return $form; } + /** + * Splits a path '/name1/name2/' to [ 'name1', 'name2' ] + * + * @param $pathInfo + * @return array + */ + public static function splitPathToArray($pathInfo) { + $arr = explode('/', $pathInfo); + $final = array(); + foreach ($arr as $item) { + if ($item != '') { + $final[] = $item; + } + } + return $final; + } + /** * Returns true if the subject string contains any of the given words in targets. * Case insensitive! @@ -278,10 +293,8 @@ class OnString { * @param string $subject * @return bool */ - public static function containsOneOfWords(array $words, string $subject): bool - { - foreach($words as $word) - { + public static function containsOneOfWords(array $words, string $subject): bool { + foreach ($words as $word) { if (preg_match("/\b" . $word . "\b/i", $subject)) { return true; } @@ -297,8 +310,7 @@ class OnString { * @param string $needle * @return bool */ - public static function strStartsWith(string $haystack , string $needle) : bool - { + public static function strStartsWith(string $haystack, string $needle): bool { return substr_compare($haystack, $needle, 0, strlen($needle)) === 0; } @@ -310,8 +322,7 @@ class OnString { * @param string $needle * @return bool */ - public static function strEndsWith(string $haystack , string $needle) : bool - { + public static function strEndsWith(string $haystack, string $needle): bool { return substr_compare($haystack, $needle, -strlen($needle)) === 0; } @@ -323,8 +334,7 @@ class OnString { * @param string $needle * @return bool */ - public static function strContains(string $haystack , string $needle) : bool - { + public static function strContains(string $haystack, string $needle): bool { return strpos($haystack, $needle) !== false; } } diff --git a/extension/Classes/Core/Report/Download.php b/extension/Classes/Core/Report/Download.php index 9d413834754f0227e488f0137cdf7a8b1f71502c..0f88d75157d40d13f91f277a9849fa896782eecf 100644 --- a/extension/Classes/Core/Report/Download.php +++ b/extension/Classes/Core/Report/Download.php @@ -680,6 +680,77 @@ class Download { return $pathFilenameThumbnail; } + /** + * Retrieve SQL query from QFQ config, specific to script name. + * Four script names are possible: download.php, dl.php, dl2.php, dl3.php + * + * @return array // [ 'sql' -> 'SELECT "d|F:file.pdf"', 'error' -> 'Record not found'] + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + * @throws \UserReportException + */ + private function getDirectDownloadSql() { + $scriptName = str_replace('.', '', $this->store->getVar('SCRIPT_NAME', STORE_CLIENT . STORE_EMPTY)); + + // Example: /var/www/html/qfq/dl.php >> dl.php + $scriptName = substr($scriptName, strrpos($scriptName, '/') + 1); + + $arr['sql'] = $this->store->getVar(SYSTEM_SQL_DIRECT_DOWNLOAD . $scriptName, STORE_SYSTEM . STORE_EMPTY); + if ($arr['sql'] == '') { + throw new \DownloadException(json_encode([ERROR_MESSAGE_TO_USER => 'Missing SQL', + ERROR_MESSAGE_TO_DEVELOPER => "Missing SQL in QFQ extension config variable: " . SYSTEM_SQL_DIRECT_DOWNLOAD . $scriptName + ]) + , ERROR_MISSING_VALUE); + } + + $arr['error'] = $this->store->getVar(SYSTEM_SQL_DIRECT_DOWNLOAD . $scriptName . 'error', STORE_SYSTEM . STORE_EMPTY); + return $arr; + } + + /** + * @return int[] + * @throws \CodeException + * @throws \DbException + * @throws \UserFormException + * @throws \UserReportException + */ + private function getDirectDownloadModeDetails() { + + $arr = $this->getDirectDownloadSql(); + + // Get, Clean: with http://localhost/qfq/typo3conf/ext/qfq/Classes/Api/download.php/help is $_SERVER['PATH_INFO']='/help'. + $pathInfo = $this->store->getVar('PATH_INFO', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX); + + $pathInfo = Sanitize::sanitize(urldecode($pathInfo), SANITIZE_ALLOW_ALNUMX); + $param = OnString::splitPathToArray($pathInfo); + + // In case there are more question mark than parameter, duplicate the last parameter until enough parameter filled. + $last = end($param); + $questionMark = substr_count($arr['sql'], '?'); + while ($questionMark > count($param)) { + $param[] = $last; + } + + if ($arr['error'] == '') { + $arr['error'] = 'Key for download not found.'; + } + // Get cmd which defines the download + $param = $this->db->sql($arr['sql'], ROW_EXPECT_1, $param, $arr['error']); + // In case there are more than one column: implode + $cmd = implode('', $param); + // Use the link class only to reuse the parsing code of the download element. + $link = new Link(Store::getSipInstance()); + $s = $link->renderLink($cmd, 'r:8|s:1'); + + // Retrieve the generated vars + $vars = Store::getSipInstance()->getVarsFromSip($s); + $vars[SIP_DOWNLOAD_PARAMETER] = base64_decode($vars[SIP_DOWNLOAD_PARAMETER]); + $vars[SIP_SIP] = $s; + + return $vars; + } + /** * Process download as requested in $vars. Output is either directly send to the browser, or a file which has to be deleted later. * @@ -702,7 +773,13 @@ class Download { public function process($vars, $outputMode = OUTPUT_MODE_DIRECT) { if (!is_array($vars)) { + $vars = $this->store->getStore(STORE_SIP); + + if ($vars === array()) { + // No SIP >> this seems to be a DirectDownloadMode + $vars = $this->getDirectDownloadModeDetails(); + } } $this->setOutputFormat(empty($vars[DOWNLOAD_OUTPUT_FORMAT]) ? DOWNLOAD_OUTPUT_FORMAT_RAW : $vars[DOWNLOAD_OUTPUT_FORMAT]); diff --git a/extension/Classes/Core/Report/Link.php b/extension/Classes/Core/Report/Link.php index ed78c3ac6da551d7d52078b63261dd280c0f9115..013925d89719b8a284cd687ffab7e8099090fe78 100644 --- a/extension/Classes/Core/Report/Link.php +++ b/extension/Classes/Core/Report/Link.php @@ -688,7 +688,7 @@ class Link { throw new \UserReportException ("Mode not implemented. internal render mode=$mode", ERROR_UNKNOWN_MODE); break; case '8': - $link = substr($vars[FINAL_HREF], 12); // strip 'index.php?s=' + $link = substr($vars[FINAL_HREF], -SIP_TOKEN_LENGTH); // get only the last 13 characters (the sip) break; default: @@ -850,12 +850,12 @@ class Link { } // Download Link needs some extra work - if (isset($rcTokenGiven[TOKEN_DOWNLOAD]) && $rcTokenGiven[TOKEN_DOWNLOAD]) { + if ($rcTokenGiven[TOKEN_DOWNLOAD] ?? false) { $vars = $this->buildDownloadLate($vars); } // CopyToClipboard (Download) Link needs some extra work - if (isset($rcTokenGiven[TOKEN_COPY_TO_CLIPBOARD]) && $rcTokenGiven[TOKEN_COPY_TO_CLIPBOARD]) { + if ($rcTokenGiven[TOKEN_COPY_TO_CLIPBOARD] ?? false) { $vars = $this->buildCopyToClipboardLate($vars); } @@ -985,11 +985,6 @@ class Link { case LINK_PICTURE: $countLinkPicture++; break; - case NAME_FILE: - case NAME_URL: - case NAME_PAGE: - $countSources++; - break; default: break; } @@ -1008,10 +1003,14 @@ class Link { throw new \UserReportException ("Token Mail and Target at the same time not possible'" . TOKEN_PAGE . "'", ERROR_MULTIPLE_DEFINITION); } - if (isset($tokenGiven[TOKEN_DOWNLOAD]) && count($vars[NAME_COLLECT_ELEMENTS]) == 0 && $countSources == 0) { + if (isset($tokenGiven[TOKEN_DOWNLOAD]) && $vars[NAME_SIP] == "1" && count($vars[NAME_COLLECT_ELEMENTS]) == 0) { throw new \UserReportException ("Missing element sources for download", ERROR_MISSING_REQUIRED_PARAMETER); } + if (isset($tokenGiven[TOKEN_DOWNLOAD]) && $vars[NAME_SIP] == "0" && count($vars[NAME_COLLECT_ELEMENTS]) > 0) { + throw new \UserReportException ("For persistent downloads, sources are not necessary/allowed in the link definition. Instead, please define the sources in QFQ extension setup > file.", ERROR_MISSING_REQUIRED_PARAMETER); + } + if (isset($tokenGiven[TOKEN_ACTION_DELETE])) { $this->checkDeleteParam($vars); } @@ -1107,11 +1106,14 @@ class Link { $vars[NAME_EXTRA_CONTENT_WRAP] = str_replace(DOWNLOAD_POPUP_REPLACE_TEXT, $altText, $vars[NAME_EXTRA_CONTENT_WRAP]); $vars[NAME_EXTRA_CONTENT_WRAP] = str_replace(DOWNLOAD_POPUP_REPLACE_TITLE, 'Download: ' . addslashes($vars[NAME_DOWNLOAD]), $vars[NAME_EXTRA_CONTENT_WRAP]); - $tmpUrlParam = array(); - $tmpUrlParam[DOWNLOAD_MODE] = $this->getDownloadModeNCheck($vars); - $tmpUrlParam[DOWNLOAD_EXPORT_FILENAME] = $vars[NAME_DOWNLOAD]; - $tmpUrlParam[SIP_DOWNLOAD_PARAMETER] = base64_encode(implode(PARAM_DELIMITER, $vars[NAME_COLLECT_ELEMENTS])); - $vars[NAME_URL_PARAM] = KeyValueStringParser::unparse($tmpUrlParam, '=', '&'); + if ($vars[NAME_SIP] == '1' || $vars[NAME_SIP] == '') { + // Special encoding only necessary for SIP based downloads. + $tmpUrlParam = array(); + $tmpUrlParam[DOWNLOAD_MODE] = $this->getDownloadModeNCheck($vars); + $tmpUrlParam[DOWNLOAD_EXPORT_FILENAME] = $vars[NAME_DOWNLOAD]; + $tmpUrlParam[SIP_DOWNLOAD_PARAMETER] = base64_encode(implode(PARAM_DELIMITER, $vars[NAME_COLLECT_ELEMENTS])); + $vars[NAME_URL_PARAM] = KeyValueStringParser::unparse($tmpUrlParam, '=', '&'); + } } // CopyToClipboard @@ -1655,6 +1657,8 @@ EOF; * @param $vars * @return array * @throws \CodeException + * @throws \UserFormException + * @throws \UserReportException */ private function buildDownloadLate($vars) { @@ -1683,6 +1687,11 @@ EOF; } } + // Download links should be by default SIP encoded. + if (($vars[NAME_SIP]) === false) { + $vars[NAME_SIP] = "1"; + } + // No download link! Only text/button if ($vars[NAME_RENDER] == '3') { return $vars; @@ -1703,18 +1712,29 @@ EOF; $onClick = <<<EOF onclick="$('#qfqModalTitle101').text($(this).data('title')); $('#qfqModalText101').text($(this).data('text'));" EOF; - - $vars[NAME_URL] = Path::appToApi(API_DOWNLOAD_PHP); + // Check for SIP encoded download link or persistent download link + if ($vars[NAME_SIP] == '1') { + // SIP encoded. + $vars[NAME_URL] = Path::appToApi(API_DOWNLOAD_PHP); + } else { + // Persistent Download Link + if ($vars[NAME_DOWNLOAD] == '') { + throw new \UserReportException("Persistent download link (s:0): please specify target key like 'd:1234'", ERROR_MISSING_VALUE); + } + // Check if there is a shortcut version defined like d:dl.php/123. + if (strstr($vars[NAME_DOWNLOAD], '.php/') === false) { + // Set the default: API_DOWNLOAD_PHP + $vars[NAME_URL] = Path::appToApi(API_DOWNLOAD_PHP) . '/' . $vars[NAME_DOWNLOAD]; + } else { + // Take given shortcut + $vars[NAME_URL] = $vars[NAME_DOWNLOAD]; + } + } $vars[NAME_LINK_CLASS_DEFAULT] = NO_CLASS; $vars[NAME_EXTRA_CONTENT_WRAP] = '<span ' . $attributes . $onClick . '>'; $vars[NAME_BOOTSTRAP_BUTTON] = '0'; - // Download links should be by default SIP encoded. - if (($vars[NAME_SIP]) === false) { - $vars[NAME_SIP] = "1"; - } - return $vars; } diff --git a/extension/Classes/Core/Report/RestClient.php b/extension/Classes/Core/Report/RestClient.php index ff56ce8cb26b2160532c0e870b0eeac2e351c3c7..d3ccb372e42f4312c7422412df73c71c000a0735 100644 --- a/extension/Classes/Core/Report/RestClient.php +++ b/extension/Classes/Core/Report/RestClient.php @@ -117,6 +117,15 @@ class RestClient { return $param; } + /** + * @param string $method + * @param string $url + * @param string $data + * @param array $header + * @param int $timeout + * @return array + * @throws Exception + */ public static function callApiCurl(string $method, string $url, string $data = '', array $header = [], int $timeout = 5) { // Header: Set content-type if not set diff --git a/extension/Classes/Core/Store/Session.php b/extension/Classes/Core/Store/Session.php index e444afe5f24cd9c35a197c84009f466ee6363c2c..c27a381995a78e2f1624d80757b9dc4cc63cec9f 100644 --- a/extension/Classes/Core/Store/Session.php +++ b/extension/Classes/Core/Store/Session.php @@ -44,6 +44,7 @@ class Session if (self::$phpUnit === true) { self::$sessionLocal = array(); } else { + ini_set('session.cookie_httponly', 1); $lifetime = SYSTEM_COOKIE_LIFETIME; diff --git a/extension/Tests/Unit/Core/Report/LinkTest.php b/extension/Tests/Unit/Core/Report/LinkTest.php index 523ec6fa321755b1a2c5b35658e9f0076ba54afe..d30cdaaa12d5596f923669cf9b3a3ca1cad62936 100644 --- a/extension/Tests/Unit/Core/Report/LinkTest.php +++ b/extension/Tests/Unit/Core/Report/LinkTest.php @@ -1506,17 +1506,30 @@ EOF; $result = $link->renderLink('d'); } + /** + * @expectedException UserReportException + * + * @throws \CodeException + * @throws \UserFormException + * @throws \UserReportException + */ + public function testDownloadException1() { + $link = new Link($this->sip, DB_INDEX_DEFAULT, true); + + // Empty + $result = $link->renderLink('d|F:file.pdf|F:file2.pdf|s:0'); + } + /** * @throws \CodeException * @throws \UserFormException * @throws \UserReportException */ - public function testDownload() { + public function testDownloadSecureLink() { $link = new Link($this->sip, DB_INDEX_DEFAULT, true); // Single file $result = $link->renderLink('d|F:file.pdf'); -// $this->assertEquals('<a href="typo3conf/ext/qfq/Classes/Api/download.php?mode=pdf&_exportFilename=&_b64_download=RjpmaWxlLnBkZg==" class="0" title="Download" ><span class="btn btn-default" data-toggle="modal" data-target="#qfqModal101" data-title="Download: " data-text="Please wait" data-backdrop="static" data-keyboard="false" onclick="$(\'#qfqModalTitle101\').text($(this).data(\'title\')); $(\'#qfqModalText101\').text($(this).data(\'text\'));"><span class="glyphicon glyphicon-file" ></span></span></a>', $result); $this->assertEquals('<a href="typo3conf/ext/qfq/Classes/Api/download.php?s=badcaffee1234" class="0" title="Download" ><span class="btn btn-default" data-toggle="modal" data-target="#qfqModal101" data-title="Download: " data-text="Please wait" data-backdrop="static" data-keyboard="false" onclick="$(\'#qfqModalTitle101\').text($(this).data(\'title\')); $(\'#qfqModalText101\').text($(this).data(\'text\'));"><span class="glyphicon glyphicon-file" ></span></span></a>', $result); // With download filename @@ -1577,6 +1590,25 @@ EOF; } + /** + * @throws \CodeException + * @throws \UserFormException + * @throws \UserReportException + */ + public function testDownloadPersistentLink() { + $link = new Link($this->sip, DB_INDEX_DEFAULT, true); + + Store::setVar(SYSTEM_SQL_DIRECT_DOWNLOAD . 'downloadphp', 'SELECT "d|F:file.pdf"', STORE_SYSTEM); + + $result = $link->renderLink('d:123|s:0'); + $this->assertEquals('<a href="typo3conf/ext/qfq/Classes/Api/download.php/123" class="0" title="Download" ><span class="btn btn-default" data-toggle="modal" data-target="#qfqModal101" data-title="Download: 123" data-text="Please wait" data-backdrop="static" data-keyboard="false" onclick="$(\'#qfqModalTitle101\').text($(this).data(\'title\')); $(\'#qfqModalText101\').text($(this).data(\'text\'));"><span class="glyphicon glyphicon-file" ></span></span></a>', $result); + + Store::setVar(SYSTEM_SQL_DIRECT_DOWNLOAD . 'dlphp', 'SELECT "d|F:file.pdf"', STORE_SYSTEM); + $result = $link->renderLink('d:dl.php/123|s:0'); + $this->assertEquals('<a href="dl.php/123" class="0" title="Download" ><span class="btn btn-default" data-toggle="modal" data-target="#qfqModal101" data-title="Download: dl.php/123" data-text="Please wait" data-backdrop="static" data-keyboard="false" onclick="$(\'#qfqModalTitle101\').text($(this).data(\'title\')); $(\'#qfqModalText101\').text($(this).data(\'text\'));"><span class="glyphicon glyphicon-file" ></span></span></a>', $result); + + } + /** * @throws \CodeException * @throws \UserFormException diff --git a/extension/ext_conf_template.txt b/extension/ext_conf_template.txt index 21ca72536835e0c607878e17b4668c9cd434415d..888a4d3ce73130c9089c93207c0bea0310c070d9 100644 --- a/extension/ext_conf_template.txt +++ b/extension/ext_conf_template.txt @@ -387,6 +387,27 @@ custom29 = # cat=custom/layout; type=string; label=Custom variable 30 custom30 = +# cat=file/file; type=string; label=Query for direct download mode. Access via download.php. No default.: SELECT CONCAT('d:output.pdf|F:', n.pathFileName) FROM notiz AS n WHERE n.id=? AND NOW()<n.expire +sqlDirectdownloadphp = +# cat=file/file; type=string; label=Message shown to the user if record isn't found. +sqlDirectdownloadphperror = +# cat=file/file; type=string; label=Query for direct download mode. Access via dl.php. No default.: SELECT CONCAT('d:output.pdf|F:', n.pathFileName) FROM notiz AS n WHERE n.id=? AND NOW()<n.expire +sqlDirectdlphp = + +# cat=file/file; type=string; label=Message shown to the user if record isn't found. +sqlDirectdlphperror = + +# cat=file/file; type=string; label=Query for direct download mode. Access via dl2.php. No default.: SELECT CONCAT('d:output.pdf|F:', n.pathFileName) FROM notiz AS n WHERE n.id=? AND NOW()<n.expire +sqlDirectdl2php = + +# cat=file/file; type=string; label=Message shown to the user if record isn't found. +sqlDirectdl2phperror = + +# cat=file/file; type=string; label=Query for direct download mode. Access via dl3.php. No default.: SELECT CONCAT('d:output.pdf|F:', n.pathFileName) FROM notiz AS n WHERE n.id=? AND NOW()<n.expire +sqlDirectdl3php = + +# cat=file/file; type=string; label=Message shown to the user if record isn't found. +sqlDirectdl3phperror =