Skip to content
Merged
81 changes: 52 additions & 29 deletions lib/Handler/FooterHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@

class FooterHandler {
private QrCode $qrCode;
private FileEntity $fileEntity;
private const MIN_QRCODE_SIZE = 100;
private const POINT_TO_MILIMETER = 0.3527777778;
private array $templateVars = [];

private TemplateVariables $templateVars;

public function __construct(
private IAppConfig $appConfig,
Expand All @@ -44,10 +44,10 @@ public function __construct(
private IFactory $l10nFactory,
private ITempManager $tempManager,
) {
$this->templateVars = new TemplateVariables();
}

public function getFooter(array $dimensions, FileEntity $fileEntity): string {
$this->fileEntity = $fileEntity;
public function getFooter(array $dimensions): string {
$add_footer = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'add_footer', true);
if (!$add_footer) {
return '';
Expand All @@ -70,7 +70,7 @@ public function getFooter(array $dimensions, FileEntity $fileEntity): string {
$dimension['h'] * self::POINT_TO_MILIMETER,
],
]);
$pdf->SetDirectionality($this->templateVars['direction']);
$pdf->SetDirectionality($this->templateVars->getDirection());
}
$pdf->AddPage(
orientation: 'P',
Expand Down Expand Up @@ -103,49 +103,72 @@ private function getRenderedHtmlFooter(): string {
);
return $twigEnvironment
->createTemplate($this->getTemplate())
->render($this->getTemplateVars());
->render($this->prepareTemplateVars());
} catch (SyntaxError $e) {
throw new LibresignException($e->getMessage());
}
}

public function setTemplateVar(string $name, mixed $value): self {
$this->templateVars[$name] = $value;
$this->templateVars->merge([$name => $value]);
return $this;
}

private function getTemplateVars(): array {
$this->templateVars['signedBy'] = $this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.'));

$this->templateVars['direction'] = $this->l10nFactory->getLanguageDirection($this->l10n->getLanguageCode());
private function prepareTemplateVars(): array {
if (!$this->templateVars->getSignedBy()) {
$this->templateVars->setSignedBy(
$this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.'))
);
}

$this->templateVars['linkToSite'] = $this->appConfig->getValueString(Application::APP_ID, 'footer_link_to_site', 'https://libresign.coop');
if (!$this->templateVars->getDirection()) {
$this->templateVars->setDirection(
$this->l10nFactory->getLanguageDirection($this->l10n->getLanguageCode())
);
}

$this->templateVars['validationSite'] = $this->appConfig->getValueString(Application::APP_ID, 'validation_site');
if ($this->templateVars['validationSite']) {
$this->templateVars['validationSite'] = rtrim($this->templateVars['validationSite'], '/') . '/' . $this->fileEntity->getUuid();
} else {
$this->templateVars['validationSite'] = $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
'uuid' => $this->fileEntity->getUuid(),
]);
if (!$this->templateVars->getLinkToSite()) {
$this->templateVars->setLinkToSite(
$this->appConfig->getValueString(Application::APP_ID, 'footer_link_to_site', 'https://libresign.coop')
);
}

$this->templateVars['validateIn'] = $this->appConfig->getValueString(Application::APP_ID, 'footer_validate_in', 'Validate in %s.');
if ($this->templateVars['validateIn'] === 'Validate in %s.') {
$this->templateVars['validateIn'] = $this->l10n->t('Validate in %s.', ['%s']);
if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) {
$validationSite = $this->appConfig->getValueString(Application::APP_ID, 'validation_site');
if ($validationSite) {
$this->templateVars->setValidationSite(
rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid()
);
} else {
$this->templateVars->setValidationSite(
$this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
'uuid' => $this->templateVars->getUuid(),
])
);
}
}

foreach ($this->templateVars as $key => $value) {
if (is_string($value)) {
$this->templateVars[$key] = htmlentities($value, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401);
if (!$this->templateVars->getValidateIn()) {
$validateIn = $this->appConfig->getValueString(Application::APP_ID, 'footer_validate_in', 'Validate in %s.');
if ($validateIn === 'Validate in %s.') {
$this->templateVars->setValidateIn($this->l10n->t('Validate in %s.', ['%s']));
} else {
$this->templateVars->setValidateIn($validateIn);
}
}

if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true)) {
$this->templateVars['qrcode'] = $this->getQrCodeImageBase64($this->templateVars['validationSite']);
if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) {
$this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite()));
}

$vars = $this->templateVars->toArray();
foreach ($vars as $key => $value) {
if (is_string($value)) {
$vars[$key] = htmlentities($value, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401);
}
}

return $this->templateVars;
return $vars;
}

private function getTemplate(): string {
Expand All @@ -171,7 +194,7 @@ private function getQrCodeImageBase64(string $text): string {
$result = $writer->write($this->qrCode);
$qrcode = base64_encode($result->getString());

$this->templateVars['qrcodeSize'] = $this->qrCode->getSize() + $this->qrCode->getMargin() * 2;
$this->templateVars->setQrcodeSize($this->qrCode->getSize() + $this->qrCode->getMargin() * 2);

return $qrcode;
}
Expand Down
108 changes: 108 additions & 0 deletions lib/Handler/TemplateVariables.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Handler;

/**
* @method self setDirection(string $value)
* @method self setLinkToSite(string $value)
* @method self setQrcode(string $value)
* @method self setQrcodeSize(int $value)
* @method self setSignedBy(string $value)
* @method self setSigners(array $value)
* @method self setUuid(string $value)
* @method self setValidateIn(string $value)
* @method self setValidationSite(string $value)
* @method string|null getDirection()
* @method string|null getLinkToSite()
* @method string|null getQrcode()
* @method int|null getQrcodeSize()
* @method string|null getSignedBy()
* @method array|null getSigners()
* @method string|null getUuid()
* @method string|null getValidateIn()
* @method string|null getValidationSite()
*/
class TemplateVariables {
private array $variables = [];

/**
* Allowed template variable names with their expected types
*/
private const ALLOWED_VARIABLES = [
'direction' => 'string',
'linkToSite' => 'string',
'qrcode' => 'string',
'qrcodeSize' => 'integer',
'signedBy' => 'string',
'signers' => 'array',
'uuid' => 'string',
'validateIn' => 'string',
'validationSite' => 'string',
];

/**
* @throws \InvalidArgumentException if trying to access non-whitelisted variable or wrong type
*/
public function __call(string $method, array $args): mixed {
if (str_starts_with($method, 'set')) {
$key = lcfirst(substr($method, 3));
$this->ensureAllowed($key);
$this->ensureType($key, $args[0]);

$this->variables[$key] = $args[0];
return $this;
}

if (str_starts_with($method, 'get')) {
$key = lcfirst(substr($method, 3));
$this->ensureAllowed($key);
return $this->variables[$key] ?? null;
}

throw new \BadMethodCallException("Method {$method} does not exist");
}

private function ensureAllowed(string $key): void {
if (!array_key_exists($key, self::ALLOWED_VARIABLES)) {
throw new \InvalidArgumentException("Template variable '{$key}' is not allowed");
}
}

private function ensureType(string $key, mixed $value): void {
$expected = self::ALLOWED_VARIABLES[$key];
$actual = gettype($value);

if ($actual !== $expected) {
throw new \InvalidArgumentException("Template variable '{$key}' must be of type {$expected}, got {$actual}");
}
}

public function has(string $key): bool {
return isset($this->variables[$key]);
}

public function toArray(): array {
return $this->variables;
}

/**
* Merge additional variables, validating against whitelist and types
*
* @throws \InvalidArgumentException if trying to merge non-whitelisted variable or wrong type
*/
public function merge(array $variables): self {
foreach ($variables as $key => $value) {
$this->ensureAllowed($key);
$this->ensureType($key, $value);
}
$this->variables = array_merge($this->variables, $variables);
return $this;
}
}
3 changes: 2 additions & 1 deletion lib/Service/SignFileService.php
Original file line number Diff line number Diff line change
Expand Up @@ -673,13 +673,14 @@ protected function getPdfToSign(File $originalFile): File {
}
$metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile);
$footer = $this->footerHandler
->setTemplateVar('uuid', $this->libreSignFile->getUuid())
->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [
'displayName' => $signer->getDisplayName(),
'signed' => $signer->getSigned()
? $signer->getSigned()->format(DateTimeInterface::ATOM)
: null,
], $this->getSigners()))
->getFooter($metadata['d'], $this->libreSignFile);
->getFooter($metadata['d']);
if ($footer) {
$stamp = $this->tempManager->getTemporaryFile('stamp.pdf');
file_put_contents($stamp, $footer);
Expand Down
55 changes: 42 additions & 13 deletions tests/php/Unit/Handler/FooterHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ private function getClass(): FooterHandler {
public function testGetFooterWithoutValidationSite(): void {
$this->appConfig->setValueBool(Application::APP_ID, 'add_footer', false);
$dimensions = [['w' => 595, 'h' => 842]];
$libresignFile = $this->createMock(\OCA\Libresign\Db\File::class);
$this->l10n = $this->l10nFactory->get(Application::APP_ID);
$actual = $this->getClass()->getFooter($dimensions, $libresignFile);
$actual = $this->getClass()
->setTemplateVar('uuid', 'test-uuid')
->getFooter($dimensions);
$this->assertEmpty($actual);
}

Expand All @@ -74,21 +75,12 @@ public function testGetFooterWithSuccess(string $language, array $settings, arra
'h' => 595,
],
];
$libresignFile = $this->createMock(\OCA\Libresign\Db\File::class);
$libresignFile
->method('__call')
->willReturnCallback(fn ($key, $default): array|string => match ($key) {
'getMetadata' => [
'd' => $dimensions,
],
'getUuid' => 'uuid',
default => '',
});

$this->l10n = $this->l10nFactory->get(Application::APP_ID, $language);

$pdf = $this->getClass()
->getFooter($dimensions, $libresignFile);
->setTemplateVar('uuid', 'uuid')
->getFooter($dimensions);
if ($settings['add_footer']) {
$actual = $this->extractPdfContent(
$pdf,
Expand Down Expand Up @@ -245,4 +237,41 @@ private function extractPdfContent(string $content, array $keys, string $directi
$this->assertNotEmpty($content, 'Fields not found at PDF file');
return array_column($content, $columnValue, $columnKey);
}

public function testGetFooterWithoutUuid(): void {
$this->appConfig->setValueBool(Application::APP_ID, 'add_footer', true);
$this->appConfig->setValueBool(Application::APP_ID, 'write_qrcode_on_footer', true);
$this->appConfig->setValueString(Application::APP_ID, 'footer_template', '<div>{{ signedBy|raw }}</div>');

$dimensions = [['w' => 595, 'h' => 100]];
$this->l10n = $this->l10nFactory->get(Application::APP_ID, 'en');

$pdf = $this->getClass()->getFooter($dimensions);
$this->assertNotEmpty($pdf);

$parser = new \Smalot\PdfParser\Parser();
$pdfParsed = $parser->parseContent($pdf);
$text = $pdfParsed->getText();
$this->assertNotEmpty($text);
}

public function testCustomValidationSiteNotOverwritten(): void {
$this->appConfig->setValueBool(Application::APP_ID, 'add_footer', true);
$this->appConfig->setValueString(Application::APP_ID, 'validation_site', 'https://default.site');
$this->appConfig->setValueString(Application::APP_ID, 'footer_template', '<div>{{ validationSite }}</div>');

$dimensions = [['w' => 595, 'h' => 100]];
$this->l10n = $this->l10nFactory->get(Application::APP_ID, 'en');

$pdf = $this->getClass()
->setTemplateVar('validationSite', 'https://custom.validation.site')
->getFooter($dimensions);

$this->assertNotEmpty($pdf);
$parser = new \Smalot\PdfParser\Parser();
$pdfParsed = $parser->parseContent($pdf);
$text = $pdfParsed->getText();
$this->assertStringContainsString('https://custom.validation.site', $text);
$this->assertStringNotContainsString('https://default.site', $text);
}
}
Loading
Loading