Skip to content

Commit 4875bd5

Browse files
authored
Merge pull request #7650 from LibreSign/feat/issue-7514-stamp-validation-qrcode
fix: expose ValidationURL and qrcode in signature stamp templates
2 parents d0c59f4 + 76cb1db commit 4875bd5

5 files changed

Lines changed: 139 additions & 2 deletions

File tree

lib/Service/SignFileService.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -929,17 +929,31 @@ protected function getSignatureParams(): array {
929929
private function buildBaseSignatureParams(array $certificateData): array {
930930
$issuerCommonName = $this->normalizeCertificateFieldToString($certificateData['issuer']['CN'] ?? '');
931931
$signerCommonName = $this->normalizeCertificateFieldToString($certificateData['subject']['CN'] ?? '');
932+
$documentUuid = $this->libreSignFile?->getUuid() ?? '';
933+
$validationUrl = $documentUuid ? $this->buildValidationUrl($documentUuid) : '';
932934

933935
return [
934-
'DocumentUUID' => $this->libreSignFile?->getUuid(),
936+
'DocumentUUID' => $documentUuid,
935937
'IssuerCommonName' => $issuerCommonName,
936938
'SignerCommonName' => $signerCommonName,
937939
'LocalSignerTimezone' => $this->dateTimeZone->getTimeZone()->getName(),
940+
'ValidationURL' => $validationUrl,
938941
'LocalSignerSignatureDateTime' => (new DateTime('now', new \DateTimeZone('UTC')))
939942
->format(DateTimeInterface::ATOM)
940943
];
941944
}
942945

946+
private function buildValidationUrl(string $uuid): string {
947+
$validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', ''));
948+
if ($validationSite !== '') {
949+
return rtrim($validationSite, '/') . '/' . $uuid;
950+
}
951+
952+
return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
953+
'uuid' => $uuid,
954+
]);
955+
}
956+
943957
private function normalizeCertificateFieldToString(mixed $value): string {
944958
if (is_array($value)) {
945959
$flattened = [];

lib/Service/SignatureTextService.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,20 @@
1515
use ImagickPixel;
1616
use OCA\Libresign\AppInfo\Application;
1717
use OCA\Libresign\Exception\LibresignException;
18+
use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color;
19+
use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding;
20+
use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel;
21+
use OCA\Libresign\Vendor\Endroid\QrCode\QrCode;
22+
use OCA\Libresign\Vendor\Endroid\QrCode\RoundBlockSizeMode;
23+
use OCA\Libresign\Vendor\Endroid\QrCode\Writer\PngWriter;
1824
use OCA\Libresign\Vendor\Twig\Environment;
1925
use OCA\Libresign\Vendor\Twig\Error\SyntaxError;
2026
use OCA\Libresign\Vendor\Twig\Loader\FilesystemLoader;
2127
use OCP\IAppConfig;
2228
use OCP\IDateTimeZone;
2329
use OCP\IL10N;
2430
use OCP\IRequest;
31+
use OCP\IURLGenerator;
2532
use OCP\IUserSession;
2633
use Psr\Log\LoggerInterface;
2734
use Sabre\DAV\UUIDUtil;
@@ -34,12 +41,14 @@ class SignatureTextService {
3441
public const FRONT_SIZE_MAX = 30;
3542
public const DEFAULT_SIGNATURE_WIDTH = 350;
3643
public const DEFAULT_SIGNATURE_HEIGHT = 100;
44+
private const QRCODE_SIZE = 100;
3745
public function __construct(
3846
private IAppConfig $appConfig,
3947
private IL10N $l10n,
4048
private IDateTimeZone $dateTimeZone,
4149
private IRequest $request,
4250
private IUserSession $userSession,
51+
private IURLGenerator $urlGenerator,
4352
protected LoggerInterface $logger,
4453
) {
4554
}
@@ -137,8 +146,10 @@ public function parse(string $template = '', array $context = []): array {
137146
}
138147
if (empty($context)) {
139148
$date = new \DateTime('now', new \DateTimeZone('UTC'));
149+
$documentUuid = UUIDUtil::getUUID();
150+
$validationUrl = $this->buildValidationUrl($documentUuid);
140151
$context = [
141-
'DocumentUUID' => UUIDUtil::getUUID(),
152+
'DocumentUUID' => $documentUuid,
142153
'IssuerCommonName' => 'Acme Cooperative',
143154
'LocalSignerSignatureDateOnly' => ($date)->format('Y-m-d'),
144155
'LocalSignerSignatureDateTime' => ($date)->format(DateTimeInterface::ATOM),
@@ -148,8 +159,17 @@ public function parse(string $template = '', array $context = []): array {
148159
'SignerCommonName' => $this->userSession?->getUser()?->getDisplayName() ?? 'John Doe',
149160
'SignerEmail' => $this->userSession?->getUser()?->getEMailAddress() ?? 'john.doe@libresign.coop',
150161
'SignerUserAgent' => $this->request->getHeader('User-Agent'),
162+
'ValidationURL' => $validationUrl,
163+
'qrcode' => $this->getQrCodeImageBase64($validationUrl),
151164
];
152165
}
166+
167+
if (!isset($context['ValidationURL']) && isset($context['DocumentUUID']) && is_string($context['DocumentUUID']) && $context['DocumentUUID'] !== '') {
168+
$context['ValidationURL'] = $this->buildValidationUrl($context['DocumentUUID']);
169+
}
170+
if (!isset($context['qrcode']) && isset($context['ValidationURL']) && is_string($context['ValidationURL'])) {
171+
$context['qrcode'] = $this->getQrCodeImageBase64($context['ValidationURL']);
172+
}
153173
try {
154174
$twigEnvironment = new Environment(
155175
new FilesystemLoader(),
@@ -189,6 +209,13 @@ public function getAvailableVariables(): array {
189209
'{{SignerCommonName}}' => $this->l10n->t('Common Name (CN) used to identify the document signer.'),
190210
'{{SignerEmail}}' => $this->l10n->t('The signer\'s email is optional and can be left blank.'),
191211
'{{SignerIdentifier}}' => $this->l10n->t('Unique information used to identify the signer (such as email, phone number, or username).'),
212+
'{{ValidationURL}}' => $this->l10n->t('Validation URL of the signed document.'),
213+
// TRANSLATORS This sentence is a description shown in the list of
214+
// available template variables.
215+
// Keep placeholder names unchanged: {{ qrcode }} and {{ValidationURL}}.
216+
// Keep this HTML snippet unchanged:
217+
// <img src="data:image/png;base64,{{ qrcode }}">
218+
'{{qrcode}}' => $this->l10n->t('Base64-encoded PNG QR code for the validation URL. In HTML/Twig, use <img src="data:image/png;base64,{{ qrcode }}">. In plain-text templates, use {{ValidationURL}}.'),
192219
];
193220
$collectMetadata = $this->appConfig->getValueBool(Application::APP_ID, 'collect_metadata', false);
194221
if ($collectMetadata) {
@@ -198,6 +225,24 @@ public function getAvailableVariables(): array {
198225
return $list;
199226
}
200227

228+
private function getQrCodeImageBase64(string $text): string {
229+
$qrCode = new QrCode(
230+
data: $text,
231+
encoding: new Encoding('UTF-8'),
232+
errorCorrectionLevel: ErrorCorrectionLevel::Low,
233+
size: self::QRCODE_SIZE,
234+
margin: 4,
235+
roundBlockSizeMode: RoundBlockSizeMode::Margin,
236+
foregroundColor: new Color(0, 0, 0),
237+
backgroundColor: new Color(255, 255, 255)
238+
);
239+
240+
$writer = new PngWriter();
241+
$result = $writer->write($qrCode);
242+
243+
return base64_encode($result->getString());
244+
}
245+
201246
public function signerNameImage(
202247
string $text,
203248
int $width,
@@ -506,4 +551,15 @@ public function getRenderMode(): string {
506551
public function isEnabled(): bool {
507552
return !empty($this->getTemplate());
508553
}
554+
555+
private function buildValidationUrl(string $uuid): string {
556+
$validationSite = trim($this->appConfig->getValueString(Application::APP_ID, 'validation_site', ''));
557+
if ($validationSite !== '') {
558+
return rtrim($validationSite, '/') . '/' . $uuid;
559+
}
560+
561+
return $this->urlGenerator->linkToRouteAbsolute('libresign.page.validationFileWithShortUrl', [
562+
'uuid' => $uuid,
563+
]);
564+
}
509565
}

tests/php/Unit/Handler/SignEngine/JSignPdfHandlerTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
use OCP\IDateTimeZone;
2727
use OCP\IRequest;
2828
use OCP\ITempManager;
29+
use OCP\IURLGenerator;
2930
use OCP\IUserSession;
3031
use OCP\L10N\IFactory as IL10NFactory;
3132
use PHPUnit\Framework\Attributes\DataProvider;
@@ -74,12 +75,18 @@ public function setUp(): void {
7475
}
7576

7677
private function getInstance(array $methods = []): JSignPdfHandler|MockObject {
78+
$urlGenerator = $this->createMock(IURLGenerator::class);
79+
$urlGenerator
80+
->method('linkToRouteAbsolute')
81+
->willReturnCallback(fn (string $route, array $params): string => 'https://example.test/' . $route . '/' . ($params['uuid'] ?? ''));
82+
7783
$signatureTextService = new SignatureTextService(
7884
$this->appConfig,
7985
\OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID),
8086
\OCP\Server::get(IDateTimeZone::class),
8187
\OCP\Server::get(IRequest::class),
8288
\OCP\Server::get(IUserSession::class),
89+
$urlGenerator,
8390
\OCP\Server::get(LoggerInterface::class),
8491
);
8592

tests/php/Unit/Service/SignFileServiceTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use bovigo\vfs\vfsStream;
1313
use DateTime;
1414
use OC\User\NoUserException;
15+
use OCA\Libresign\AppInfo\Application;
1516
use OCA\Libresign\BackgroundJob\SignSingleFileJob;
1617
use OCA\Libresign\Db\File;
1718
use OCA\Libresign\Db\FileElement;
@@ -147,6 +148,9 @@ public function setUp(): void {
147148
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
148149
$this->secureRandom = \OCP\Server::get(\OCP\Security\ISecureRandom::class);
149150
$this->urlGenerator = $this->createMock(IURLGenerator::class);
151+
$this->urlGenerator
152+
->method('linkToRouteAbsolute')
153+
->willReturnCallback(fn (string $route, array $params): string => 'https://example.test/' . $route . '/' . ($params['uuid'] ?? ''));
150154
$this->identifyMethodMapper = $this->createMock(IdentifyMethodMapper::class);
151155
$this->tempManager = $this->createMock(ITempManager::class);
152156
$this->identifyMethodService = $this->createMock(IdentifyMethodService::class);
@@ -1215,11 +1219,39 @@ public function testGetSignatureParamsCommonName(
12151219
$this->assertEquals($expectedIssuerCN, $actual['IssuerCommonName']);
12161220
$this->assertEquals($expectedSignerCN, $actual['SignerCommonName']);
12171221
$this->assertEquals('uuid', $actual['DocumentUUID']);
1222+
$this->assertEquals('https://example.test/libresign.page.validationFileWithShortUrl/uuid', $actual['ValidationURL']);
12181223
$this->assertArrayHasKey('DocumentUUID', $actual);
1224+
$this->assertArrayHasKey('ValidationURL', $actual);
1225+
$this->assertArrayNotHasKey('qrcode', $actual);
12191226
$this->assertArrayHasKey('LocalSignerTimezone', $actual);
12201227
$this->assertArrayHasKey('LocalSignerSignatureDateTime', $actual);
12211228
}
12221229

1230+
public function testGetSignatureParamsUsesCustomValidationSite(): void {
1231+
$service = $this->getService(['readCertificate']);
1232+
1233+
$this->appConfig->setValueString(Application::APP_ID, 'validation_site', 'https://validator.example/path/');
1234+
1235+
$libreSignFile = new \OCA\Libresign\Db\File();
1236+
$libreSignFile->setUuid('uuid');
1237+
$service->setLibreSignFile($libreSignFile);
1238+
1239+
$service->method('readCertificate')->willReturn([
1240+
'issuer' => ['CN' => 'LibreCode'],
1241+
'subject' => ['CN' => 'Jane Doe'],
1242+
]);
1243+
1244+
$signRequest = $this->createSignRequestMock([
1245+
'getId' => 171,
1246+
'getMetadata' => [],
1247+
]);
1248+
$service->setSignRequest($signRequest);
1249+
1250+
$actual = $this->invokePrivate($service, 'getSignatureParams');
1251+
$this->assertEquals('https://validator.example/path/uuid', $actual['ValidationURL']);
1252+
$this->assertArrayNotHasKey('qrcode', $actual);
1253+
}
1254+
12231255
public static function providerGetSignatureParamsCommonName(): array {
12241256
return [
12251257
'simple CNs' => [

tests/php/Unit/Service/SignatureTextServiceTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use OCP\IDateTimeZone;
1818
use OCP\IL10N;
1919
use OCP\IRequest;
20+
use OCP\IURLGenerator;
2021
use OCP\IUserSession;
2122
use OCP\L10N\IFactory as IL10NFactory;
2223
use PHPUnit\Framework\Attributes\DataProvider;
@@ -30,6 +31,7 @@ final class SignatureTextServiceTest extends \OCA\Libresign\Tests\Unit\TestCase
3031
private IDateTimeZone $dateTimeZone;
3132
private IRequest&MockObject $request;
3233
private IUserSession&MockObject $userSession;
34+
private IURLGenerator&MockObject $urlGenerator;
3335
private LoggerInterface&MockObject $logger;
3436

3537

@@ -39,6 +41,10 @@ public function setUp(): void {
3941
$this->dateTimeZone = \OCP\Server::get(IDateTimeZone::class);
4042
$this->request = $this->createMock(IRequest::class);
4143
$this->userSession = $this->createMock(IUserSession::class);
44+
$this->urlGenerator = $this->createMock(IURLGenerator::class);
45+
$this->urlGenerator
46+
->method('linkToRouteAbsolute')
47+
->willReturnCallback(fn (string $route, array $params): string => 'https://example.test/' . $route . '/' . ($params['uuid'] ?? ''));
4248
$this->logger = $this->createMock(LoggerInterface::class);
4349
}
4450

@@ -49,6 +55,7 @@ private function getClass(): SignatureTextService {
4955
$this->dateTimeZone,
5056
$this->request,
5157
$this->userSession,
58+
$this->urlGenerator,
5259
$this->logger,
5360
);
5461
return $this->service;
@@ -60,6 +67,9 @@ public function testCollectingMetadata(): void {
6067

6168
$actual = $this->getClass()->getAvailableVariables();
6269
$this->assertArrayHasKey('{{SignerIP}}', $actual);
70+
$this->assertArrayHasKey('{{SignerUserAgent}}', $actual);
71+
$this->assertArrayHasKey('{{qrcode}}', $actual);
72+
$this->assertArrayHasKey('{{ValidationURL}}', $actual);
6373

6474
$template = $this->getClass()->getDefaultTemplate();
6575
$this->assertStringContainsString('IP', $template);
@@ -71,6 +81,9 @@ public function testNotCollectingMetadata(): void {
7181

7282
$actual = $this->getClass()->getAvailableVariables();
7383
$this->assertArrayNotHasKey('{{SignerIP}}', $actual);
84+
$this->assertArrayNotHasKey('{{SignerUserAgent}}', $actual);
85+
$this->assertArrayHasKey('{{qrcode}}', $actual);
86+
$this->assertArrayHasKey('{{ValidationURL}}', $actual);
7487

7588
$template = $this->getClass()->getDefaultTemplate();
7689
$this->assertStringNotContainsString('IP', $template);
@@ -130,6 +143,21 @@ public static function providerSave(): array {
130143
];
131144
}
132145

146+
public function testParseShouldGenerateQrcodeAsBase64FromValidationUrl(): void {
147+
$actual = $this->getClass()->parse('{{qrcode}}', [
148+
'DocumentUUID' => 'abc-123',
149+
'ValidationURL' => 'https://validator.example/abc-123',
150+
]);
151+
152+
$this->assertNotEmpty($actual['parsed']);
153+
$this->assertMatchesRegularExpression('/^[A-Za-z0-9+\/=]+$/', $actual['parsed']);
154+
$this->assertNotEquals('https://validator.example/abc-123', $actual['parsed']);
155+
156+
$decoded = base64_decode($actual['parsed'], true);
157+
$this->assertNotFalse($decoded);
158+
$this->assertStringStartsWith("\x89PNG\r\n\x1A\n", $decoded);
159+
}
160+
133161
#[DataProvider('providerSplitAndGetLongestHalfLength')]
134162
public function testSplitAndGetLongestHalfLength(string $text, int $expected): void {
135163
$class = $this->getClass();

0 commit comments

Comments
 (0)