Download.php 31.3 KB
Newer Older
Carsten  Rose's avatar
Carsten Rose committed
1
2
3
4
5
6
<?php
/**
 * Created by PhpStorm.
 * User: crose
 * Date: 4/17/17
 * Time: 11:32 AM
Carsten  Rose's avatar
Carsten Rose committed
7
8
 *
 * Check: CODING.md > Download
Carsten  Rose's avatar
Carsten Rose committed
9
10
 */

Marc Egger's avatar
Marc Egger committed
11
12
namespace IMATHUZH\Qfq\Core\Report;

13
14
15
use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Helper\DownloadPage;
use IMATHUZH\Qfq\Core\Helper\HelperFile;
Marc Egger's avatar
Marc Egger committed
16
use IMATHUZH\Qfq\Core\Helper\KeyValueStringParser;
17
use IMATHUZH\Qfq\Core\Helper\Logger;
Marc Egger's avatar
Marc Egger committed
18
19
use IMATHUZH\Qfq\Core\Helper\OnArray;
use IMATHUZH\Qfq\Core\Helper\OnString;
20
use IMATHUZH\Qfq\Core\Helper\Path;
Marc Egger's avatar
Marc Egger committed
21
22
use IMATHUZH\Qfq\Core\Helper\Sanitize;
use IMATHUZH\Qfq\Core\Helper\Support;
23
24
25
26
use IMATHUZH\Qfq\Core\QuickFormQuery;
use IMATHUZH\Qfq\Core\Store\Session;
use IMATHUZH\Qfq\Core\Store\Sip;
use IMATHUZH\Qfq\Core\Store\Store;
Carsten  Rose's avatar
Carsten Rose committed
27
28
29
30

/**
 * Class Download
 *
31
32
 * Documentation: PROTOCOL.md >> Download
 *
Carsten  Rose's avatar
Carsten Rose committed
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
 * 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;

62
63
64
    /**
     * @var string Filename where to write download Information
     */
65
    private $downloadDebugLogAbsolute = '';
66

67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
    /**
     * @var string Name of command
     */
    private $qpdf = '';

    /**
     * @var string Name of command
     */
    private $gs = '';

    /**
     * @var string Name of command
     */
    private $pdfunite = '';

82
83
84
85
86
    /**
     * @var string DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON
     */
    private $outputFormat = DOWNLOAD_OUTPUT_FORMAT_RAW;

Carsten  Rose's avatar
Carsten Rose committed
87
88
    /**
     * @param bool|false $phpUnit
Marc Egger's avatar
Marc Egger committed
89
90
91
92
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
Carsten  Rose's avatar
Carsten Rose committed
93
94
95
     */
    public function __construct($phpUnit = false) {

96
        #TODO: rewrite $phpUnit to: "if (!defined('PHPUNIT_QFQ')) {...}"
Carsten  Rose's avatar
Carsten Rose committed
97
98
99
        $this->session = Session::getInstance($phpUnit);
        $this->store = Store::getInstance('', $phpUnit);
        $this->db = new Database();
100
        $this->html2pdf = new Html2Pdf($this->store->getStore(STORE_SYSTEM), $phpUnit);
101

102
103
104
        $this->qpdf = $this->store->getVar(SYSTEM_CMD_QPDF, STORE_SYSTEM);
        $this->gs = $this->store->getVar(SYSTEM_CMD_GS, STORE_SYSTEM);
        $this->pdfunite = $this->store->getVar(SYSTEM_CMD_PDFUNITE, STORE_SYSTEM);
105
        $this->img2Pdf = $this->store->getVar(SYSTEM_CMD_IMG2PDF, STORE_SYSTEM);
106

107
        if (Support::findInSet(SYSTEM_SHOW_DEBUG_INFO_DOWNLOAD, $this->store->getVar(SYSTEM_SHOW_DEBUG_INFO, STORE_SYSTEM))) {
108
            $this->downloadDebugLogAbsolute = Path::absoluteSqlLogFile();
109
        }
Carsten  Rose's avatar
Carsten Rose committed
110
111
112
113
    }

    /**
     * Concatenate all named files to one PDF file. Return name of new full PDF.
114
     * If a source file is an image, it will be converted to a PDF.
Carsten  Rose's avatar
Carsten Rose committed
115
     *
116
117
     * @param array $files Array of source files
     * @param array $filesCleanLater Array of temporarily created files
Carsten  Rose's avatar
Carsten Rose committed
118
     *
Carsten  Rose's avatar
Carsten Rose committed
119
     * @return string  - fileName of concatenated file
Marc Egger's avatar
Marc Egger committed
120
121
122
     * @throws \CodeException
     * @throws \DownloadException
     * @throws \UserFormException
Carsten  Rose's avatar
Carsten Rose committed
123
     */
124
    private function concatFilesToPdf(array $files, array &$filesCleanLater) {
Carsten  Rose's avatar
Carsten Rose committed
125

126
127
128
        // Remove empty entries. Might happen if there was no upload
        $files = OnArray::removeEmptyElementsFromArray($files);

129
130
131
        // Check that all files are of type 'application/pdf'
        foreach ($files as $key => $filename) {
            // Check that all files exist and are readable
132
            if (!is_readable($filename)) {
Marc Egger's avatar
Marc Egger committed
133
                throw new \DownloadException("Error reading file $filename. Not found or no permission", ERROR_DOWNLOAD_FILE_NOT_READABLE);
134
135
            }

Carsten  Rose's avatar
Carsten Rose committed
136
            $mimetype = mime_content_type($filename);
137
138
139
140
141
142
143
144
145

            // If file is an image, convert to PDF
            if (substr($mimetype, 0, 6) == 'image/') {
                $tmpName = $this->doImgToPdf($filename);
                $filesCleanLater[] = $tmpName; // remember to purge later.
                $files[$key] = $tmpName; // replace original reference against temporary one.
                $mimetype = 'application/pdf';
            }

Carsten  Rose's avatar
Carsten Rose committed
146
            if ($mimetype != 'application/pdf') {
Marc Egger's avatar
Marc Egger committed
147
                throw new \DownloadException("Error concat file $filename. Mimetype 'application/pdf' expected, got: $mimetype", ERROR_DOWNLOAD_UNEXPECTED_MIME_TYPE);
Carsten  Rose's avatar
Carsten Rose committed
148
149
150
            }
        }

151
152
153
154
        if (count($files) === 0) {
            return '';
        }

Carsten  Rose's avatar
Carsten Rose committed
155
156
157
        if (count($files) == 1) {
            return $files[0];
        }
Carsten  Rose's avatar
Carsten Rose committed
158

Carsten  Rose's avatar
Carsten Rose committed
159
        $files = OnArray::arrayEscapeshellarg($files);
Carsten  Rose's avatar
Carsten Rose committed
160
161
        $inputFiles = implode(' ', $files);
        if (trim($inputFiles) == '') {
Marc Egger's avatar
Marc Egger committed
162
            throw new \DownloadException('No files to concatenate.', ERROR_DOWNLOAD_NO_FILES);
Carsten  Rose's avatar
Carsten Rose committed
163
164
        }

Carsten  Rose's avatar
Carsten Rose committed
165
166
167
        // Need to create a separate result file, even if it is just a single file (#6929)
        $concatFile = HelperFile::tempnam();
        if (false === $concatFile) {
Marc Egger's avatar
Marc Egger committed
168
            throw new \DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE);
Carsten  Rose's avatar
Carsten Rose committed
169
170
        }

171
172
//        $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.
173
        $cmd = $this->pdfunite . " $inputFiles $concatFile 2>&1"; // Based on poppler. URLs are preserved. Orientation and size are preserved.
Carsten  Rose's avatar
Carsten Rose committed
174

175
176
        if ($this->downloadDebugLogAbsolute != '') {
            Logger::logMessage("Download: $cmd", $this->downloadDebugLogAbsolute);
177
178
        }

179
        $rc = $this->concatPdfFilesPdfUnite($cmd, $output);
Carsten  Rose's avatar
Carsten Rose committed
180
181

        if ($rc != 0) {
Marc Egger's avatar
Marc Egger committed
182
            throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to merge PDF file",
183
184
                    ERROR_MESSAGE_TO_DEVELOPER => "CMD: " . $cmd . "<br>RC: $rc<br>Output: " . implode("<br>", $output)])
                , ERROR_DOWNLOAD_MERGE_FAILED);
Carsten  Rose's avatar
Carsten Rose committed
185
186
187
188
189
        }

        return $concatFile;
    }

190
191
192
193
194
195
196
197
198
199
200
201
202
203
    /**
     * Convert an image to PDF
     *
     * @param $fileImage
     * @throws \DownloadException
     */
    private function doImgToPdf($fileImage) {

        $filePdf = HelperFile::tempnam();
        if (false === $filePdf) {
            throw new \DownloadException('Error creating output file.', ERROR_DOWNLOAD_CREATE_NEW_FILE);
        }

        // img2pdf --pagesize A4 -o out.pdf *.jpg
204
        $cmd = $this->img2Pdf . ' --pagesize A4 -o ' . $filePdf . ' ' . escapeshellarg($fileImage) . ' 2>&1';
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219

        if ($this->downloadDebugLog != '') {
            Logger::logMessage("Download: $cmd", $this->downloadDebugLog);
        }

        exec($cmd, $rcOutput, $rc);
        if ($rc != 0) {
            throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to convert image to PDF.",
                    ERROR_MESSAGE_TO_DEVELOPER => "CMD: $cmd<br>RC: $rc<br>Output: " . implode("<br>", $rcOutput)])
                , ERROR_DOWNLOAD_CONVERT_FAILED);
        }

        return $filePdf;
    }

220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
    /**
     * Fires the merge command.
     * If for any reason the command fails: check if the reason is 'unencrypted files'.
     * If 'yes': try to decrypt them with qpdf.
     * After one decrypt, try merge again.
     * Try to merge and decrypt as long as there are encrypted files.
     *
     * @param $cmd
     * @param $rcOutput
     * @return mixed
     * @throws \DownloadException
     * @throws \UserFormException
     */
    private function concatPdfFilesPdfUnite($cmd, &$rcOutput) {
        $last = '';
        $rcOutput = '-';

237
        // Try to merge the PDFs as long as a problematic PDF has been repaired. Check this by comparing the last and the current output.
238
239
240
241
        while ($last != $rcOutput) {

            $last = $rcOutput; // Remember last

242
            // Merge:
243
244
245
246
247
248
249
250
            exec($cmd, $rcOutput, $rc);

            if ($rc == 0) {
                break; // skip rest if everything is fine
            }

            // Possible output: "Unimplemented Feature: Could not merge encrypted files ('ct.18.06.092-097.pdf')"
            $line = implode(',', $rcOutput);
251
            if (false !== ($line = strstr($line, "Unimplemented Feature: Could not merge encrypted files ("))) {
252
253
254
255
256
257
258
259
260
261
262

                $arr = explode("'", $line, 3);
                if (!empty($arr[1]) && file_exists($arr[1])) {
                    $file = $arr[1]; // problematic file

                    // Create a backup file: only one per day!
                    $backup = $file . date('.Y-m-d');
                    if (!file_exists($backup)) {
                        HelperFile::copy($file, $backup);
                    }

263
                    // Try 1: via 'qpdf --decrypt'
264
                    $cmdQpdf = $this->qpdf . " --decrypt '$backup' '$file' 2>&1"; // Try to decrypt file
265
266
267
                    exec($cmdQpdf, $outputQpdf, $rcQpdf);

                    if ($rcQpdf != 0) {
268
269

                        // Try 2: via 'gs -sDEVICE=pdfwrite'
270
                        $cmdGs = $this->gs . " -sDEVICE=pdfwrite -dNOPAUSE -sOutputFile=\"$file\" -- \"$backup\" 2>&1";
271
272
273
274
275
276
277
278
279
280
                        exec($cmdGs, $outputGs, $rcGs);

                        if ($rcGs != 0) {
                            // qpdf failed: restore origfile in case the $file has been destroyed.
                            HelperFile::copy($backup, $file);
                            throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Failed to decrypt PDF",
                                    ERROR_MESSAGE_TO_DEVELOPER => "CMD1: " . $cmdQpdf . "<br>RC: $rcQpdf<br>Output: " . implode("<br>", $outputQpdf) . '<br>' .
                                        "CMD2: " . $cmdGs . "<br>RC: $rcGs<br>Output: " . implode("<br>", $outputGs)])
                                , ERROR_DOWNLOAD_MERGE_FAILED);
                        }
281
282
283
284
285
286
287
288
                    }
                }
            } else {
                throw new \DownloadException (json_encode([ERROR_MESSAGE_TO_USER => "Merge PDF file failed.",
                        ERROR_MESSAGE_TO_DEVELOPER => "CMD: " . $cmd . "<br>RC: $rc<br>Output: " . implode("<br>", $rcOutput)])
                    , ERROR_DOWNLOAD_MERGE_FAILED);
            }
        }
289

290
291
292
        return $rc;
    }

Carsten  Rose's avatar
Carsten Rose committed
293
    /**
294
     * Get the mimetype of $filename and store them in $rcMimetype.
Carsten  Rose's avatar
Carsten Rose committed
295
     *
296
     * @param string $pathFileName
297
298
     * @param string $outputFilename
     * @param string $rcMimetype
Carsten  Rose's avatar
Carsten Rose committed
299
     *
300
     * @return string possible updated $outputFilename, according the mimetype.
Carsten  Rose's avatar
Carsten Rose committed
301
     */
302
    private function targetFilenameExtension($pathFileName, $outputFilename, &$rcMimetype) {
Carsten  Rose's avatar
Carsten Rose committed
303

304
        if ($pathFileName != '' && file_exists($pathFileName)) {
305

306
307
            $rcMimetype = mime_content_type($pathFileName);
        }
308
309
310
311
312
313
        return $outputFilename;
    }

    /**
     * Set header type and output $filename. Be careful not to send any additional characters.
     *
314
     * @param $file
315
     * @param $outputFilename
Marc Egger's avatar
Marc Egger committed
316
     * @throws \DownloadException
317
     */
318
    private function outputFile($file, $outputFilename) {
319

320
321
        $json = '';
        $flagJson = ($this->getOutputFormat() === DOWNLOAD_OUTPUT_FORMAT_JSON);
322

323
        $outputFilename = $this->targetFilenameExtension($file, $outputFilename, $mimeType);
324
        $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.
325

326
327
        if ($flagJson) {
            if (false === ($json = json_encode([JSON_TEXT => file_get_contents($file)]))) {
Marc Egger's avatar
Marc Egger committed
328
                throw new \DownloadException(json_encode(
329
                    [ERROR_MESSAGE_TO_USER => 'Error converting to JSON',
Marc Egger's avatar
Marc Egger committed
330
                        ERROR_MESSAGE_TO_DEVELOPER => "json_last_error()=" . json_last_error() . ", File=" . $file]), ERROR_DOWNLOAD_JSON_CONVERT);
331
332
333
334
335
336
337
338
339
            }
            $length = strlen($json);

            $mimeType = 'application/json';
        } else {
            $length = filesize($file);
        }

        header("Content-type: $mimeType");
Carsten  Rose's avatar
Carsten Rose committed
340
        header("Content-Length: $length");
341
        if (!$flagJson) {
342
343
344
345
            // 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\"");
        }
Carsten  Rose's avatar
Carsten Rose committed
346
347
        header("Pragma: no-cache");
        header("Expires: 0");
Carsten  Rose's avatar
Carsten Rose committed
348

349
350
        if ($flagJson) {
            print $json;
351
        } else {
352
            readfile($file);
353
        }
Carsten  Rose's avatar
Carsten Rose committed
354
355
    }

356
    /**
357
358
     * @param string $uid
     * @param array $urlParam
359
360
361
362
363
364
365
366
367
368
369
370
371
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     */
372
    private function getEvaluatedBodytext($uid, array $urlParam) {
373
374
375
376
377
378
        $bodyTextArr = $this->db->getBodytext($uid);

        // Copy $urlParam to STORE_SIP
        foreach ($urlParam as $key => $paramValue) {
            $this->store->setVar($key, $paramValue, STORE_SIP);
        }
379
380

        $qfq = new QuickFormQuery($bodyTextArr, false, false);
381
382
383
384
        return $qfq->process();

    }

Carsten  Rose's avatar
Carsten Rose committed
385
    /**
386
     * Interprets $element and fetches corresponding content, either as a file or the content in a variable.
Carsten  Rose's avatar
Carsten Rose committed
387
     *
388
     * @param string $element - U:id=myExport&r=12, u:http://www.nzz.ch/issue?nr=21, F:fileadmin/sample.pdf
Carsten  Rose's avatar
Carsten Rose committed
389
     *
390
     * @param string $downloadMode - DOWNLOAD_MODE_EXCEL | ....
391
     * @param string $rcData - With $downloadMode=DOWNLOAD_MODE_EXCEL, this contains the rendered code from the given T3 page.
Carsten  Rose's avatar
Carsten Rose committed
392
     * @return string filename - already ready or fresh exported. Fresh exported needs to be deleted later.
Marc Egger's avatar
Marc Egger committed
393
394
395
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
396
397
398
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
399
400
401
402
403
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
Carsten  Rose's avatar
Carsten Rose committed
404
     */
405
    private function getElement($element, $downloadMode, &$rcData) {
Carsten  Rose's avatar
Carsten Rose committed
406

407
408
409
410
        $filename = '';
        $rcArgs = array();
        $rcSipEncode = false;

Carsten  Rose's avatar
Carsten Rose committed
411
412
        $arr = explode(':', $element, 2);
        if (count($arr) != 2) {
413
            $possibleReason = ($element === '') ? 'If this is a download link, did you forget to include s:1?' : '';
Marc Egger's avatar
Marc Egger committed
414
            throw new \DownloadException("Missing parameter for '$element'. $possibleReason", ERROR_MISSING_REQUIRED_PARAMETER);
Carsten  Rose's avatar
Carsten Rose committed
415
        }
Carsten  Rose's avatar
Carsten Rose committed
416

Carsten  Rose's avatar
Carsten Rose committed
417
418
        $token = $arr[0];
        $value = $arr[1];
419
        if ($token === TOKEN_UID || $token === TOKEN_SOURCE) { // extract uid
420
421
422
423
            $uidParamsArr = explode('&', $value, 2);
            $uid = $uidParamsArr[0];
            $value = $uidParamsArr[1] ?? ''; // additional params
        }
Carsten  Rose's avatar
Carsten Rose committed
424

Carsten  Rose's avatar
Carsten Rose committed
425
426
427
        switch ($token) {
            case TOKEN_URL:
            case TOKEN_URL_PARAM:
428
            case TOKEN_PAGE:
429
            case TOKEN_UID:
430
431
432
433
434
435
                $urlParam = OnString::splitParam($value, $rcArgs, $rcSipEncode);
                $urlParamString = KeyValueStringParser::unparse($urlParam, '=', '&');
                if ($rcSipEncode) {
                    $sip = new Sip();
                    $urlParamString = $sip->queryStringToSip($urlParamString, RETURN_URL);
                }
436

437
                if ($downloadMode == DOWNLOAD_MODE_EXCEL) {
438
                    if ($token === TOKEN_UID) {
439
                        $rcData = $this->getEvaluatedBodytext($uid, $urlParam);
440
441
442
443
                    } else {
                        $baseUrl = $this->store->getVar(SYSTEM_BASE_URL, STORE_SYSTEM);
                        $rcData = DownloadPage::getContent($urlParamString, $baseUrl);
                    }
444
                } else {
445
446
447
448
449
                    if ($token === TOKEN_UID) {
                        // create tmp html document with bodytext
                        $htmlText = $this->getEvaluatedBodytext($uid, $urlParam);
                        $tmpFilename = HelperFile::tempnam() . '.html';

450
                        $tmpFile = fopen($tmpFilename, "w") or die('Cannot create file:  ' . $tmpFilename);
451
452
453
454
455
                        fwrite($tmpFile, $htmlText);
                        fclose($tmpFile);

                        $rcArgsString = KeyValueStringParser::unparse($rcArgs, '=', '&');
                        $url = Support::mergeUrlComponents('', $tmpFilename, $rcArgsString);
456
457
458
                        $filename = $this->html2pdf->page2pdf($token, $url);
                        HelperFile::cleanTempFiles([$tmpFilename]);

459
                    } else {
460
                        $filename = $this->html2pdf->page2pdf($token, $value);
461
                    }
462
                }
Carsten  Rose's avatar
Carsten Rose committed
463
                break;
Carsten  Rose's avatar
Carsten Rose committed
464

Carsten  Rose's avatar
Carsten Rose committed
465
            case TOKEN_FILE:
466
            case TOKEN_FILE_DEPRECATED:
Carsten  Rose's avatar
Carsten Rose committed
467
468
469
                $filename = $value;
                break;
            default:
Marc Egger's avatar
Marc Egger committed
470
                throw new \DownloadException('Unknown token: "' . $token . '"', ERROR_UNKNOWN_TOKEN);
Carsten  Rose's avatar
Carsten Rose committed
471
                break;
Carsten  Rose's avatar
Carsten Rose committed
472
473
474
475
476
        }

        return $filename;
    }

Carsten  Rose's avatar
Carsten Rose committed
477
478
479
480
    /**
     * Creates a ZIP Files of all given $files
     *
     * @param array $files
Carsten  Rose's avatar
Carsten Rose committed
481
     *
Carsten  Rose's avatar
Carsten Rose committed
482
     * @return string ZIP filename - has to be deleted later.
Marc Egger's avatar
Marc Egger committed
483
     * @throws \DownloadException
Carsten  Rose's avatar
Carsten Rose committed
484
     */
485
    private function zipFiles(array $files) {
Carsten  Rose's avatar
Carsten Rose committed
486

487
        $zipFile = HelperFile::tempnam();
Carsten  Rose's avatar
Carsten Rose committed
488
        if (false === $zipFile) {
Marc Egger's avatar
Marc Egger committed
489
            throw new \DownloadException("Error creating output file.", ERROR_DOWNLOAD_CREATE_NEW_FILE);
Carsten  Rose's avatar
Carsten Rose committed
490
491
492
493
        }

        $zip = new \ZipArchive();

Carsten  Rose's avatar
Carsten Rose committed
494
        if ($zip->open($zipFile, \ZipArchive::CREATE) !== true) {
Marc Egger's avatar
Marc Egger committed
495
            throw new \DownloadException("Error creating/opening new empty zip file: $zipFile", ERROR_IO_OPEN);
Carsten  Rose's avatar
Carsten Rose committed
496
497
        }

498
        $len = strlen(TMP_FILE_PREFIX);
499
        $ii = 1;
500
501
502
503
        foreach ($files as $item) {
            $arr = explode(PARAM_TOKEN_DELIMITER, $item);
            $filename = $arr[0] ?? '';
            $localName = ($arr[1] == '') ? substr($filename, strrpos($filename, '/') + 1) : $arr[1];
504

505
506
            if (substr($localName, 0, $len) == TMP_FILE_PREFIX) {
                $localName = 'file-' . $ii;
507
508
509
                $ii++;
            }

510
            $zip->addFile($filename, Sanitize::safeFilename($localName, false, true));
Carsten  Rose's avatar
Carsten Rose committed
511
512
513
514
515
516
        }
        $zip->close();

        return $zipFile;
    }

517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
    /**
     * Check $param for 'source:.<function name>&arg1=val1&arg2=val2,....'.
     * For each found, expand it  by fire the given QFQ function with the arguments.
     * Be aware, the result might contain again a 'source:..' definition ... do it recursively.
     *
     * Returns a string withut any 'source:' definition.
     *
     * @param string $param
     * @return string
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function checkAndExpandSource($param) {

        if ($param == '') {
            return '';
        }

        $final = '';

        // $param = 'F:file.pdf|uid:123&pId=22|p:htmlcontent&appId=1|source:myFunction&accId=33'
        $elements = explode(PARAM_DELIMITER, $param);

        // $elements = [ 'F:file.pdf', 'uid:123&pId=22;, 'p:htmlcontent&appId=1', 'source:myFunction&accId=33' ]
        foreach ($elements as $element) {
            // E.g.: $element = 'source:myFunction&accId=33' >>
            $arr = explode(PARAM_TOKEN_DELIMITER, $element, 2);

            // Check for 'source:...'  - $arr[0] = 'source'
            if (0 === strcmp($arr[0], TOKEN_SOURCE)) {

                // $arr[1] = 'myFunction&accId=33&grId=44'
                $args = explode('&', $arr[1], 2);
                $urlParam = KeyValueStringParser::parse($args[1] ?? '', '=', '&');

                // Return a list of
                $element = $this->checkAndExpandSource($this->getEvaluatedBodytext($args[0], $urlParam));
                if ($element == '') {
                    continue;
                }
            }

            $final .= '|' . $element;
        }

        return substr($final, 1);
    }

Carsten  Rose's avatar
Carsten Rose committed
574
    /**
575
576
     * $vars[DOWNLOAD_EXPORT_FILENAME] - Optional. '<new filename>'
     * $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.
Carsten  Rose's avatar
Carsten Rose committed
577
578
579
580
     * HTML to PDF | Excel
     *   <i>_id=<Typo3 pageId>
     *   <i>_<key>=<value i>
     * Direct
581
     *   <i>_file=<filename>
Carsten  Rose's avatar
Carsten Rose committed
582
     *
Carsten  Rose's avatar
Carsten Rose committed
583
     * @param array $vars [ DOWNLOAD_EXPORT_FILENAME, DOWNLOAD_MODE, SIP_DOWNLOAD_PARAMETER ]
Carsten  Rose's avatar
Carsten Rose committed
584
     *
Carsten  Rose's avatar
Carsten Rose committed
585
586
     * @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
Marc Egger's avatar
Marc Egger committed
587
588
589
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
590
591
592
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
593
594
595
596
597
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
Carsten  Rose's avatar
Carsten Rose committed
598
     */
599
    private function doElements(array $vars, $outputMode) {
Carsten  Rose's avatar
Carsten Rose committed
600

601
602
        $srcFiles = array();
        $filesCleanLater = array();
Carsten  Rose's avatar
Carsten Rose committed
603

604
        $workDir = Path::absoluteApp();
605
        HelperFile::chdir($workDir);
Carsten  Rose's avatar
Carsten Rose committed
606

Carsten  Rose's avatar
Carsten Rose committed
607
        $downloadMode = $vars[DOWNLOAD_MODE];
608

609
        if ($downloadMode == DOWNLOAD_MODE_MONITOR) {
610
            $monitor = new Monitor();
Carsten  Rose's avatar
Carsten Rose committed
611
612

            return $monitor->dump($vars[TOKEN_L_FILE], $vars[TOKEN_L_TAIL], $vars[TOKEN_L_APPEND]);
613
614
        }

615
616
617
618
619
620
621
        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;
        }

622
623
        // Check and expand 'source:...'
        $elements = explode(PARAM_DELIMITER, $this->checkAndExpandSource($vars[SIP_DOWNLOAD_PARAMETER]));
Carsten  Rose's avatar
Carsten Rose committed
624

625
        // Get all files / content
626
        $tmpData = array();
Carsten  Rose's avatar
Carsten Rose committed
627
        foreach ($elements as $element) {
628
            $data = '';
629
            $srcFiles[] = $this->getElement($element, $downloadMode, $data);
630
631
            if (!empty($data)) {
                $tmpData[] = $data;
632
            }
Carsten  Rose's avatar
Carsten Rose committed
633
634
        }

Carsten  Rose's avatar
Carsten Rose committed
635
636
637
        // Export, Concat File(s)
        switch ($downloadMode) {
            case DOWNLOAD_MODE_ZIP:
638
                $filename = $this->zipFiles($srcFiles);
639
640
641
                if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
                    $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
                }
Carsten  Rose's avatar
Carsten Rose committed
642
643
644
                break;

            case DOWNLOAD_MODE_EXCEL:
645
                $excel = new Excel();
646
                $filename = $excel->process($srcFiles, $tmpData);
647
648
649

                if (empty($filename) || !file_exists($filename)) {
                    throw new \DownloadException(json_encode(
Carsten  Rose's avatar
Carsten Rose committed
650
651
                        [ERROR_MESSAGE_TO_USER => 'New created Excel file is broken.',
                            ERROR_MESSAGE_TO_DEVELOPER => "File: '$filename'"]), ERROR_IO_READ_FILE);
652
653
                }

654
655
656
657
658
659
660
                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);
                    }
                }
Carsten  Rose's avatar
Carsten Rose committed
661
662
                break;

Carsten  Rose's avatar
Carsten Rose committed
663
            case DOWNLOAD_MODE_FILE:
664
                $filename = $srcFiles[0];
665
                if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
666
                    $vars[DOWNLOAD_EXPORT_FILENAME] = basename($filename);
667
                }
Carsten  Rose's avatar
Carsten Rose committed
668
                break;
Carsten  Rose's avatar
Carsten Rose committed
669
670

            case DOWNLOAD_MODE_PDF:
671

672
                $filename = $this->concatFilesToPdf($srcFiles, $filesCleanLater);
673

674
675
                // try to find a meaningful filename
                if (empty($vars[DOWNLOAD_EXPORT_FILENAME])) {
676
                    if (count($srcFiles) > 1) {
677
678
679
680
681
682
683
684
685
                        $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);
                        }
                    }
                }
Carsten  Rose's avatar
Carsten Rose committed
686
687
688
                break;

            default:
Marc Egger's avatar
Marc Egger committed
689
                throw new \DownloadException("Unknown downloadMode: $downloadMode", ERROR_UNKNOWN_MODE);
Carsten  Rose's avatar
Carsten Rose committed
690
691
692
                break;
        }

693
        if ($filename != '' && !file_exists($filename)) {
Marc Egger's avatar
Marc Egger committed
694
            throw new \DownloadException(json_encode(
695
                [ERROR_MESSAGE_TO_USER => 'Can\'t read file',
Marc Egger's avatar
Marc Egger committed
696
                    ERROR_MESSAGE_TO_DEVELOPER => "File: $filename"]), ERROR_IO_FILE_EXIST);
697
698
        }

699
700
701
        switch ($outputMode) {

            case OUTPUT_MODE_FILE:
702
                // At least for sendmail with dynamic PDF created from URL: unlink temporary files later, not now.
703
704
                break;

Carsten  Rose's avatar
Carsten Rose committed
705
706
            case OUTPUT_MODE_COPY_TO_FILE:
                HelperFile::copy($filename, $vars[DOWNLOAD_EXPORT_FILENAME]);
707
                $filesCleanLater[] = $filename;
Carsten  Rose's avatar
Carsten Rose committed
708
709
                break;

710
            case OUTPUT_MODE_DIRECT:
711
                $this->outputFile($filename, $vars[DOWNLOAD_EXPORT_FILENAME]);
712
                $filesCleanLater[] = $filename;
713
                $filename = '';
714
                break;
Carsten  Rose's avatar
Carsten Rose committed
715

716
            default:
Marc Egger's avatar
Marc Egger committed
717
                throw new \CodeException('Unkown mode: ' . $outputMode, ERROR_UNKNOWN_MODE);
718
719
        }

720
721
        HelperFile::cleanTempFiles($filesCleanLater);

722
        return $filename;
Carsten  Rose's avatar
Carsten Rose committed
723
724
    }

725
726
727
    /**
     * @param string $urlParam
     * @return string
Marc Egger's avatar
Marc Egger committed
728
729
730
     * @throws \CodeException
     * @throws \UserFormException
     * @throws \UserReportException
731
     */
732
    private function doThumbnail($urlParam) {
733
734
735
736
737
738
739

        $thumbnail = new Thumbnail();
        $pathFilenameThumbnail = $thumbnail->process($urlParam, THUMBNAIL_VIA_DOWNLOAD);

        return $pathFilenameThumbnail;
    }

740
741
742
743
    /**
     * Retrieve SQL query from QFQ config, specific to script name.
     * Four script names are possible: download.php, dl.php, dl2.php, dl3.php
     *
744
     * @return array   //  [ 'sql' -> 'SELECT "d|F:file.pdf"', 'error' -> 'Record not found']
745
746
747
748
749
750
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function getDirectDownloadSql() {
751
        $scriptName = str_replace('.', '', $this->store->getVar('SCRIPT_NAME', STORE_CLIENT . STORE_EMPTY));
752
753

        // Example: /var/www/html/qfq/dl.php >> dl.php
754
        $scriptName = substr($scriptName, strrpos($scriptName, '/') + 1);
755

756
757
758
759
760
761
762
763
764
765
        $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;
766
767
    }

768
769
770
771
772
773
774
775
776
    /**
     * @return int[]
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    private function getDirectDownloadModeDetails() {

777
778
779
        $arr = $this->getDirectDownloadSql();

        // Get, Clean: with  http://localhost/qfq/typo3conf/ext/qfq/Classes/Api/download.php/help is $_SERVER['PATH_INFO']='/help'.
780
        $pathInfo = $this->store->getVar('PATH_INFO', STORE_CLIENT . STORE_EMPTY, SANITIZE_ALLOW_ALNUMX);
781
782

        $pathInfo = Sanitize::sanitize(urldecode($pathInfo), SANITIZE_ALLOW_ALNUMX);
783
784
785
786
        $param = OnString::splitPathToArray($pathInfo);

        // In case there are more question mark than parameter, duplicate the last parameter until enough parameter filled.
        $last = end($param);
787
        $questionMark = substr_count($arr['sql'], '?');
788
789
790
        while ($questionMark > count($param)) {
            $param[] = $last;
        }
791
792
793
794

        if ($arr['error'] == '') {
            $arr['error'] = 'Key for download not found.';
        }
795
        // Get cmd which defines the download
796
797
        $param = $this->db->sql($arr['sql'], ROW_EXPECT_1, $param, $arr['error']);
        // In case there are more than one column: implode
798
799
800
801
802
803
804
805
806
807
808
809
810
        $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;
    }

Carsten  Rose's avatar
Carsten Rose committed
811
    /**
812
813
     * Process download as requested in $vars. Output is either directly send to the browser, or a file which has to be deleted later.
     *
814
     * @param string|array $vars - If $config is an array, take it, else get values from STORE_SIP
815
816
     * @param string $outputMode OUTPUT_MODE_DIRECT | OUTPUT_MODE_FILE
     *
Carsten  Rose's avatar
Carsten Rose committed
817
     * @return string
Marc Egger's avatar
Marc Egger committed
818
819
820
     * @throws \CodeException
     * @throws \DbException
     * @throws \DownloadException
821
822
823
     * @throws \PhpOffice\PhpSpreadsheet\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Reader\Exception
     * @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
824
825
826
827
828
     * @throws \Twig\Error\LoaderError
     * @throws \Twig\Error\RuntimeError
     * @throws \Twig\Error\SyntaxError
     * @throws \UserFormException
     * @throws \UserReportException
Carsten  Rose's avatar
Carsten Rose committed
829
     */
830
    public function process($vars, $outputMode = OUTPUT_MODE_DIRECT) {
Carsten  Rose's avatar
Carsten Rose committed
831

832
        if (!is_array($vars)) {
833

834
            $vars = $this->store->getStore(STORE_SIP);
835
836
837
838
839

            if ($vars === array()) {
                // No SIP >> this seems to be a DirectDownloadMode
                $vars = $this->getDirectDownloadModeDetails();
            }
840
        }
841

842
843
        $this->setOutputFormat(empty($vars[DOWNLOAD_OUTPUT_FORMAT]) ? DOWNLOAD_OUTPUT_FORMAT_RAW : $vars[DOWNLOAD_OUTPUT_FORMAT]);

844
        return $this->doElements($vars, $outputMode);
Carsten  Rose's avatar
Carsten Rose committed
845
846
    }

847
848
849
    /**
     * @param $outputFormat
     */
850
    private function setOutputFormat($outputFormat) {
851
852
853
854
855
856
        $this->outputFormat = $outputFormat;
    }

    /**
     * @return string - DOWNLOAD_OUTPUT_FORMAT_RAW | DOWNLOAD_OUTPUT_FORMAT_JSON
     */
857
    public function getOutputFormat() {
858
859
        return $this->outputFormat;
    }
860
}
Carsten  Rose's avatar
Carsten Rose committed
861