Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.53% covered (success)
91.53%
108 / 118
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
FooterHandler
91.53% covered (success)
91.53%
108 / 118
70.59% covered (warning)
70.59%
12 / 17
42.02
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
 getFooter
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
5
 getMetadata
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getRenderedHtmlFooter
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 setTemplateVar
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPolicyValue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setWriteQrcodeOnFooterOverride
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setTemplateOverride
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getEffectiveFooterPolicyAsJson
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveFooterPolicy
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 prepareTemplateVars
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
13
 getTemplate
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 getDefaultTemplate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getQrCodeImageBase64
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getTemplateVariablesMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isFooterEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPolicyValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Db\File as FileEntity;
13use OCA\Libresign\Exception\LibresignException;
14use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor;
15use OCA\Libresign\Service\Policy\PolicyService;
16use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy;
17use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue;
18use OCA\Libresign\Service\SandboxedTemplateRenderer;
19use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color;
20use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding;
21use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel;
22use OCA\Libresign\Vendor\Endroid\QrCode\QrCode;
23use OCA\Libresign\Vendor\Endroid\QrCode\RoundBlockSizeMode;
24use OCA\Libresign\Vendor\Endroid\QrCode\Writer\PngWriter;
25use OCA\Libresign\Vendor\Mpdf\Mpdf;
26use OCA\Libresign\Vendor\Twig\Error\SyntaxError;
27use OCA\Libresign\Vendor\Twig\Sandbox\SecurityError;
28use OCP\Files\File;
29use OCP\IAppConfig;
30use OCP\IL10N;
31use OCP\ITempManager;
32use OCP\IURLGenerator;
33use OCP\L10N\IFactory;
34
35class FooterHandler {
36    public const DEFAULT_TEMPLATE_PATH = __DIR__ . '/Templates/footer.twig';
37
38    private QrCode $qrCode;
39    private ?string $policyValue = null;
40    private ?string $templateOverride = null;
41    private ?bool $writeQrcodeOnFooterOverride = null;
42    private const int MIN_QRCODE_SIZE = 100;
43    private const float POINT_TO_MILIMETER = 0.3527777778;
44
45    public function __construct(
46        private IAppConfig $appConfig,
47        private PdfMetadataExtractor $pdfMetadataExtractor,
48        private IURLGenerator $urlGenerator,
49        private IL10N $l10n,
50        private IFactory $l10nFactory,
51        private ITempManager $tempManager,
52        private PolicyService $policyService,
53        private TemplateVariables $templateVars,
54    ) {
55    }
56
57    public function getFooter(array $dimensions, bool $forceEnabled = false): string {
58        if (!$forceEnabled && !$this->isFooterEnabled()) {
59            return '';
60        }
61
62        $htmlFooter = $this->getRenderedHtmlFooter($forceEnabled);
63        foreach ($dimensions as $dimension) {
64            if (!isset($pdf)) {
65                $pdf = new Mpdf([
66                    'tempDir' => $this->tempManager->getTempBaseDir(),
67                    'orientation' => 'P',
68                    'margin_left' => 0,
69                    'margin_right' => 0,
70                    'margin_top' => 0,
71                    'margin_bottom' => 0,
72                    'margin_header' => 0,
73                    'margin_footer' => 0,
74                    'format' => [
75                        $dimension['w'] * self::POINT_TO_MILIMETER,
76                        $dimension['h'] * self::POINT_TO_MILIMETER,
77                    ],
78                ]);
79                $pdf->SetDirectionality($this->templateVars->getDirection());
80            }
81            $pdf->AddPage(
82                orientation: 'P',
83                newformat: [
84                    $dimension['w'] * self::POINT_TO_MILIMETER,
85                    $dimension['h'] * self::POINT_TO_MILIMETER,
86                ],
87            );
88
89            $pdf->SetHTMLFooter($htmlFooter);
90        }
91
92        return $pdf->Output('', 'S');
93    }
94
95    public function getMetadata(File $file, FileEntity $fileEntity): array {
96        $metadata = $fileEntity->getMetadata();
97        if (!is_array($metadata) || !isset($metadata['d'])) {
98            $metadata = $this->pdfMetadataExtractor
99                ->setFile($file)
100                ->getPageDimensions();
101        }
102        return $metadata;
103    }
104
105    private function getRenderedHtmlFooter(bool $forceEnabled = false): string {
106        try {
107            return SandboxedTemplateRenderer::render(
108                $this->getTemplate(),
109                $this->prepareTemplateVars($forceEnabled),
110            );
111        } catch (SecurityError $e) {
112            // TRANSLATORS A previously saved PDF footer template contains Twig operations that are no longer allowed; an administrator must edit its policy before signing can continue.
113            throw new LibresignException($this->l10n->t('The configured footer template is not supported. Ask an administrator to review the footer policy.'), 0, $e);
114        } catch (SyntaxError $e) {
115            throw new LibresignException($e->getMessage(), 0, $e);
116        }
117    }
118
119    public function setTemplateVar(string $name, mixed $value): self {
120        $this->templateVars->merge([$name => $value]);
121        return $this;
122    }
123
124    /**
125     * The footer policy to render with, used as is. Without one, the current
126     * policy applies.
127     */
128    public function setPolicyValue(?string $policyValue): self {
129        $this->policyValue = $policyValue;
130        return $this;
131    }
132
133    public function setWriteQrcodeOnFooterOverride(?bool $value): self {
134        $this->writeQrcodeOnFooterOverride = $value;
135        return $this;
136    }
137
138    public function setTemplateOverride(?string $template): self {
139        $this->templateOverride = $template;
140        return $this;
141    }
142
143    public function getEffectiveFooterPolicyAsJson(): string {
144        return (string)$this->getPolicyValue();
145    }
146
147    /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */
148    private function resolveFooterPolicy(): array {
149        return FooterPolicyValue::normalize($this->getPolicyValue());
150    }
151
152    private function prepareTemplateVars(bool $forceEnabled = false): array {
153        $footerPolicy = $this->resolveFooterPolicy();
154
155        if (!$this->templateVars->getSignedBy()) {
156            $this->templateVars->setSignedBy(
157                // TRANSLATORS Default PDF footer sentence stamped after LibreSign applies a digital signature; readers see this on the finished document.
158                $this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.'))
159            );
160        }
161
162        if (!$this->templateVars->getDirection()) {
163            $this->templateVars->setDirection(
164                $this->l10nFactory->getLanguageDirection($this->l10n->getLanguageCode())
165            );
166        }
167
168        if (!$this->templateVars->getLinkToSite()) {
169            $this->templateVars->setLinkToSite(
170                $this->appConfig->getValueString(Application::APP_ID, 'footer_link_to_site', 'https://libresign.coop')
171            );
172        }
173
174        if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) {
175            $validationSite = $footerPolicy['validationSite'];
176            if ($validationSite) {
177                $this->templateVars->setValidationSite(
178                    rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid()
179                );
180            } else {
181                $this->templateVars->setValidationSite(
182                    $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
183                        'uuid' => $this->templateVars->getUuid(),
184                    ])
185                );
186            }
187        }
188
189        if (!$this->templateVars->getValidateIn()) {
190            $validateIn = $this->appConfig->getValueString(Application::APP_ID, 'footer_validate_in', 'Validate in %s.');
191            if ($validateIn === 'Validate in %s.') {
192                // TRANSLATORS Default PDF footer line after signing. "Validate" means check that the digital signature is authentic on LibreSign's public validation page, not approve a document. %s is the validation site URL or name.
193                $this->templateVars->setValidateIn($this->l10n->t('Validate in %s.', ['%s']));
194            } else {
195                $this->templateVars->setValidateIn($validateIn);
196            }
197        }
198
199        $shouldWriteQrcode = $this->writeQrcodeOnFooterOverride ?? $footerPolicy['writeQrcodeOnFooter'];
200        if ($shouldWriteQrcode && $this->templateVars->getValidationSite()) {
201            $this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite()));
202        }
203
204        $vars = $this->templateVars->toArray();
205        foreach ($vars as $key => $value) {
206            if (is_string($value)) {
207                $vars[$key] = htmlentities($value, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401);
208            }
209        }
210
211        return $vars;
212    }
213
214    public function getTemplate(): string {
215        if ($this->templateOverride !== null) {
216            return trim($this->templateOverride) !== '' ? $this->templateOverride : $this->getDefaultTemplate();
217        }
218
219        $footerPolicy = $this->resolveFooterPolicy();
220
221        if ($footerPolicy['customizeFooterTemplate']) {
222            $policyTemplate = trim((string)($footerPolicy['footerTemplate'] ?? ''));
223            if ($policyTemplate !== '') {
224                return $policyTemplate;
225            }
226        }
227        return $this->getDefaultTemplate();
228    }
229
230    public function getDefaultTemplate(): string {
231        return (string)file_get_contents(self::DEFAULT_TEMPLATE_PATH);
232    }
233
234    private function getQrCodeImageBase64(string $text): string {
235        $this->qrCode = new QrCode(
236            data: $text,
237            encoding: new Encoding('UTF-8'),
238            errorCorrectionLevel: ErrorCorrectionLevel::Low,
239            size: self::MIN_QRCODE_SIZE,
240            margin: 4,
241            roundBlockSizeMode: RoundBlockSizeMode::Margin,
242            foregroundColor: new Color(0, 0, 0),
243            backgroundColor: new Color(255, 255, 255)
244        );
245        $writer = new PngWriter();
246        $result = $writer->write($this->qrCode);
247        $qrcode = base64_encode($result->getString());
248
249        $this->templateVars->setQrcodeSize($this->qrCode->getSize() + $this->qrCode->getMargin() * 2);
250
251        return $qrcode;
252    }
253
254    public function getTemplateVariablesMetadata(): array {
255        return $this->templateVars->getVariablesMetadata();
256    }
257
258    private function isFooterEnabled(): bool {
259        return FooterPolicyValue::isEnabled($this->getPolicyValue());
260    }
261
262    private function getPolicyValue(): mixed {
263        return $this->policyValue ?? $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue();
264    }
265}