Skip to content

Commit 06908f6

Browse files
authored
Merge pull request #7444 from LibreSign/backport/7442/stable32
[stable32] fix(validation): harden unified files contract
2 parents dc5fe5c + 489797f commit 06908f6

31 files changed

Lines changed: 2068 additions & 240 deletions

lib/Controller/FileController.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,9 @@ public function __construct(
8787
* Validate a file using Uuid
8888
*
8989
* Validate a file returning file data.
90-
* When `nodeType` is `envelope`, the response includes `filesCount`
91-
* and `files` as a list of envelope child files.
90+
* The response always includes `filesCount` and `files`.
91+
* For `nodeType=file`, `filesCount=1` and `files` contains the current file.
92+
* For `nodeType=envelope`, `files` contains envelope child files.
9293
*
9394
* @param string $uuid The UUID of the LibreSign file
9495
* @param bool $showVisibleElements Whether to include visible elements in the response
@@ -118,8 +119,9 @@ public function validateUuid(
118119
* Validate a file using FileId
119120
*
120121
* Validate a file returning file data.
121-
* When `nodeType` is `envelope`, the response includes `filesCount`
122-
* and `files` as a list of envelope child files.
122+
* The response always includes `filesCount` and `files`.
123+
* For `nodeType=file`, `filesCount=1` and `files` contains the current file.
124+
* For `nodeType=envelope`, `files` contains envelope child files.
123125
*
124126
* @param int $fileId The identifier value of the LibreSign file
125127
* @param bool $showVisibleElements Whether to include visible elements in the response
@@ -150,8 +152,9 @@ public function validateFileId(
150152
*
151153
* Validate a binary file returning file data.
152154
* Use field 'file' for the file upload.
153-
* When `nodeType` is `envelope`, the response includes `filesCount`
154-
* and `files` as a list of envelope child files.
155+
* The response always includes `filesCount` and `files`.
156+
* For `nodeType=file`, `filesCount=1` and `files` contains the current file.
157+
* For `nodeType=envelope`, `files` contains envelope child files.
155158
*
156159
* @return DataResponse<Http::STATUS_OK, LibresignValidatedFile, array{}>|DataResponse<Http::STATUS_NOT_FOUND|Http::STATUS_BAD_REQUEST, LibresignActionErrorResponse, array{}>
157160
*

lib/Controller/RequestSignatureController.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ public function __construct(
5656
* Request that a file be signed by a list of signers.
5757
* Each signer in the signers array can optionally include a 'signingOrder' field
5858
* to control the order of signatures when ordered signing flow is enabled.
59-
* When the created entity is an envelope (`nodeType` = `envelope`),
60-
* the returned `data` includes `filesCount` and `files` as a list of
61-
* envelope child files.
59+
* The returned `data` always includes `filesCount` and `files`.
60+
* For `nodeType=file`, `filesCount=1` and `files` contains the current file.
61+
* For `nodeType=envelope`, `files` contains envelope child files.
6262
*
6363
* @param LibresignNewSigner[] $signers Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status
6464
* @param string $name The name of file to sign
@@ -181,12 +181,14 @@ public function updateSign(
181181
'file' => $file,
182182
'signers' => $signers,
183183
'userManager' => $user,
184-
'status' => $status,
185184
'visibleElements' => $visibleElements,
186185
'signatureFlow' => $signatureFlow,
187186
'name' => $name,
188187
'settings' => $settings,
189188
];
189+
if ($status !== null) {
190+
$data['status'] = $status;
191+
}
190192
$this->validateHelper->validateExistingFile($data);
191193
$this->validateHelper->validateFileStatus($data);
192194
$this->validateHelper->validateIdentifySigners($data);
@@ -239,13 +241,16 @@ private function createSignatureRequest(
239241
'file' => $file,
240242
'name' => $name,
241243
'signers' => $signers,
242-
'status' => $status,
243244
'callback' => $callback,
244245
'userManager' => $user,
245246
'signatureFlow' => $signatureFlow,
246247
'settings' => !empty($settings) ? $settings : ($file['settings'] ?? []),
247248
];
248249

250+
if ($status !== null) {
251+
$data['status'] = $status;
252+
}
253+
249254
if ($isEnvelope) {
250255
$data['files'] = $filesToSave;
251256
}

lib/Db/File.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,20 @@ public function getUserId(): string {
100100
}
101101

102102
public function getStatusEnum(): FileStatus {
103-
return FileStatus::from($this->status ?? FileStatus::DRAFT->value);
103+
return FileStatus::from($this->getStatus());
104+
}
105+
106+
public function getStatus(): int {
107+
return $this->status ?? FileStatus::DRAFT->value;
108+
}
109+
110+
public function setStatus(int $status): void {
111+
if (FileStatus::tryFrom($status) === null) {
112+
throw new \InvalidArgumentException(sprintf('Invalid file status code: %d', $status));
113+
}
114+
115+
$this->status = $status;
116+
$this->markFieldUpdated('status');
104117
}
105118

106119
public function setStatusEnum(FileStatus $status): void {

lib/ResponseDefinitions.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,8 @@
395395
* nodeType: 'file'|'envelope',
396396
* signatureFlow: int,
397397
* docmdpLevel: int,
398-
* filesCount?: int<0, max>,
399-
* files?: list<LibresignValidatedChildFile>,
398+
* filesCount: int<0, max>,
399+
* files: list<LibresignValidatedChildFile>,
400400
* totalPages: non-negative-int,
401401
* size: non-negative-int,
402402
* pdfVersion: string,

lib/Service/File/EnvelopeAssembler.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OCA\Libresign\Service\FileElementService;
1717
use OCA\Libresign\Service\IdentifyMethodService;
1818
use OCP\Files\IRootFolder;
19+
use OCP\IURLGenerator;
1920
use Psr\Log\LoggerInterface;
2021

2122
class EnvelopeAssembler {
@@ -24,6 +25,7 @@ public function __construct(
2425
private IdentifyMethodService $identifyMethodService,
2526
private FileMapper $fileMapper,
2627
private IRootFolder $root,
28+
private IURLGenerator $urlGenerator,
2729
private SignersLoader $signersLoader,
2830
private ?CertificateChainService $certificateChainService,
2931
private \OCA\Libresign\Handler\SignEngine\Pkcs12Handler $pkcs12Handler,
@@ -40,11 +42,14 @@ public function buildEnvelopeChildData(File $childFile, \OCA\Libresign\Service\F
4042
$fileData->status = $childFile->getStatus();
4143
$fileData->statusText = $this->fileMapper->getTextOfStatus($childFile->getStatus());
4244
$fileData->nodeId = $childFile->getNodeId();
43-
$fileData->metadata = $childFile->getMetadata();
45+
$fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $childFile->getUuid()]);
4446
$childMetadata = $childFile->getMetadata() ?? [];
4547
$fileData->totalPages = (int)($childMetadata['p'] ?? 0);
4648
$fileData->pdfVersion = (string)($childMetadata['pdfVersion'] ?? '');
4749

50+
$childMetadata = ValidationMetadataNormalizer::normalize($childMetadata, $childFile->getName(), $fileData->totalPages);
51+
$fileData->metadata = $childMetadata;
52+
4853
$nodeId = $childFile->getSignedNodeId() ?: $childFile->getNodeId();
4954
$fileNode = $this->root->getUserFolder($childFile->getUserId())->getFirstNodeById($nodeId);
5055
if ($fileNode instanceof \OCP\Files\File) {

lib/Service/File/MetadataLoader.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ public function loadMetadata(?File $file, stdClass $fileData): void {
3131
return;
3232
}
3333

34+
$rawMetadata = $file->getMetadata() ?? [];
35+
3436
try {
3537
$fileNode = $this->getFileNode($file);
36-
$metadata = $file->getMetadata() ?? [];
37-
38-
$fileData->metadata = $metadata;
38+
$metadata = $rawMetadata;
3939

4040
if (method_exists($fileNode, 'getSize')) {
4141
$fileData->size = $fileNode->getSize();
@@ -49,16 +49,23 @@ public function loadMetadata(?File $file, stdClass $fileData): void {
4949
}
5050

5151
$fileData->pages = $this->getPages($file);
52-
5352
$fileData->totalPages = (int)($metadata['p'] ?? count($fileData->pages ?? []));
5453
$fileData->pdfVersion = (string)($metadata['pdfVersion'] ?? '');
54+
55+
$metadata = ValidationMetadataNormalizer::normalize($metadata, $file->getName(), $fileData->totalPages);
56+
$fileData->metadata = $metadata;
5557
} catch (\Throwable $e) {
5658
$this->logger->warning('Failed to load file metadata: ' . $e->getMessage());
5759
}
5860

5961
$fileData->size ??= 0;
6062
$fileData->totalPages ??= 0;
6163
$fileData->pdfVersion ??= '';
64+
$fileData->metadata = ValidationMetadataNormalizer::normalize(
65+
is_array($fileData->metadata ?? null) ? $fileData->metadata : $rawMetadata,
66+
$file->getName(),
67+
(int)$fileData->totalPages,
68+
);
6269
}
6370

6471
/**
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Libresign\Service\File;
11+
12+
/**
13+
* @psalm-import-type LibresignValidateMetadata from \OCA\Libresign\ResponseDefinitions
14+
*/
15+
final class ValidationMetadataNormalizer {
16+
private const OPTIONAL_SCALAR_TYPE_GUARDS = [
17+
'original_file_deleted' => 'is_bool',
18+
'pdfVersion' => 'is_string',
19+
'status_changed_at' => 'is_string',
20+
];
21+
22+
/**
23+
* @param array<string, mixed> $metadata
24+
* @psalm-return array<string, mixed>&LibresignValidateMetadata
25+
*/
26+
public static function normalize(array $metadata, string $fileName, int $totalPages): array {
27+
$normalized = $metadata;
28+
$normalized['p'] = self::normalizePageCount($totalPages);
29+
$normalized['extension'] = self::normalizeExtension($normalized, $fileName);
30+
31+
self::normalizeOptionalScalarFields($normalized);
32+
self::normalizeDimensionsField($normalized);
33+
34+
return $normalized;
35+
}
36+
37+
private static function normalizePageCount(int $totalPages): int {
38+
return max(0, $totalPages);
39+
}
40+
41+
/**
42+
* @param array<string, mixed> $metadata
43+
*/
44+
private static function normalizeExtension(array $metadata, string $fileName): string {
45+
if (isset($metadata['extension']) && is_string($metadata['extension']) && trim($metadata['extension']) !== '') {
46+
return $metadata['extension'];
47+
}
48+
49+
$extension = pathinfo($fileName, PATHINFO_EXTENSION);
50+
return is_string($extension) && $extension !== '' ? strtolower($extension) : 'pdf';
51+
}
52+
53+
/**
54+
* @param array<string, mixed> $metadata
55+
*/
56+
private static function normalizeOptionalScalarFields(array &$metadata): void {
57+
foreach (self::OPTIONAL_SCALAR_TYPE_GUARDS as $key => $guard) {
58+
if (!array_key_exists($key, $metadata)) {
59+
continue;
60+
}
61+
62+
if (!is_callable($guard) || !$guard($metadata[$key])) {
63+
unset($metadata[$key]);
64+
}
65+
}
66+
}
67+
68+
/**
69+
* @param array<string, mixed> $metadata
70+
*/
71+
private static function normalizeDimensionsField(array &$metadata): void {
72+
if (!array_key_exists('d', $metadata)) {
73+
return;
74+
}
75+
76+
$normalizedDimensions = self::normalizeDimensions($metadata['d']);
77+
if ($normalizedDimensions === null) {
78+
unset($metadata['d']);
79+
return;
80+
}
81+
82+
$metadata['d'] = $normalizedDimensions;
83+
}
84+
85+
/**
86+
* @return list<array{w: float, h: float}>|null
87+
*/
88+
private static function normalizeDimensions(mixed $dimensions): ?array {
89+
if (!is_array($dimensions)) {
90+
return null;
91+
}
92+
93+
$normalized = [];
94+
foreach ($dimensions as $dimension) {
95+
if (!is_array($dimension)
96+
|| !array_key_exists('w', $dimension)
97+
|| !array_key_exists('h', $dimension)
98+
|| !is_numeric($dimension['w'])
99+
|| !is_numeric($dimension['h'])) {
100+
continue;
101+
}
102+
103+
$normalized[] = [
104+
'w' => (float)$dimension['w'],
105+
'h' => (float)$dimension['h'],
106+
];
107+
}
108+
109+
return $normalized === [] ? null : $normalized;
110+
}
111+
}

lib/Service/FileService.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050

5151
/**
5252
* @psalm-import-type LibresignValidatedFile from ResponseDefinitions
53+
* @psalm-import-type LibresignSignerDetail from ResponseDefinitions
54+
* @psalm-import-type LibresignSignerSummary from ResponseDefinitions
5355
*/
5456
class FileService {
5557

@@ -594,12 +596,88 @@ public function toArray(): array {
594596
$this->loadVisibleElements();
595597
$this->loadMessages();
596598
$this->computeEnvelopeSignersProgress();
599+
$this->syncSingleFileCollection();
597600

598601
$return = json_decode(json_encode($this->fileData), true);
599602
ksort($return);
600603
return $return;
601604
}
602605

606+
private function syncSingleFileCollection(): void {
607+
if ($this->fileData->nodeType === 'envelope') {
608+
return;
609+
}
610+
611+
$this->fileData->filesCount = 1;
612+
$this->fileData->files = [
613+
(object)[
614+
'id' => $this->fileData->id,
615+
'uuid' => $this->fileData->uuid,
616+
'name' => $this->fileData->name,
617+
'status' => $this->fileData->status,
618+
'statusText' => $this->fileData->statusText,
619+
'nodeId' => $this->fileData->nodeId,
620+
'metadata' => $this->fileData->metadata,
621+
'totalPages' => $this->fileData->totalPages ?? 0,
622+
'pdfVersion' => $this->fileData->pdfVersion ?? '',
623+
'size' => $this->fileData->size ?? 0,
624+
'signers' => $this->mapSignerDetailsToSummary($this->fileData->signers ?? []),
625+
'file' => $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->fileData->uuid]),
626+
],
627+
];
628+
}
629+
630+
/**
631+
* @param list<LibresignSignerDetail> $signers
632+
* @return list<LibresignSignerSummary>
633+
*/
634+
private function mapSignerDetailsToSummary(array $signers): array {
635+
$summaries = [];
636+
637+
foreach ($signers as $signer) {
638+
$signerData = is_object($signer) ? (array)$signer : $signer;
639+
if (!is_array($signerData)) {
640+
continue;
641+
}
642+
643+
$signRequestId = $this->extractValidSignRequestId($signerData);
644+
if ($signRequestId === null) {
645+
continue;
646+
}
647+
648+
$summary = [
649+
'signRequestId' => $signRequestId,
650+
'displayName' => isset($signerData['displayName']) ? (string)$signerData['displayName'] : '',
651+
'email' => isset($signerData['email']) ? (string)$signerData['email'] : '',
652+
'signed' => $signerData['signed'] ?? null,
653+
'status' => isset($signerData['status']) ? (int)$signerData['status'] : 0,
654+
'statusText' => isset($signerData['statusText']) ? (string)$signerData['statusText'] : '',
655+
];
656+
657+
if (isset($signerData['identifyMethods']) && is_array($signerData['identifyMethods'])) {
658+
$summary['identifyMethods'] = $signerData['identifyMethods'];
659+
}
660+
661+
$summaries[] = $summary;
662+
}
663+
664+
return $summaries;
665+
}
666+
667+
private function extractValidSignRequestId(array $signerData): ?int {
668+
$signRequestId = $signerData['signRequestId'] ?? null;
669+
670+
if (is_int($signRequestId)) {
671+
return $signRequestId;
672+
}
673+
674+
if (is_string($signRequestId) && ctype_digit($signRequestId)) {
675+
return (int)$signRequestId;
676+
}
677+
678+
return null;
679+
}
680+
603681
private function computeEnvelopeSignersProgress(): void {
604682
if (!$this->file || $this->file->getParentFileId() || empty($this->fileData->signers)) {
605683
return;

0 commit comments

Comments
 (0)