Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.53% covered (success)
92.53%
347 / 375
71.43% covered (warning)
71.43%
25 / 35
CRAP
0.00% covered (danger)
0.00%
0 / 1
JSignPdfHandler
92.53% covered (success)
92.53%
347 / 375
71.43% covered (warning)
71.43%
25 / 35
120.51
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setJSignPdf
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getJSignPdf
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getJSignParam
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 getHome
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getConfiguredHome
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 createJSignPdfTempFolder
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 initializeJSignPdfConfigurationFiles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 createEmptyFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractPdfVersion
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 normalizePdfVersion
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 isVeryOldPdfVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 replacePdfVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCertificationLevel
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 sign
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSignedContent
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 signUsingVisibleElements
95.65% covered (success)
95.65%
88 / 92
0.00% covered (danger)
0.00%
0 / 1
26
 hasExistingSignatures
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScaleFactor
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 normalizeScaleFactor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readCertificate
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 createTextImage
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
3.05
 mergeBackgroundWithSignature
95.45% covered (success)
95.45%
42 / 44
0.00% covered (danger)
0.00%
0 / 1
3
 prepareBackgroundForPdf
93.33% covered (success)
93.33%
28 / 30
0.00% covered (danger)
0.00%
0 / 1
3.00
 parseSignatureText
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 shouldUseJSignTimestampPlaceholder
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 getSignatureText
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 toJSignParameters
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getTsaParameters
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 getTsaPassword
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 getTsaSettings
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 signWrapper
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 checkTsaError
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 getTsaRejectionMessage
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 checkHashAlgorithmError
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\SignEngine\JSignPdf;
10
11use Imagick;
12use ImagickPixel;
13use OCA\Libresign\AppInfo\Application;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
16use OCA\Libresign\Handler\SignEngine\Pkcs12Handler;
17use OCA\Libresign\Helper\JavaHelper;
18use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService;
19use OCA\Libresign\Service\Policy\PolicyService;
20use OCA\Libresign\Service\Policy\Provider\SignatureText\SignatureTextPolicyValue;
21use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicy;
22use OCA\Libresign\Service\Policy\Provider\Tsa\TsaPolicyValue;
23use OCA\Libresign\Service\SignatureBackgroundService;
24use OCA\Libresign\Service\SignatureTextService;
25use OCA\Libresign\Service\SignerElementsService;
26use OCA\Libresign\Service\TemporarySignatureImageService;
27use OCA\Libresign\Vendor\Jeidison\JSignPDF\JSignPDF;
28use OCA\Libresign\Vendor\Jeidison\JSignPDF\Sign\JSignParam;
29use OCP\Files\File;
30use OCP\IAppConfig;
31use OCP\ITempManager;
32use Psr\Log\LoggerInterface;
33
34class JSignPdfHandler extends Pkcs12Handler {
35    private const float MIN_PDF_VERSION = 1.2;
36    private const string TARGET_OLD_PDF_VERSION = '1.3';
37    private const string TARGET_PDF_VERSION_SHA256 = '1.6';
38    private const int PAGE_FIRST = 1;
39    private const int SCALE_FACTOR_MIN = 5;
40
41    /** @var JSignPDF */
42    private $jSignPdf;
43    /** @var JSignParam */
44    private $jSignParam;
45    private array $parsedSignatureText = [];
46
47    public function __construct(
48        private IAppConfig $appConfig,
49        private LoggerInterface $logger,
50        private SignatureTextService $signatureTextService,
51        private ITempManager $tempManager,
52        private SignatureBackgroundService $signatureBackgroundService,
53        private PolicyService $policyService,
54        protected CertificateEngineFactory $certificateEngineFactory,
55        protected JavaHelper $javaHelper,
56        private DocMdpConfigService $docMdpConfigService,
57        private HashAlgorithmResolver $hashAlgorithmResolver,
58        private TemporarySignatureImageService $temporarySignatureImageService,
59    ) {
60    }
61
62    public function setJSignPdf(JSignPDF $jSignPdf): void {
63        $this->jSignPdf = $jSignPdf;
64    }
65
66    public function getJSignPdf(): JSignPDF {
67        if (!$this->jSignPdf) {
68            // @codeCoverageIgnoreStart
69            $this->setJSignPdf(new JSignPDF());
70            // @codeCoverageIgnoreEnd
71        }
72        return $this->jSignPdf;
73    }
74
75    /**
76     * @psalm-suppress MixedReturnStatement
77     */
78    public function getJSignParam(): JSignParam {
79        if (!$this->jSignParam) {
80            $javaPath = $this->javaHelper->getJavaPath();
81            $tempPath = $this->appConfig->getValueString(Application::APP_ID, 'jsignpdf_temp_path', sys_get_temp_dir() . DIRECTORY_SEPARATOR);
82            if (!is_writable($tempPath)) {
83                throw new \Exception('The path ' . $tempPath . ' is not writtable. Fix this or change the LibreSign app setting jsignpdf_temp_path to a writtable path');
84            }
85            $jSignPdfPath = $this->appConfig->getValueString(Application::APP_ID, 'jsignpdf_path');
86            if (!is_dir($jSignPdfPath)) {
87                throw new \Exception('Invalid JSignPdf path. Run occ libresign:install --jsignpdf');
88            }
89            $home = $this->getHome();
90            $this->jSignParam = (new JSignParam())
91                ->setTempPath($tempPath)
92                ->setIsUseJavaInstalled(empty($javaPath))
93                ->setJavaDownloadUrl('')
94                ->setJSignPdfDownloadUrl('')
95                ->setJSignPdfPath($jSignPdfPath)
96                ->setJavaOptions(['-Duser.home=' . $home])
97                ->setEnvironmentVariables(['JSIGNPDF_HOME' => $home]);
98            if (!empty($javaPath)) {
99                if (!file_exists($javaPath)) {
100                    throw new \Exception('Invalid Java binary. Run occ libresign:install --java');
101                }
102                $this->jSignParam->setJavaPath($javaPath);
103            }
104        }
105        return $this->jSignParam;
106    }
107
108    /**
109     * It's a workaround to create the folder structure that JSignPdf needs. Without
110     * this, the JSignPdf will return the follow message to all commands:
111     * > FINE Config file conf/conf.properties doesn't exists.
112     * > FINE Default property file /root/.JSignPdf doesn't exists.
113     */
114    private function getHome(): string {
115        $configuredHome = $this->getConfiguredHome();
116        if ($configuredHome !== null) {
117            return $configuredHome;
118        }
119
120        $tempFolder = $this->createJSignPdfTempFolder();
121        $this->initializeJSignPdfConfigurationFiles($tempFolder);
122        return $tempFolder;
123    }
124
125    private function getConfiguredHome(): ?string {
126        $jSignPdfHome = $this->appConfig->getValueString(Application::APP_ID, 'jsignpdf_home', '');
127        if ($jSignPdfHome && is_dir($jSignPdfHome)) {
128            return $jSignPdfHome;
129        }
130        return null;
131    }
132
133    private function createJSignPdfTempFolder(): string {
134        $jsignpdfTempFolder = $this->tempManager->getTemporaryFolder('jsignpdf');
135        if (!$jsignpdfTempFolder) {
136            throw new \Exception('Temporary file not accessible');
137        }
138        mkdir(
139            directory: $jsignpdfTempFolder . '/conf',
140            recursive: true
141        );
142        return $jsignpdfTempFolder;
143    }
144
145    private function initializeJSignPdfConfigurationFiles(string $folder): void {
146        $this->createEmptyFile($folder . '/conf/conf.properties');
147        $this->createEmptyFile($folder . '/.JSignPdf');
148    }
149
150    private function createEmptyFile(string $path): void {
151        $file = fopen($path, 'w');
152        fclose($file);
153    }
154
155    private function extractPdfVersion(string $content): ?float {
156        if (!preg_match('/^%PDF-(?<version>\d+(\.\d+)?)/', $content, $match)) {
157            return null;
158        }
159        return (float)$match['version'];
160    }
161
162    /**
163     * Normalizes very old PDFs (1.0/1.1) to 1.3.
164     * Rationale: JSignPDF enum PdfVersion only defines 1.2+; for 1.0/1.1,
165     * PdfVersion.fromCharVersion(...) returns null and SignerLogic.signFile() NPEs.
166     * See JSignPDF 2.3.0 sources: types/PdfVersion.java and SignerLogic.signFile().
167     */
168    private function normalizePdfVersion(string $content): string {
169        $version = $this->extractPdfVersion($content);
170        if ($version === null) {
171            return $content;
172        }
173
174        // Convert very old PDFs (< 1.2) to 1.3 to avoid JSignPDF NullPointerException
175        if ($this->isVeryOldPdfVersion($version)) {
176            return $this->replacePdfVersion($content, self::TARGET_OLD_PDF_VERSION);
177        }
178
179        // Convert PDFs < 1.6 to 1.6 if using SHA-256 (the default hash algorithm)
180        // This prevents "The chosen hash algorithm (SHA-256) requires a newer PDF version" error
181        if ($this->hashAlgorithmResolver->requiresPdfVersionUpgradeForSha256($version)) {
182            return $this->replacePdfVersion($content, self::TARGET_PDF_VERSION_SHA256);
183        }
184
185        return $content;
186    }
187
188    private function isVeryOldPdfVersion(float $version): bool {
189        return $version > 0 && $version < self::MIN_PDF_VERSION;
190    }
191
192    private function replacePdfVersion(string $content, string $newVersion): string {
193        return (string)preg_replace('/^%PDF-\d+(\.\d+)?/', '%PDF-' . $newVersion, $content, 1);
194    }
195
196    private function getCertificationLevel(): ?string {
197        if (!$this->docMdpConfigService->isEnabled()) {
198            return null;
199        }
200
201        return $this->docMdpConfigService->getLevel()->name;
202    }
203
204    #[\Override]
205    public function sign(): File {
206        $this->beforeSign();
207
208        $signedContent = $this->getSignedContent();
209        $this->getInputFile()->putContent($signedContent);
210        return $this->getInputFile();
211    }
212
213    #[\Override]
214    public function getSignedContent(): string {
215        $normalizedPdf = $this->normalizePdfVersion($this->getInputFile()->getContent());
216        $hashAlgorithm = $this->hashAlgorithmResolver->forSignature($this->extractPdfVersion($normalizedPdf));
217        $param = $this->getJSignParam();
218        $param->setCertificate($this->getCertificate())
219            ->setPdf($normalizedPdf)
220            ->setPassword($this->getPassword());
221
222        $parameters = [];
223        $certificationLevel = $this->getCertificationLevel();
224        if ($certificationLevel !== null && !$this->getVisibleElements() && !$this->hasExistingSignatures($normalizedPdf)) {
225            $parameters['-cl'] = $certificationLevel;
226        }
227        $parameters += $this->getTsaParameters();
228        $param->addJSignParameters($parameters);
229        $tsaPassword = $this->getTsaPassword();
230        if ($tsaPassword !== '') {
231            $param->setTsaPassword($tsaPassword);
232        }
233
234        $signed = $this->signUsingVisibleElements($normalizedPdf, $hashAlgorithm);
235        if ($signed) {
236            return $signed;
237        }
238
239        $param->addJSignParameters(['--hash-algorithm' => $hashAlgorithm]);
240        $jSignPdf = $this->getJSignPdf();
241        $jSignPdf->setParam($param);
242        return $this->signWrapper($jSignPdf);
243    }
244
245    private function signUsingVisibleElements(string $normalizedPdf, string $hashAlgorithm): string {
246        $visibleElements = $this->getVisibleElements();
247        if ($visibleElements) {
248            $signed = '';
249            $jSignPdf = $this->getJSignPdf();
250
251            $renderMode = $this->signatureTextService->getRenderMode();
252
253            $params = [
254                '--l2-text' => $this->getSignatureText(),
255                '-V' => null,
256            ];
257
258            // When l2-text is empty, add hash-algorithm at the beginning
259            if ($params['--l2-text'] === '') {
260                $params = [
261                    '--hash-algorithm' => $hashAlgorithm,
262                    '--l2-text' => $params['--l2-text'],
263                    '-V' => null,
264                ];
265            }
266
267            $fontSize = $this->parseSignatureText()['templateFontSize'];
268            if ($fontSize === SignatureTextPolicyValue::DEFAULT_SIGNATURE_FONT_SIZE || !$fontSize || $params['--l2-text'] === '') {
269                $fontSize = 0;
270            }
271
272            $backgroundType = $this->signatureBackgroundService->getSignatureBackgroundType();
273            if ($backgroundType !== 'deleted') {
274                $backgroundPath = $this->signatureBackgroundService->getImagePath();
275            } else {
276                $backgroundPath = '';
277            }
278
279            $certificationLevel = $this->getCertificationLevel();
280            $applyCertification = $certificationLevel !== null && !$this->hasExistingSignatures($normalizedPdf);
281            $elementIndex = 0;
282
283            $originalParam = $this->getJSignParam();
284
285            foreach ($visibleElements as $element) {
286                $elementIndex++;
287                $params['-pg'] = $element->getFileElement()->getPage();
288                if ($params['-pg'] <= self::PAGE_FIRST) {
289                    unset($params['-pg']);
290                }
291                $params['-llx'] = $element->getFileElement()->getLlx();
292                $params['-lly'] = $element->getFileElement()->getLly();
293                $params['-urx'] = $element->getFileElement()->getUrx();
294                $params['-ury'] = $element->getFileElement()->getUry();
295
296                $scaleFactor = $this->getScaleFactor($params['-urx'] - $params['-llx']);
297                if ($fontSize) {
298                    $params['--font-size'] = $fontSize * $scaleFactor;
299                }
300
301                $backgroundPathForElement = $backgroundPath
302                    ? $this->prepareBackgroundForPdf($backgroundPath, $this->normalizeScaleFactor($scaleFactor))
303                    : '';
304
305                $signatureImagePath = $element->getTempFile();
306                $this->temporarySignatureImageService->requireAvailable($signatureImagePath);
307                if ($backgroundType === 'deleted') {
308                    if ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) {
309                        $params['--render-mode'] = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION;
310                        $params['--img-path'] = $this->createTextImage(
311                            width: ($params['-urx'] - $params['-llx']),
312                            height: ($params['-ury'] - $params['-lly']),
313                            fontSize: $this->signatureTextService->getSignatureFontSize() * $scaleFactor,
314                            scaleFactor: $this->normalizeScaleFactor($scaleFactor),
315                        );
316                    } elseif ($signatureImagePath) {
317                        $params['--bg-path'] = $signatureImagePath;
318                    }
319                } elseif ($params['--l2-text'] === '') {
320                    if ($backgroundPathForElement && $signatureImagePath) {
321                        $params['--bg-path'] = $this->mergeBackgroundWithSignature(
322                            $backgroundPathForElement,
323                            $signatureImagePath,
324                            $this->normalizeScaleFactor($scaleFactor),
325                        );
326                    } elseif ($backgroundPathForElement) {
327                        $params['--bg-path'] = $backgroundPathForElement;
328                    } elseif ($signatureImagePath) {
329                        $params['--bg-path'] = $signatureImagePath;
330                    }
331                } else {
332                    if ($renderMode === SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION) {
333                        $params['--render-mode'] = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION;
334                        $params['--bg-path'] = $backgroundPathForElement;
335                        if ($signatureImagePath) {
336                            $params['--img-path'] = $signatureImagePath;
337                        }
338                    } elseif ($renderMode === SignerElementsService::RENDER_MODE_SIGNAME_AND_DESCRIPTION) {
339                        $params['--render-mode'] = SignerElementsService::RENDER_MODE_GRAPHIC_AND_DESCRIPTION;
340                        $params['--bg-path'] = $backgroundPathForElement;
341                        $params['--img-path'] = $this->createTextImage(
342                            width: (int)(($params['-urx'] - $params['-llx']) / 2),
343                            height: $params['-ury'] - $params['-lly'],
344                            fontSize: $this->signatureTextService->getSignatureFontSize() * $scaleFactor,
345                            scaleFactor: $this->normalizeScaleFactor($scaleFactor),
346                        );
347
348                    } else {
349                        $params['--bg-path'] = $backgroundPathForElement;
350                    }
351                }
352
353                // Only add hash-algorithm at the end if l2-text is not empty
354                if ($params['--l2-text'] !== '') {
355                    $params['--hash-algorithm'] = $hashAlgorithm;
356                }
357
358                $param = clone $originalParam;
359                if ($applyCertification && $elementIndex === 1) {
360                    $param->addJSignParameters(['-cl' => $certificationLevel]);
361                }
362                $param->addJSignParameters($this->toJSignParameters($params));
363                $param->setPdf($normalizedPdf);
364                $jSignPdf->setParam($param);
365                $signed = $this->signWrapper($jSignPdf);
366                $normalizedPdf = $signed;
367            }
368            return $signed;
369        }
370        return '';
371    }
372
373    private function hasExistingSignatures(string $pdfContent): bool {
374        return (bool)preg_match('/\/ByteRange\s*\[|\/Type\s*\/Sig\b|\/DocMDP\b|\/Perms\b/', $pdfContent);
375    }
376
377    private function getScaleFactor(float $width): float {
378        $systemWidth = $this->signatureTextService->getFullSignatureWidth();
379        if (!$systemWidth) {
380            return 1;
381        }
382        return $width / $systemWidth;
383    }
384
385    private function normalizeScaleFactor(float $scaleFactor): float {
386        return max($scaleFactor, self::SCALE_FACTOR_MIN);
387    }
388
389    #[\Override]
390    public function readCertificate(): array {
391        $result = $this->certificateEngineFactory
392            ->getEngine()
393            ->readCertificate(
394                $this->getCertificate(),
395                $this->getPassword()
396            );
397
398        if (!is_array($result)) {
399            throw new \RuntimeException('Failed to read certificate data');
400        }
401
402        return $result;
403    }
404
405    private function createTextImage(int $width, int $height, float $fontSize, float $scaleFactor): string {
406        $params = $this->getSignatureParams();
407        if (!empty($params['SignerCommonName'])) {
408            $commonName = $params['SignerCommonName'];
409        } else {
410            $certificateData = $this->readCertificate();
411            $commonName = $certificateData['subject']['CN'] ?? throw new \RuntimeException('Certificate must have a Common Name (CN) in subject field');
412        }
413        $content = $this->signatureTextService->signerNameImage(
414            width: $width,
415            height: $height,
416            text: $commonName,
417            fontSize: $fontSize,
418            scale: $scaleFactor,
419        );
420
421        $tmpPath = $this->tempManager->getTemporaryFile('_text_image.png');
422        if (!$tmpPath) {
423            throw new \Exception('Temporary file not accessible');
424        }
425        file_put_contents($tmpPath, $content);
426        return $tmpPath;
427    }
428
429    private function mergeBackgroundWithSignature(string $backgroundPath, string $signaturePath, float $scaleFactor): string {
430        if (!extension_loaded('imagick')) {
431            throw new \Exception('Extension imagick is not loaded.');
432        }
433        $baseWidth = $this->signatureTextService->getFullSignatureWidth();
434        $baseHeight = $this->signatureTextService->getFullSignatureHeight();
435
436        $canvasWidth = round($baseWidth * $scaleFactor);
437        $canvasHeight = round($baseHeight * $scaleFactor);
438
439        $background = new Imagick($backgroundPath);
440        $signature = new Imagick($signaturePath);
441
442        $background->setImageFormat('png');
443        $signature->setImageFormat('png');
444
445        $background->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
446        $signature->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
447
448        $background->resizeImage(
449            (int)$canvasWidth,
450            (int)$canvasHeight,
451            Imagick::FILTER_LANCZOS,
452            1,
453            true
454        );
455
456        $signature->resizeImage(
457            (int)$canvasWidth,
458            (int)$canvasHeight,
459            Imagick::FILTER_LANCZOS,
460            1,
461            true
462        );
463
464        $canvas = new Imagick();
465        $canvas->newImage((int)$canvasWidth, (int)$canvasHeight, new ImagickPixel('transparent'));
466        $canvas->setImageFormat('png32');
467        $canvas->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
468
469        $bgX = (int)(($canvasWidth - $background->getImageWidth()) / 2);
470        $bgY = (int)(($canvasHeight - $background->getImageHeight()) / 2);
471        $canvas->compositeImage($background, Imagick::COMPOSITE_OVER, $bgX, $bgY);
472
473        $sigX = (int)(($canvasWidth - $signature->getImageWidth()) / 2);
474        $sigY = (int)(($canvasHeight - $signature->getImageHeight()) / 2);
475        $canvas->compositeImage($signature, Imagick::COMPOSITE_OVER, $sigX, $sigY);
476
477        $tmpPath = $this->tempManager->getTemporaryFile('_merged.png');
478        if (!$tmpPath) {
479            throw new \Exception('Temporary file not accessible');
480        }
481        $canvas->writeImage($tmpPath);
482
483        $canvas->clear();
484        $background->clear();
485        $signature->clear();
486
487        return $tmpPath;
488    }
489
490    private function prepareBackgroundForPdf(string $backgroundPath, float $scaleFactor): string {
491        if (!extension_loaded('imagick')) {
492            throw new \Exception('Extension imagick is not loaded.');
493        }
494        $baseWidth = $this->signatureTextService->getFullSignatureWidth();
495        $baseHeight = $this->signatureTextService->getFullSignatureHeight();
496
497        $canvasWidth = (int)round($baseWidth * $scaleFactor);
498        $canvasHeight = (int)round($baseHeight * $scaleFactor);
499
500        $background = new Imagick($backgroundPath);
501        $background->setImageFormat('png');
502        $background->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
503        $background->resizeImage(
504            $canvasWidth,
505            $canvasHeight,
506            Imagick::FILTER_LANCZOS,
507            1,
508            true
509        );
510
511        $canvas = new Imagick();
512        $canvas->newImage($canvasWidth, $canvasHeight, new ImagickPixel('transparent'));
513        $canvas->setImageFormat('png32');
514        $canvas->setImageAlphaChannel(Imagick::ALPHACHANNEL_ACTIVATE);
515
516        $bgX = (int)(($canvasWidth - $background->getImageWidth()) / 2);
517        $bgY = (int)(($canvasHeight - $background->getImageHeight()) / 2);
518        $canvas->compositeImage($background, Imagick::COMPOSITE_OVER, $bgX, $bgY);
519
520        $tmpPath = $this->tempManager->getTemporaryFile('_background.png');
521        if (!$tmpPath) {
522            throw new \Exception('Temporary file not accessible');
523        }
524        $canvas->writeImage($tmpPath);
525
526        $canvas->clear();
527        $background->clear();
528
529        return $tmpPath;
530    }
531
532    private function parseSignatureText(): array {
533        if (!$this->parsedSignatureText) {
534            $params = $this->getSignatureParams();
535            $template = $this->signatureTextService->getTemplate();
536            $params['ServerSignatureDate'] = $this->shouldUseJSignTimestampPlaceholder($template)
537                ? '${timestamp}'
538                : (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))
539                    ->format(\DateTimeInterface::ATOM);
540            $this->parsedSignatureText = $this->signatureTextService->parse(context: $params);
541        }
542        return $this->parsedSignatureText;
543    }
544
545    private function shouldUseJSignTimestampPlaceholder(string $template): bool {
546        if (!preg_match_all('/{{\s*(.*?)\s*}}/s', $template, $matches)) {
547            return true;
548        }
549
550        $hasPlainServerSignatureDate = false;
551        foreach ($matches[1] as $expression) {
552            if (!str_contains($expression, 'ServerSignatureDate')) {
553                continue;
554            }
555            if (trim($expression) === 'ServerSignatureDate') {
556                $hasPlainServerSignatureDate = true;
557                continue;
558            }
559            // Any transformation (for example Twig date filter) requires a real date value.
560            return false;
561        }
562
563        return $hasPlainServerSignatureDate;
564    }
565
566    public function getSignatureText(): string {
567        $renderMode = $this->signatureTextService->getRenderMode();
568        if ($renderMode !== SignerElementsService::RENDER_MODE_GRAPHIC_ONLY) {
569            return $this->parseSignatureText()['parsed'];
570        }
571        return '';
572    }
573
574    /**
575     * Options with a null value are flags. Every other value reaches the
576     * wrapper as a string; the wrapper escapes it for the shell.
577     *
578     * @param array<string, string|int|float|null> $params
579     * @return array<array-key, string>
580     */
581    private function toJSignParameters(array $params): array {
582        $parameters = [];
583        foreach ($params as $option => $value) {
584            if ($value === null) {
585                $parameters[] = $option;
586                continue;
587            }
588            $parameters[$option] = (string)$value;
589        }
590        return $parameters;
591    }
592
593    /**
594     * @return array<string, string>
595     */
596    private function getTsaParameters(): array {
597        $tsaSettings = $this->getTsaSettings();
598        if (empty($tsaSettings['url'])) {
599            return [];
600        }
601
602        $params = [
603            '--tsa-server-url' => $tsaSettings['url'],
604            '--tsa-hash-algorithm' => $this->hashAlgorithmResolver->forTsa(),
605        ];
606        if ($tsaSettings['policy_oid']) {
607            $params['--tsa-policy-oid'] = $tsaSettings['policy_oid'];
608        }
609        if ($this->getTsaPassword() !== '') {
610            $params['--tsa-authentication'] = 'PASSWORD';
611            $params['--tsa-user'] = $tsaSettings['username'];
612        }
613
614        return $params;
615    }
616
617    /**
618     * The TSA password never goes to the command line: the wrapper writes it
619     * to the stdin of JSignPdf.
620     */
621    private function getTsaPassword(): string {
622        $tsaSettings = $this->getTsaSettings();
623        if (empty($tsaSettings['url']) || $tsaSettings['auth_type'] !== 'basic' || empty($tsaSettings['username'])) {
624            return '';
625        }
626        return $this->appConfig->getValueString(Application::APP_ID, TsaPolicy::PASSWORD_APP_CONFIG_KEY, '');
627    }
628
629    /**
630     * @return array{url: string, policy_oid: string, auth_type: string, username: string, hash_algorithm: string}
631     */
632    private function getTsaSettings(): array {
633        $resolved = $this->policyService->resolve(TsaPolicy::KEY)->getEffectiveValue();
634        $settings = TsaPolicyValue::decode($resolved);
635        return $settings;
636    }
637
638    private function signWrapper(JSignPDF $jSignPDF): string {
639        try {
640            return $jSignPDF->sign();
641        } catch (\Throwable $th) {
642            $errorMessage = $th->getMessage();
643
644            // Log before the checks below: they throw, and the original message
645            // from JSignPdf is the only place where the real cause is described.
646            $this->logger->error('Error at JSignPdf side. LibreSign can not do nothing. Follow the error message: ' . $errorMessage);
647
648            $this->checkTsaError($errorMessage);
649            $this->checkHashAlgorithmError($errorMessage);
650
651            throw new \Exception($errorMessage);
652        }
653    }
654
655    private function checkTsaError(string $errorMessage): void {
656        $tsaErrors = ['TSAClientBouncyCastle', 'UnknownHostException', 'Invalid TSA'];
657        $isTsaError = false;
658        foreach ($tsaErrors as $error) {
659            if (str_contains($errorMessage, $error)) {
660                $isTsaError = true;
661                break;
662            }
663        }
664
665        if ($isTsaError) {
666            $rejectionMessage = $this->getTsaRejectionMessage($errorMessage);
667            if ($rejectionMessage !== null) {
668                throw new LibresignException($rejectionMessage);
669            }
670
671            if (str_contains($errorMessage, 'Invalid TSA') && preg_match("/Invalid TSA '([^']+)'/", $errorMessage, $matches)) {
672                $friendlyMessage = 'Timestamp Authority (TSA) service is unavailable. Check DNS/network/firewall connectivity from this server: ' . $matches[1];
673            } else {
674                $friendlyMessage = 'Timestamp Authority (TSA) service error.' . "\n"
675                    . 'Check TSA endpoint and DNS/network/firewall connectivity from this server.';
676            }
677            throw new LibresignException($friendlyMessage);
678        }
679    }
680
681    /**
682     * An HTTP status in the JSignPdf output means that the TSA was reached and
683     * answered: the request was rejected. Reporting DNS/network/firewall in this
684     * case sends whoever is debugging to the wrong place.
685     */
686    private function getTsaRejectionMessage(string $errorMessage): ?string {
687        if (!preg_match('/HTTP response code: (?<status>\d{3})(?: for URL: (?<url>[^\s"\',]+))?/', $errorMessage, $matches)) {
688            return null;
689        }
690
691        $endpoint = isset($matches['url']) && $matches['url'] !== '' ? ': ' . $matches['url'] : '.';
692        return 'Timestamp Authority (TSA) rejected the request with HTTP status ' . $matches['status'] . $endpoint . "\n"
693            . 'The server was reached, so this is not a DNS/network/firewall problem.' . "\n"
694            . 'Check what the authority expects: hash algorithm, policy OID and authentication.';
695    }
696
697    private function checkHashAlgorithmError(string $errorMessage): void {
698        $rows = str_getcsv($errorMessage);
699        $hashAlgorithm = array_filter($rows, fn ($r) => str_contains((string)$r, 'The chosen hash algorithm'));
700
701        if (!empty($hashAlgorithm)) {
702            $hashAlgorithm = current($hashAlgorithm);
703            $hashAlgorithm = trim((string)$hashAlgorithm, 'INFO ');
704            $hashAlgorithm = str_replace('\"', '"', $hashAlgorithm);
705            $hashAlgorithm = preg_replace('/\.( )/', ".\n", $hashAlgorithm);
706            throw new LibresignException($hashAlgorithm);
707        }
708    }
709}