From 1cb071857e2d60f2dedd69c29f0bdca71c18223f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:52:30 -0300 Subject: [PATCH 001/265] feat: add NodeType enum for file and envelope types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Enum/NodeType.php | 19 +++++++++++++++++++ tests/php/Unit/Enum/NodeTypeTest.php | 22 ++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 lib/Enum/NodeType.php create mode 100644 tests/php/Unit/Enum/NodeTypeTest.php diff --git a/lib/Enum/NodeType.php b/lib/Enum/NodeType.php new file mode 100644 index 0000000000..cd35429b92 --- /dev/null +++ b/lib/Enum/NodeType.php @@ -0,0 +1,19 @@ +assertFalse(NodeType::FILE->isEnvelope()); + } + + public function testIsEnvelopeReturnsTrueForEnvelopeType(): void { + $this->assertTrue(NodeType::ENVELOPE->isEnvelope()); + } +} From 2de158e7cceff5728e26e8e01dfaadd57687e3fe Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:52:51 -0300 Subject: [PATCH 002/265] feat: add migration for node_type and parent_file_id columns Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Version16000Date20251218000000.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 lib/Migration/Version16000Date20251218000000.php diff --git a/lib/Migration/Version16000Date20251218000000.php b/lib/Migration/Version16000Date20251218000000.php new file mode 100644 index 0000000000..e564948ce1 --- /dev/null +++ b/lib/Migration/Version16000Date20251218000000.php @@ -0,0 +1,66 @@ +getTable('libresign_file'); + + if (!$table->hasColumn('node_type')) { + $table->addColumn('node_type', Types::STRING, [ + 'notnull' => true, + 'length' => 10, + 'default' => NodeType::FILE->value, + ]); + } + + if (!$table->hasColumn('parent_file_id')) { + $table->addColumn('parent_file_id', Types::INTEGER, [ + 'notnull' => false, + 'default' => null, + ]); + } + + if (!$table->hasIndex('libresign_file_parent_idx')) { + $table->addIndex(['parent_file_id'], 'libresign_file_parent_idx'); + } + + if (!$table->hasIndex('libresign_file_node_type_idx')) { + $table->addIndex(['node_type'], 'libresign_file_node_type_idx'); + } + + if (!$table->hasIndex('libresign_file_parent_type_idx')) { + $table->addIndex(['parent_file_id', 'node_type'], 'libresign_file_parent_type_idx'); + } + + return $schema; + } +} From 3d7afa4faa7e103d083f05b3cf6b18ad0743ef23 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:00 -0300 Subject: [PATCH 003/265] feat: add node_type and parent_file_id fields to File entity Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/File.php | 25 +++++++++++++++++ lib/Db/FileMapper.php | 49 ++++++++++++++++++++++++++++++++++ tests/php/Unit/Db/FileTest.php | 19 +++++++++++++ 3 files changed, 93 insertions(+) diff --git a/lib/Db/File.php b/lib/Db/File.php index 04b93a1c26..14c68714c0 100644 --- a/lib/Db/File.php +++ b/lib/Db/File.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Db; +use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Enum\SignatureFlow; use OCP\AppFramework\Db\Entity; use OCP\DB\Types; @@ -43,6 +44,10 @@ * @method int getSignatureFlow() * @method void setDocmdpLevel(int $docmdpLevel) * @method int getDocmdpLevel() + * @method void setNodeType(string $nodeType) + * @method string getNodeType() + * @method void setParentFileId(?int $parentFileId) + * @method ?int getParentFileId() */ class File extends Entity { protected int $nodeId = 0; @@ -59,6 +64,8 @@ class File extends Entity { protected int $modificationStatus = 0; protected int $signatureFlow = SignatureFlow::NUMERIC_NONE; protected int $docmdpLevel = 0; + protected string $nodeType = 'file'; + protected ?int $parentFileId = null; public const STATUS_NOT_LIBRESIGN_FILE = -1; public const STATUS_DRAFT = 0; public const STATUS_ABLE_TO_SIGN = 1; @@ -87,6 +94,8 @@ public function __construct() { $this->addType('modificationStatus', Types::SMALLINT); $this->addType('signatureFlow', Types::SMALLINT); $this->addType('docmdpLevel', Types::SMALLINT); + $this->addType('nodeType', Types::STRING); + $this->addType('parentFileId', Types::INTEGER); } public function isDeletedAccount(): bool { @@ -114,4 +123,20 @@ public function getDocmdpLevelEnum(): \OCA\Libresign\Enum\DocMdpLevel { public function setDocmdpLevelEnum(\OCA\Libresign\Enum\DocMdpLevel $level): void { $this->setDocmdpLevel($level->value); } + + public function getNodeTypeEnum(): NodeType { + return NodeType::from($this->nodeType); + } + + public function setNodeTypeEnum(NodeType $nodeType): void { + $this->setNodeType($nodeType->value); + } + + public function isEnvelope(): bool { + return $this->getNodeTypeEnum()->isEnvelope(); + } + + public function hasParent(): bool { + return $this->parentFileId !== null; + } } diff --git a/lib/Db/FileMapper.php b/lib/Db/FileMapper.php index 1a54438d50..2f1c8014a3 100644 --- a/lib/Db/FileMapper.php +++ b/lib/Db/FileMapper.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Db; use OCA\Libresign\Enum\FileStatus; +use OCA\Libresign\Enum\NodeType; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\QBMapper; use OCP\Comments\ICommentsManager; @@ -274,4 +275,52 @@ public function neutralizeDeletedUser(string $userId, string $displayName): void $update->executeStatement(); } } + + /** + * @return File[] + */ + public function getChildrenFiles(int $parentId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_file_id', $qb->createNamedParameter($parentId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('node_type', $qb->createNamedParameter(NodeType::FILE->value)) + ) + ->orderBy('id', 'ASC'); + + return $this->findEntities($qb); + } + + public function getParentEnvelope(int $fileId): ?File { + $file = $this->getById($fileId); + + if (!$file->hasParent()) { + return null; + } + + return $this->getById($file->getParentFileId()); + } + + public function countChildrenFiles(int $envelopeId): int { + $qb = $this->db->getQueryBuilder(); + + $qb->select($qb->func()->count('*', 'count')) + ->from($this->getTableName()) + ->where( + $qb->expr()->eq('parent_file_id', $qb->createNamedParameter($envelopeId, IQueryBuilder::PARAM_INT)) + ) + ->andWhere( + $qb->expr()->eq('node_type', $qb->createNamedParameter(NodeType::FILE->value)) + ); + + $cursor = $qb->executeQuery(); + $row = $cursor->fetch(); + $cursor->closeCursor(); + + return $row ? (int)$row['count'] : 0; + } } diff --git a/tests/php/Unit/Db/FileTest.php b/tests/php/Unit/Db/FileTest.php index 05dd0f9751..1a85eb0272 100644 --- a/tests/php/Unit/Db/FileTest.php +++ b/tests/php/Unit/Db/FileTest.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Tests\Unit\Db; use OCA\Libresign\Db\File; +use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Tests\Unit\TestCase; @@ -35,4 +36,22 @@ public function testSetSignatureFlowEnumConvertsToInt(): void { $this->file->setSignatureFlowEnum(SignatureFlow::ORDERED_NUMERIC); $this->assertEquals(2, $this->file->getSignatureFlow()); } + + public function testIsEnvelopeReturnsFalseByDefault(): void { + $this->assertFalse($this->file->isEnvelope()); + } + + public function testIsEnvelopeReturnsTrueWhenNodeTypeIsEnvelope(): void { + $this->file->setNodeTypeEnum(NodeType::ENVELOPE); + $this->assertTrue($this->file->isEnvelope()); + } + + public function testHasParentReturnsFalseByDefault(): void { + $this->assertFalse($this->file->hasParent()); + } + + public function testHasParentReturnsTrueWhenParentFileIdIsSet(): void { + $this->file->setParentFileId(123); + $this->assertTrue($this->file->hasParent()); + } } From 8a81e5a4aa0975e110f4f6cd1f5f684538c5eeeb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:10 -0300 Subject: [PATCH 004/265] feat: add EnvelopeService to create physical folders for envelopes Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/EnvelopeService.php | 92 ++++++++++ .../php/Unit/Service/EnvelopeServiceTest.php | 167 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 lib/Service/EnvelopeService.php create mode 100644 tests/php/Unit/Service/EnvelopeServiceTest.php diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php new file mode 100644 index 0000000000..780cd2f04d --- /dev/null +++ b/lib/Service/EnvelopeService.php @@ -0,0 +1,92 @@ +folderService->setUserId($userId); + } + $parentFolder = $this->folderService->getFolder(); + + $folderName = $name . '_' . substr(UUIDUtil::getUUID(), 0, 8); + $envelopeFolder = $parentFolder->newFolder($folderName); + + $envelope = new FileEntity(); + $envelope->setNodeId($envelopeFolder->getId()); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setName($name); + $envelope->setUuid(UUIDUtil::getUUID()); + $envelope->setCreatedAt(new DateTime()); + $envelope->setStatus(FileEntity::STATUS_DRAFT); + + if ($userId) { + $envelope->setUserId($userId); + } + + return $this->fileMapper->insert($envelope); + } + + public function addFileToEnvelope(int $envelopeId, FileEntity $file): FileEntity { + $envelope = $this->fileMapper->getById($envelopeId); + + if (!$envelope->isEnvelope()) { + throw new LibresignException($this->l10n->t('The specified ID is not an envelope')); + } + + if ($envelope->getStatus() > FileEntity::STATUS_DRAFT) { + throw new LibresignException($this->l10n->t('Cannot add files to an envelope that is already in signing process')); + } + + $maxFiles = $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); + $currentCount = $this->fileMapper->countChildrenFiles($envelopeId); + if ($currentCount >= $maxFiles) { + throw new LibresignException( + $this->l10n->t('Maximum number of files per envelope (%s) exceeded', [$maxFiles]) + ); + } + + $file->setParentFileId($envelopeId); + $file->setNodeTypeEnum(NodeType::FILE); + + return $this->fileMapper->update($file); + } + + public function getEnvelopeByFileId(int $fileId): ?FileEntity { + try { + return $this->fileMapper->getParentEnvelope($fileId); + } catch (DoesNotExistException) { + return null; + } + } +} diff --git a/tests/php/Unit/Service/EnvelopeServiceTest.php b/tests/php/Unit/Service/EnvelopeServiceTest.php new file mode 100644 index 0000000000..943580ef63 --- /dev/null +++ b/tests/php/Unit/Service/EnvelopeServiceTest.php @@ -0,0 +1,167 @@ +fileMapper = $this->createMock(FileMapper::class); + $this->l10n = \OCP\Server::get(\OCP\L10N\IFactory::class)->get(Application::APP_ID); + $this->appConfig = $this->getMockAppConfigWithReset(); + $this->folderService = $this->createMock(FolderService::class); + + $this->service = new EnvelopeService( + $this->fileMapper, + $this->l10n, + $this->appConfig, + $this->folderService, + ); + } + + public function testEnvelopeIsCreatedAsDraft(): void { + $this->fileMapper->method('insert')->willReturnArgument(0); + + $mockFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder->method('getId')->willReturn(999); + $mockFolder->method('newFolder')->willReturn($mockEnvelopeFolder); + $this->folderService->method('getFolder')->willReturn($mockFolder); + + $envelope = $this->service->createEnvelope('Contract Package'); + + $this->assertSame(FileEntity::STATUS_DRAFT, $envelope->getStatus()); + } + + public function testEnvelopeIsCreatedWithEnvelopeType(): void { + $this->fileMapper->method('insert')->willReturnArgument(0); + + $mockFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder = $this->createMock(Folder::class); + $mockEnvelopeFolder->method('getId')->willReturn(999); + $mockFolder->method('newFolder')->willReturn($mockEnvelopeFolder); + $this->folderService->method('getFolder')->willReturn($mockFolder); + + $envelope = $this->service->createEnvelope('Contract Package'); + + $this->assertTrue($envelope->isEnvelope()); + } + + public function testCannotAddFileToRegularFile(): void { + $this->expectException(LibresignException::class); + + $regularFile = new FileEntity(); + $regularFile->setNodeTypeEnum(NodeType::FILE); + + $this->fileMapper->method('getById')->willReturn($regularFile); + + $this->service->addFileToEnvelope(1, new FileEntity()); + } + + public function testCannotAddFileToEnvelopeAfterSigningStarts(): void { + $this->expectException(LibresignException::class); + + $envelope = new FileEntity(); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setStatus(FileEntity::STATUS_ABLE_TO_SIGN); + + $this->fileMapper->method('getById')->willReturn($envelope); + + $this->service->addFileToEnvelope(1, new FileEntity()); + } + + public function testCannotExceedMaximumFilesPerEnvelope(): void { + $this->expectException(LibresignException::class); + + $envelope = new FileEntity(); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setStatus(FileEntity::STATUS_DRAFT); + + $this->fileMapper->method('getById')->willReturn($envelope); + $this->fileMapper->method('countChildrenFiles')->willReturn(50); + + $this->service->addFileToEnvelope(1, new FileEntity()); + } + + public function testFileIsLinkedToEnvelopeWhenAdded(): void { + $envelopeId = 100; + $envelope = new FileEntity(); + $envelope->setId($envelopeId); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setStatus(FileEntity::STATUS_DRAFT); + + $file = new FileEntity(); + + $this->fileMapper->method('getById')->willReturn($envelope); + $this->fileMapper->method('countChildrenFiles')->willReturn(0); + $this->fileMapper->method('update')->willReturnArgument(0); + + $result = $this->service->addFileToEnvelope($envelopeId, $file); + + $this->assertSame($envelopeId, $result->getParentFileId()); + } + + public function testFileBecomesRegularFileTypeWhenAddedToEnvelope(): void { + $envelope = new FileEntity(); + $envelope->setId(1); + $envelope->setNodeTypeEnum(NodeType::ENVELOPE); + $envelope->setStatus(FileEntity::STATUS_DRAFT); + + $file = new FileEntity(); + + $this->fileMapper->method('getById')->willReturn($envelope); + $this->fileMapper->method('countChildrenFiles')->willReturn(0); + $this->fileMapper->method('update')->willReturnArgument(0); + + $result = $this->service->addFileToEnvelope(1, $file); + + $this->assertSame(NodeType::FILE, $result->getNodeTypeEnum()); + } + + public function testReturnsNullWhenFileHasNoEnvelope(): void { + $this->fileMapper->method('getParentEnvelope') + ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('')); + + $result = $this->service->getEnvelopeByFileId(999); + + $this->assertNull($result); + } + + public function testReturnsEnvelopeWhenFileHasParent(): void { + $expectedEnvelope = new FileEntity(); + $expectedEnvelope->setId(5); + $expectedEnvelope->setNodeTypeEnum(NodeType::ENVELOPE); + + $this->fileMapper->method('getParentEnvelope')->willReturn($expectedEnvelope); + + $result = $this->service->getEnvelopeByFileId(10); + + $this->assertNotNull($result); + $this->assertSame(5, $result->getId()); + } +} + From fe00a82afdcb9f6c101ca754bb10d9d18716bbca Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:19 -0300 Subject: [PATCH 005/265] feat: add saveEnvelope method to RequestSignatureService Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 41 +++++++++++++++++++ .../Service/RequestSignatureServiceTest.php | 8 ++++ 2 files changed, 49 insertions(+) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index aee7d3bb10..1cd9eed438 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -57,6 +57,7 @@ public function __construct( protected FileStatusService $fileStatusService, protected SignRequestStatusService $signRequestStatusService, protected DocMdpConfigService $docMdpConfigService, + protected EnvelopeService $envelopeService, ) { } @@ -71,6 +72,46 @@ public function save(array $data): FileEntity { return $file; } + public function saveEnvelope(array $data): array { + $envelopeName = $data['name'] ?: $this->l10n->t('Envelope %s', [date('Y-m-d H:i:s')]); + $userManager = $data['userManager'] ?? null; + $userId = $userManager instanceof IUser ? $userManager->getUID() : null; + + $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId); + + $files = []; + foreach ($data['files'] as $fileData) { + $fileEntity = $this->createFileForEnvelope( + $fileData, + $userManager, + $data['settings'] ?? [] + ); + $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); + $files[] = $fileEntity; + } + + return [ + 'envelope' => $envelope, + 'files' => $files, + ]; + } + + private function createFileForEnvelope(array $fileData, ?IUser $userManager, array $settings): FileEntity { + if (!isset($fileData['node'])) { + throw new \InvalidArgumentException('Node not provided in file data'); + } + + $node = $fileData['node']; + $fileName = $fileData['name'] ?? $node->getName(); + + return $this->saveFile([ + 'file' => ['fileNode' => $node], + 'name' => $fileName, + 'userManager' => $userManager, + 'status' => FileEntity::STATUS_DRAFT, + ]); + } + /** * Save file data * diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index e634f03286..2ddc6235fd 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -14,7 +14,9 @@ use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\DocMdpConfigService; +use OCA\Libresign\Service\EnvelopeService; use OCA\Libresign\Service\FileElementService; +use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\IdentifyMethodService; @@ -58,6 +60,8 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private FileStatusService&MockObject $fileStatusService; private SignRequestStatusService&MockObject $signRequestStatusService; private DocMdpConfigService&MockObject $docMdpConfigService; + private EnvelopeService&MockObject $envelopeService; + private FileService&MockObject $fileService; public function setUp(): void { parent::setUp(); @@ -88,6 +92,8 @@ public function setUp(): void { $this->fileStatusService = $this->createMock(FileStatusService::class); $this->signRequestStatusService = $this->createMock(SignRequestStatusService::class); $this->docMdpConfigService = $this->createMock(DocMdpConfigService::class); + $this->envelopeService = $this->createMock(EnvelopeService::class); + $this->fileService = $this->createMock(FileService::class); } private function getService(): RequestSignatureService { @@ -113,6 +119,8 @@ private function getService(): RequestSignatureService { $this->fileStatusService, $this->signRequestStatusService, $this->docMdpConfigService, + $this->envelopeService, + $this->fileService, ); } From 776dadc053e7783edf8147a6ef7a24c1308ebad3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:28 -0300 Subject: [PATCH 006/265] feat: add envelope support to FileController save endpoint Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 189 ++++++++++++++++++++++++------ 1 file changed, 150 insertions(+), 39 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index dc2cf23192..6e56ca954f 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -400,7 +400,8 @@ private function fetchPreview( * @param LibresignNewFile $file File to save * @param string $name The name of file to sign * @param LibresignFolderSettings $settings Settings to define the pattern to store the file. See more informations at FolderService::getFolderName method. - * @return DataResponse|DataResponse + * @param list $files Multiple files to create an envelope (optional, use either file or files) + * @return DataResponse, files?: array>}, array{}>|DataResponse * * 200: OK * 422: Failed to save data @@ -409,58 +410,168 @@ private function fetchPreview( #[NoCSRFRequired] #[RequireManager] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file', requirements: ['apiVersion' => '(v1)'])] - public function save(array $file, string $name = '', array $settings = []): DataResponse { + public function save( + array $file = [], + string $name = '', + array $settings = [], + array $files = [], + ): DataResponse { try { - if (empty($name)) { - if (!empty($file['url'])) { - $name = rawurldecode(pathinfo($file['url'], PATHINFO_FILENAME)); - } + if ((empty($file) && empty($files)) || (!empty($files) && count($files) === 0)) { + throw new LibresignException($this->l10n->t('File or files parameter is required')); } - if (empty($name)) { - // The name of file to sign is mandatory. This phrase is used when we do a request to API sending a file to sign. - throw new \Exception($this->l10n->t('Name is mandatory')); + + if (!empty($files)) { + return $this->saveMultipleFiles($files, $name, $settings); + } + + return $this->saveSingleFile($file, $name, $settings); + } catch (LibresignException $e) { + return new DataResponse( + [ + 'message' => $e->getMessage(), + ], + Http::STATUS_UNPROCESSABLE_ENTITY, + ); + } + } + + /** + * @return DataResponse + */ + private function saveSingleFile(array $file, string $name, array $settings): DataResponse { + if (empty($name)) { + if (!empty($file['url'])) { + $name = rawurldecode(pathinfo($file['url'], PATHINFO_FILENAME)); } + } + if (empty($name)) { + throw new LibresignException($this->l10n->t('Name is mandatory')); + } + + $this->validateHelper->validateNewFile([ + 'file' => $file, + 'userManager' => $this->userSession->getUser(), + ]); + $this->validateHelper->canRequestSign($this->userSession->getUser()); + + $node = $this->fileService->getNodeFromData([ + 'userManager' => $this->userSession->getUser(), + 'name' => $name, + 'file' => $file, + 'settings' => $settings + ]); + + $data = [ + 'file' => [ + 'fileNode' => $node, + ], + 'name' => $name, + 'userManager' => $this->userSession->getUser(), + 'status' => FileEntity::STATUS_DRAFT, + ]; + $savedFile = $this->requestSignatureService->save($data); + + return new DataResponse( + [ + 'message' => $this->l10n->t('Success'), + 'id' => $savedFile->getNodeId(), + 'uuid' => $savedFile->getUuid(), + 'name' => $savedFile->getName(), + 'status' => $savedFile->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($savedFile->getStatus()), + 'nodeType' => $savedFile->getNodeType(), + 'created_at' => $savedFile->getCreatedAt()->format(\DateTimeInterface::ATOM), + 'files' => [$this->formatFilesResponse([$savedFile])[0]], + ], + Http::STATUS_OK + ); + } + + /** + * @return DataResponse, files: array>}, array{}> + */ + private function saveMultipleFiles(array $files, string $name, array $settings): DataResponse { + if (!$this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)) { + throw new LibresignException($this->l10n->t('Envelope feature is disabled')); + } + + $this->validateFilesArray($files); + $this->validateHelper->canRequestSign($this->userSession->getUser()); + + $preparedFiles = []; + foreach ($files as $fileData) { $this->validateHelper->validateNewFile([ - 'file' => $file, + 'file' => $fileData, 'userManager' => $this->userSession->getUser(), ]); - $this->validateHelper->canRequestSign($this->userSession->getUser()); + $fileName = $this->extractFileName($fileData); $node = $this->fileService->getNodeFromData([ 'userManager' => $this->userSession->getUser(), - 'name' => $name, - 'file' => $file, + 'name' => $fileName, + 'file' => $fileData, 'settings' => $settings ]); - $data = [ - 'file' => [ - 'fileNode' => $node, - ], - 'name' => $name, - 'userManager' => $this->userSession->getUser(), - 'status' => FileEntity::STATUS_DRAFT, + + $preparedFiles[] = [ + 'node' => $node, + 'name' => $fileName, ]; - $file = $this->requestSignatureService->save($data); + } - return new DataResponse( - [ - 'message' => $this->l10n->t('Success'), - 'name' => $name, - 'id' => $node->getId(), - 'status' => $file->getStatus(), - 'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()), - 'created_at' => $file->getCreatedAt()->format(\DateTimeInterface::ATOM), - ], - Http::STATUS_OK - ); - } catch (\Exception $e) { - return new DataResponse( - [ - 'message' => $e->getMessage(), - ], - Http::STATUS_UNPROCESSABLE_ENTITY, - ); + $result = $this->requestSignatureService->saveEnvelope([ + 'files' => $preparedFiles, + 'name' => $name, + 'userManager' => $this->userSession->getUser(), + 'settings' => $settings, + ]); + + $envelope = $result['envelope']; + return new DataResponse( + [ + 'message' => $this->l10n->t('Success'), + 'id' => $envelope->getNodeId(), + 'uuid' => $envelope->getUuid(), + 'name' => $envelope->getName(), + 'status' => $envelope->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($envelope->getStatus()), + 'nodeType' => $envelope->getNodeType(), + 'created_at' => $envelope->getCreatedAt()->format(\DateTimeInterface::ATOM), + 'files' => $this->formatFilesResponse($result['files']), + ], + Http::STATUS_OK + ); + } + + private function extractFileName(array $fileData): string { + if (!empty($fileData['name'])) { + return $fileData['name']; } + if (!empty($fileData['url'])) { + return rawurldecode(pathinfo($fileData['url'], PATHINFO_FILENAME)); + } + return ''; + } + + private function validateFilesArray(array $files): void { + if (empty($files)) { + throw new LibresignException($this->l10n->t('At least one file is required')); + } + + $maxFiles = $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); + if (count($files) > $maxFiles) { + throw new LibresignException($this->l10n->t('Maximum of %d files per envelope', [$maxFiles])); + } + } + + private function formatFilesResponse(array $files): array { + return array_map(fn (FileEntity $file) => [ + 'id' => $file->getNodeId(), + 'uuid' => $file->getUuid(), + 'name' => $file->getName(), + 'status' => $file->getStatus(), + ], $files); } /** From 7a4dc80bd721e2c1370926b5310342d9af84c5bd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:40 -0300 Subject: [PATCH 007/265] feat: add envelope support and files array to list response Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/SignRequestMapper.php | 67 ++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/lib/Db/SignRequestMapper.php b/lib/Db/SignRequestMapper.php index adad246ce7..f86c8ae749 100644 --- a/lib/Db/SignRequestMapper.php +++ b/lib/Db/SignRequestMapper.php @@ -482,6 +482,15 @@ public function getMyLibresignFile(string $userId, ?array $filter = []): File { if (!$row) { throw new DoesNotExistException('LibreSign file not found'); } + + unset( + $row['parent_id'], + $row['parent_uuid'], + $row['parent_name'], + $row['parent_status'], + $row['parent_created_at'], + ); + $file = new File(); return $file->fromRow($row); } @@ -491,7 +500,8 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array $qb->from('libresign_file', 'f') ->leftJoin('f', 'libresign_sign_request', 'sr', 'sr.file_id = f.id') ->leftJoin('f', 'libresign_identify_method', 'im', $qb->expr()->eq('sr.id', 'im.sign_request_id')) - ->leftJoin('f', 'libresign_id_docs', 'id', 'id.file_id = f.id'); + ->leftJoin('f', 'libresign_id_docs', 'id', 'id.file_id = f.id') + ->leftJoin('f', 'libresign_file', 'parent', $qb->expr()->eq('f.parent_file_id', 'parent.id')); if ($count) { $qb->select($qb->func()->count()) ->setFirstResult(0) @@ -509,6 +519,13 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array 'f.created_at', 'f.signature_flow', 'f.docmdp_level', + 'f.node_type', + 'f.parent_file_id', + 'parent.id as parent_id', + 'parent.uuid as parent_uuid', + 'parent.name as parent_name', + 'parent.status as parent_status', + 'parent.created_at as parent_created_at' ) ->groupBy( 'f.id', @@ -521,6 +538,13 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array 'f.created_at', 'f.signature_flow', 'f.docmdp_level', + 'f.node_type', + 'f.parent_file_id', + 'parent.id', + 'parent.uuid', + 'parent.name', + 'parent.status', + 'parent.created_at' ); // metadata is a json column, the right way is to use f.metadata::text // when the database is PostgreSQL. The problem is that the command @@ -544,7 +568,9 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array $qb->expr()->neq('sr.status', $qb->createNamedParameter(SignRequestStatus::DRAFT->value)), ) ]; - $qb->where($qb->expr()->orX(...$or))->andWhere($qb->expr()->isNull('id.id')); + $qb->where($qb->expr()->orX(...$or)) + ->andWhere($qb->expr()->isNull('id.id')) + ->andWhere($qb->expr()->isNull('f.parent_file_id')); if ($filter) { if (isset($filter['email']) && filter_var($filter['email'], FILTER_VALIDATE_EMAIL)) { $or[] = $qb->expr()->andX( @@ -617,7 +643,8 @@ private function getFilesAssociatedFilesWithMeStmt( } private function formatListRow(array $row): array { - $row['id'] = (int)$row['id']; + $internalId = (int)$row['id']; + $row['id'] = (int)$row['node_id']; $row['status'] = (int)$row['status']; $row['statusText'] = $this->fileMapper->getTextOfStatus($row['status']); $row['nodeId'] = (int)$row['node_id']; @@ -633,6 +660,33 @@ private function formatListRow(array $row): array { $row['name'] = $this->removeExtensionFromName($row['name'], $row['metadata']); $row['signatureFlow'] = SignatureFlow::fromNumeric((int)($row['signature_flow']))->value; $row['docmdpLevel'] = (int)($row['docmdp_level'] ?? 0); + $row['nodeType'] = $row['node_type'] ?? 'file'; + $row['isEnvelope'] = $row['node_type'] === 'envelope'; + + if ($row['node_type'] === 'envelope') { + $childrenFiles = $this->fileMapper->getChildrenFiles($internalId); + $filesData = array_map(fn ($file) => [ + 'id' => $file->getNodeId(), + 'uuid' => $file->getUuid(), + 'name' => $file->getName(), + 'status' => $file->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()), + ], $childrenFiles); + + $row['envelope'] = [ + 'filesCount' => count($childrenFiles), + 'files' => $filesData, + ]; + $row['files'] = $filesData; + } else { + $row['files'] = [[ + 'id' => (int)$row['node_id'], + 'uuid' => $row['uuid'], + 'name' => $row['name'], + 'status' => (int)$row['status'], + 'statusText' => $row['statusText'], + ]]; + } unset( $row['user_id'], @@ -640,6 +694,13 @@ private function formatListRow(array $row): array { $row['signed_node_id'], $row['signature_flow'], $row['docmdp_level'], + $row['node_type'], + $row['parent_file_id'], + $row['parent_id'], + $row['parent_uuid'], + $row['parent_name'], + $row['parent_status'], + $row['parent_created_at'], ); return $row; } From f6c5150b9372626bcba9174ff30f1dc0602e6038 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:50 -0300 Subject: [PATCH 008/265] test: add integration tests for envelope feature Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../features/file/envelope.feature | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 tests/integration/features/file/envelope.feature diff --git a/tests/integration/features/file/envelope.feature b/tests/integration/features/file/envelope.feature new file mode 100644 index 0000000000..fae9aeff0d --- /dev/null +++ b/tests/integration/features/file/envelope.feature @@ -0,0 +1,105 @@ +Feature: envelope + Scenario: Cannot save envelope when feature is disabled + Given as user "admin" + And the following "libresign" app config is set + | envelope_enabled | false | + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | files | [{"url":"/apps/libresign/develop/pdf"},{"url":"/apps/libresign/develop/pdf"}] | + | name | Contract Package | + Then the response should have a status code 422 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Envelope feature is disabled | + + Scenario: Cannot save empty file and empty files array + Given as user "admin" + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | name | Test | + Then the response should have a status code 422 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | File or files parameter is required | + + Scenario: Cannot save envelope with empty files array + Given as user "admin" + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | files | [] | + | name | Empty Package | + Then the response should have a status code 422 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | File or files parameter is required | + + Scenario: Cannot exceed maximum files per envelope + Given as user "admin" + And the following "libresign" app config is set + | envelope_max_files | 2 | + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | files | [{"url":"/apps/libresign/develop/pdf"},{"url":"/apps/libresign/develop/pdf"},{"url":"/apps/libresign/develop/pdf"}] | + | name | Too Many Files | + Then the response should have a status code 422 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Maximum of 2 files per envelope | + + Scenario: Successfully save single file + Given as user "admin" + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | file | {"url":"/apps/libresign/develop/pdf"} | + | name | Single Document | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Success | + | (jq).ocs.data.name | Single Document | + | (jq).ocs.data.status | 0 | + | (jq).ocs.data.statusText | draft | + | (jq).ocs.data.nodeType | file | + | (jq).ocs.data.files[0].name | Single Document | + | (jq).ocs.data.files \| length | 1 | + + Scenario: Successfully save envelope with multiple files + Given as user "admin" + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | files | [{"url":"/apps/libresign/develop/pdf","name":"Contract.pdf"},{"url":"/apps/libresign/develop/pdf","name":"Annex.pdf"}] | + | name | Contract Package | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Success | + | (jq).ocs.data.name | Contract Package | + | (jq).ocs.data.status | 0 | + | (jq).ocs.data.statusText | draft | + | (jq).ocs.data.nodeType | envelope | + | (jq).ocs.data.files[0].name | Contract | + | (jq).ocs.data.files[1].name | Annex | + | (jq).ocs.data.files \| length | 2 | + + Scenario: Envelope files are linked to envelope + Given as user "admin" + And sending "post" to ocs "/apps/libresign/api/v1/admin/certificate/openssl" + | rootCert | {"commonName":"test"} | + When sending "post" to ocs "/apps/libresign/api/v1/file" + | files | [{"url":"/apps/libresign/develop/pdf","name":"Doc1.pdf"},{"url":"/apps/libresign/develop/pdf","name":"Doc2.pdf"}] | + | name | Package | + Then the response should have a status code 200 + And the response should be a JSON array with the following mandatory values + | key | value | + | (jq).ocs.data.message | Success | + | (jq).ocs.data.name | Package | + | (jq).ocs.data.nodeType | envelope | + | (jq).ocs.data.files[0].name | Doc1 | + | (jq).ocs.data.files[1].name | Doc2 | + | (jq).ocs.data.files \| length | 2 | From 31e437b4099b47b58985d6d1fb33962125b66bbd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:53:59 -0300 Subject: [PATCH 009/265] feat: add envelope configuration options and update services Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 31 ++++ lib/Service/FileStatusService.php | 62 ++++++++ tests/php/Unit/Service/FileServiceTest.php | 6 +- .../Unit/Service/FileStatusServiceTest.php | 146 +++++++++++++++++- 4 files changed, 240 insertions(+), 5 deletions(-) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 4ce3899d32..de2e5ba560 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -92,6 +92,7 @@ public function __construct( private IRootFolder $root, protected LoggerInterface $logger, protected IL10N $l10n, + private EnvelopeService $envelopeService, ) { $this->docMdpHandler = $docMdpHandler; $this->fileData = new stdClass(); @@ -720,6 +721,9 @@ private function loadLibreSignData(): void { 'displayName' => $this->userManager->get($this->file->getUserId())->getDisplayName(), ]; $this->fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->file->getUuid()]); + + $this->loadEnvelopeData(); + if ($this->showVisibleElements) { $signers = $this->signRequestMapper->getByMultipleFileId([$this->file->getId()]); $this->fileData->visibleElements = []; @@ -735,6 +739,33 @@ private function loadLibreSignData(): void { } } + private function loadEnvelopeData(): void { + if (!$this->file->hasParent()) { + return; + } + + $envelope = $this->envelopeService->getEnvelopeByFileId($this->file->getId()); + if (!$envelope) { + return; + } + + $envelopeFiles = $this->fileMapper->getChildrenFiles($envelope->getId()); + $this->fileData->envelope = [ + 'id' => $envelope->getId(), + 'uuid' => $envelope->getUuid(), + 'name' => $envelope->getName(), + 'status' => $envelope->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($envelope->getStatus()), + 'filesCount' => count($envelopeFiles), + 'files' => array_map(fn (File $file) => [ + 'id' => $file->getId(), + 'uuid' => $file->getUuid(), + 'name' => $file->getName(), + 'status' => $file->getStatus(), + ], $envelopeFiles), + ]; + } + private function loadMessages(): void { if (!$this->showMessages) { return; diff --git a/lib/Service/FileStatusService.php b/lib/Service/FileStatusService.php index b211bfe56a..64df49e806 100644 --- a/lib/Service/FileStatusService.php +++ b/lib/Service/FileStatusService.php @@ -10,6 +10,7 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileMapper; +use OCP\AppFramework\Db\DoesNotExistException; class FileStatusService { public function __construct( @@ -22,6 +23,10 @@ public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus): Fil if ($newStatus > $currentStatus) { $file->setStatus($newStatus); $this->fileMapper->update($file); + + if ($file->hasParent()) { + $this->propagateStatusToParent($file->getParentFileId()); + } } return $file; } @@ -29,4 +34,61 @@ public function updateFileStatusIfUpgrade(FileEntity $file, int $newStatus): Fil public function canNotifySigners(?int $fileStatus): bool { return $fileStatus === FileEntity::STATUS_ABLE_TO_SIGN; } + + public function propagateStatusToParent(int $parentId): void { + try { + $parent = $this->fileMapper->getById($parentId); + } catch (DoesNotExistException) { + return; + } + + if (!$parent->isEnvelope()) { + return; + } + + $children = $this->fileMapper->getChildrenFiles($parentId); + + if (empty($children)) { + return; + } + + $minStatus = FileEntity::STATUS_SIGNED; + $maxStatus = FileEntity::STATUS_DRAFT; + + foreach ($children as $child) { + $status = $child->getStatus(); + if ($status < $minStatus) { + $minStatus = $status; + } + if ($status > $maxStatus) { + $maxStatus = $status; + } + } + + $newStatus = FileEntity::STATUS_DRAFT; + + if ($minStatus === FileEntity::STATUS_SIGNED && $maxStatus === FileEntity::STATUS_SIGNED) { + $newStatus = FileEntity::STATUS_SIGNED; + } elseif ($maxStatus >= FileEntity::STATUS_PARTIAL_SIGNED) { + $newStatus = FileEntity::STATUS_PARTIAL_SIGNED; + } elseif ($maxStatus >= FileEntity::STATUS_ABLE_TO_SIGN) { + $newStatus = FileEntity::STATUS_ABLE_TO_SIGN; + } + + if ($parent->getStatus() !== $newStatus) { + $parent->setStatus($newStatus); + $this->fileMapper->update($parent); + } + } + + public function updateFileStatus(FileEntity $file, int $newStatus): FileEntity { + $file->setStatus($newStatus); + $this->fileMapper->update($file); + + if ($file->hasParent()) { + $this->propagateStatusToParent($file->getParentFileId()); + } + + return $file; + } } diff --git a/tests/php/Unit/Service/FileServiceTest.php b/tests/php/Unit/Service/FileServiceTest.php index d47ac2f403..95119512d3 100644 --- a/tests/php/Unit/Service/FileServiceTest.php +++ b/tests/php/Unit/Service/FileServiceTest.php @@ -30,6 +30,7 @@ function is_uploaded_file($filename) { use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\AccountService; +use OCA\Libresign\Service\EnvelopeService; use OCA\Libresign\Service\FileElementService; use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\FolderService; @@ -75,9 +76,10 @@ final class FileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { protected IMimeTypeDetector $mimeTypeDetector; protected Pkcs12Handler $pkcs12Handler; protected DocMdpHandler $docMdpHandler; - private IRootFolder $root; + protected IRootFolder $root; protected LoggerInterface $logger; protected IL10N $l10n; + protected EnvelopeService $envelopeService; protected vfsDirectory $tempFolder; public function setUp(): void { @@ -111,6 +113,7 @@ private function getService(): FileService { $this->root = \OCP\Server::get(IRootFolder::class); $this->logger = \OCP\Server::get(LoggerInterface::class); $this->l10n = \OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID); + $this->envelopeService = \OCP\Server::get(EnvelopeService::class); return new FileService( $this->fileMapper, $this->signRequestMapper, @@ -135,6 +138,7 @@ private function getService(): FileService { $this->root, $this->logger, $this->l10n, + $this->envelopeService, ); } diff --git a/tests/php/Unit/Service/FileStatusServiceTest.php b/tests/php/Unit/Service/FileStatusServiceTest.php index c8090c092b..503363fa82 100644 --- a/tests/php/Unit/Service/FileStatusServiceTest.php +++ b/tests/php/Unit/Service/FileStatusServiceTest.php @@ -24,7 +24,7 @@ protected function setUp(): void { $this->service = new FileStatusService($this->fileMapper); } - #[DataProvider('fileStatusUpgradeScenarios')] + #[DataProvider('dataFileStatusUpgrade')] public function testUpdateFileStatusIfUpgrade(int $currentStatus, int $newStatus, bool $shouldUpdate): void { $file = new FileEntity(); $file->setStatus($currentStatus); @@ -44,7 +44,7 @@ public function testUpdateFileStatusIfUpgrade(int $currentStatus, int $newStatus $this->assertEquals($expectedStatus, $result->getStatus()); } - public static function fileStatusUpgradeScenarios(): array { + public static function dataFileStatusUpgrade(): array { $draft = FileEntity::STATUS_DRAFT; $able = FileEntity::STATUS_ABLE_TO_SIGN; $partial = FileEntity::STATUS_PARTIAL_SIGNED; @@ -72,13 +72,13 @@ public static function fileStatusUpgradeScenarios(): array { ]; } - #[DataProvider('fileStatusNotificationScenarios')] + #[DataProvider('dataCanNotifySigners')] public function testCanNotifySigners(?int $fileStatus, bool $expected): void { $result = $this->service->canNotifySigners($fileStatus); $this->assertEquals($expected, $result); } - public static function fileStatusNotificationScenarios(): array { + public static function dataCanNotifySigners(): array { return [ [FileEntity::STATUS_DRAFT, false], [FileEntity::STATUS_ABLE_TO_SIGN, true], @@ -88,4 +88,142 @@ public static function fileStatusNotificationScenarios(): array { [null, false], ]; } + + #[DataProvider('dataPropagateStatusToParent')] + public function testPropagateStatusToParent(array $childrenStatuses, int $expectedEnvelopeStatus, int $currentEnvelopeStatus): void { + $parentId = 1; + $envelope = new FileEntity(); + $envelope->setId($parentId); + $envelope->setNodeType('envelope'); + $envelope->setStatus($currentEnvelopeStatus); + + $children = []; + foreach ($childrenStatuses as $index => $status) { + $child = new FileEntity(); + $child->setId($index + 10); + $child->setStatus($status); + $children[] = $child; + } + + $this->fileMapper->expects($this->once()) + ->method('getById') + ->with($parentId) + ->willReturn($envelope); + + $this->fileMapper->expects($this->once()) + ->method('getChildrenFiles') + ->with($parentId) + ->willReturn($children); + + if ($currentEnvelopeStatus !== $expectedEnvelopeStatus) { + $this->fileMapper->expects($this->once()) + ->method('update') + ->with($this->callback(function (FileEntity $file) use ($expectedEnvelopeStatus) { + return $file->getStatus() === $expectedEnvelopeStatus; + })); + } else { + $this->fileMapper->expects($this->never())->method('update'); + } + + $this->service->propagateStatusToParent($parentId); + } + + public static function dataPropagateStatusToParent(): array { + $draft = FileEntity::STATUS_DRAFT; + $able = FileEntity::STATUS_ABLE_TO_SIGN; + $partial = FileEntity::STATUS_PARTIAL_SIGNED; + $signed = FileEntity::STATUS_SIGNED; + + return [ + 'all draft' => [[$draft, $draft, $draft], $draft, $draft], + 'all able to sign' => [[$able, $able, $able], $able, $draft], + 'all partial signed' => [[$partial, $partial, $partial], $partial, $draft], + 'all signed' => [[$signed, $signed, $signed], $signed, $draft], + 'mixed draft and able' => [[$draft, $able, $draft], $able, $draft], + 'mixed able and partial' => [[$able, $partial, $able], $partial, $draft], + 'mixed partial and signed' => [[$partial, $signed, $partial], $partial, $draft], + 'mixed draft, able and partial' => [[$draft, $able, $partial], $partial, $draft], + 'mixed all statuses' => [[$draft, $able, $partial, $signed], $partial, $draft], + 'one signed, rest draft' => [[$draft, $draft, $signed], $partial, $draft], + ]; + } + + public function testPropagateStatusToParentWhenParentNotFound(): void { + $parentId = 999; + + $this->fileMapper->expects($this->once()) + ->method('getById') + ->with($parentId) + ->willThrowException(new \OCP\AppFramework\Db\DoesNotExistException('Not found')); + + $this->fileMapper->expects($this->never())->method('getChildrenFiles'); + $this->fileMapper->expects($this->never())->method('update'); + + $this->service->propagateStatusToParent($parentId); + } + + public function testPropagateStatusToParentWhenParentIsNotEnvelope(): void { + $parentId = 1; + $file = new FileEntity(); + $file->setId($parentId); + $file->setNodeType('file'); + + $this->fileMapper->expects($this->once()) + ->method('getById') + ->with($parentId) + ->willReturn($file); + + $this->fileMapper->expects($this->never())->method('getChildrenFiles'); + $this->fileMapper->expects($this->never())->method('update'); + + $this->service->propagateStatusToParent($parentId); + } + + public function testPropagateStatusToParentWhenNoChildren(): void { + $parentId = 1; + $envelope = new FileEntity(); + $envelope->setId($parentId); + $envelope->setNodeType('envelope'); + + $this->fileMapper->expects($this->once()) + ->method('getById') + ->with($parentId) + ->willReturn($envelope); + + $this->fileMapper->expects($this->once()) + ->method('getChildrenFiles') + ->with($parentId) + ->willReturn([]); + + $this->fileMapper->expects($this->never())->method('update'); + + $this->service->propagateStatusToParent($parentId); + } + + public function testPropagateStatusToParentDoesNotUpdateIfStatusUnchanged(): void { + $parentId = 1; + $envelope = new FileEntity(); + $envelope->setId($parentId); + $envelope->setNodeType('envelope'); + $envelope->setStatus(FileEntity::STATUS_SIGNED); + + $child1 = new FileEntity(); + $child1->setStatus(FileEntity::STATUS_SIGNED); + $child2 = new FileEntity(); + $child2->setStatus(FileEntity::STATUS_SIGNED); + + $this->fileMapper->expects($this->once()) + ->method('getById') + ->with($parentId) + ->willReturn($envelope); + + $this->fileMapper->expects($this->once()) + ->method('getChildrenFiles') + ->with($parentId) + ->willReturn([$child1, $child2]); + + $this->fileMapper->expects($this->never())->method('update'); + + $this->service->propagateStatusToParent($parentId); + } } From 7bba3deac976a5674f23723f5d8a1cf8f1734da2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:54:09 -0300 Subject: [PATCH 010/265] chore: update OpenAPI specifications Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 56 +++++++++++++++++-- openapi.json | 89 ++++++++++++++++++------------- src/types/openapi/openapi-full.ts | 31 +++++++++-- src/types/openapi/openapi.ts | 41 +++++++++----- 4 files changed, 156 insertions(+), 61 deletions(-) diff --git a/openapi-full.json b/openapi-full.json index 06e6e1fc22..418d421166 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -4456,17 +4456,15 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "file" - ], "properties": { "file": { "$ref": "#/components/schemas/NewFile", + "default": [], "description": "File to save" }, "name": { @@ -4478,6 +4476,14 @@ "$ref": "#/components/schemas/FolderSettings", "default": [], "description": "Settings to define the pattern to store the file. See more informations at FolderService::getFolderName method." + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files)", + "items": { + "$ref": "#/components/schemas/NewFile" + } } } } @@ -4530,7 +4536,47 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NextcloudFile" + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "envelope": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } } } } diff --git a/openapi.json b/openapi.json index 1a54d7bca9..c611ce1615 100644 --- a/openapi.json +++ b/openapi.json @@ -465,39 +465,6 @@ } } }, - "NextcloudFile": { - "type": "object", - "required": [ - "message", - "name", - "id", - "status", - "statusText", - "created_at" - ], - "properties": { - "message": { - "type": "string" - }, - "name": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "integer", - "format": "int64" - }, - "statusText": { - "type": "string" - }, - "created_at": { - "type": "string" - } - } - }, "Notify": { "type": "object", "required": [ @@ -4306,17 +4273,15 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "file" - ], "properties": { "file": { "$ref": "#/components/schemas/NewFile", + "default": [], "description": "File to save" }, "name": { @@ -4328,6 +4293,14 @@ "$ref": "#/components/schemas/FolderSettings", "default": [], "description": "Settings to define the pattern to store the file. See more informations at FolderService::getFolderName method." + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files)", + "items": { + "$ref": "#/components/schemas/NewFile" + } } } } @@ -4380,7 +4353,47 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NextcloudFile" + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "envelope": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } } } } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 6416e21b9b..9bbf6f1348 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -3231,11 +3231,14 @@ export interface operations { }; cookie?: never; }; - requestBody: { + requestBody?: { content: { "application/json": { - /** @description File to save */ - file: components["schemas"]["NewFile"]; + /** + * @description File to save + * @default [] + */ + file?: components["schemas"]["NewFile"]; /** * @description The name of file to sign * @default @@ -3246,6 +3249,11 @@ export interface operations { * @default [] */ settings?: components["schemas"]["FolderSettings"]; + /** + * @description Multiple files to create an envelope (optional, use either file or files) + * @default [] + */ + files?: components["schemas"]["NewFile"][]; }; }; }; @@ -3259,7 +3267,22 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: components["schemas"]["NextcloudFile"]; + data: { + message: string; + name?: string; + /** Format: int64 */ + id?: number; + /** Format: int64 */ + status?: number; + statusText?: string; + created_at?: string; + envelope?: { + [key: string]: Record; + }; + files?: { + [key: string]: Record; + }[]; + }; }; }; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2efb700ac5..1e12859c10 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1155,16 +1155,6 @@ export type components = { /** Format: int64 */ signingOrder?: number; }; - NextcloudFile: { - message: string; - name: string; - /** Format: int64 */ - id: number; - /** Format: int64 */ - status: number; - statusText: string; - created_at: string; - }; Notify: { date: string; /** @enum {string} */ @@ -2753,11 +2743,14 @@ export interface operations { }; cookie?: never; }; - requestBody: { + requestBody?: { content: { "application/json": { - /** @description File to save */ - file: components["schemas"]["NewFile"]; + /** + * @description File to save + * @default [] + */ + file?: components["schemas"]["NewFile"]; /** * @description The name of file to sign * @default @@ -2768,6 +2761,11 @@ export interface operations { * @default [] */ settings?: components["schemas"]["FolderSettings"]; + /** + * @description Multiple files to create an envelope (optional, use either file or files) + * @default [] + */ + files?: components["schemas"]["NewFile"][]; }; }; }; @@ -2781,7 +2779,22 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: components["schemas"]["NextcloudFile"]; + data: { + message: string; + name?: string; + /** Format: int64 */ + id?: number; + /** Format: int64 */ + status?: number; + statusText?: string; + created_at?: string; + envelope?: { + [key: string]: Record; + }; + files?: { + [key: string]: Record; + }[]; + }; }; }; }; From 5d7ff9111a5845d6734cb9e9c3b92365377f8f4d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:54:23 -0300 Subject: [PATCH 011/265] chore: update Psalm configuration and baseline Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- psalm.xml | 1 + tests/psalm-baseline.xml | 23 ----------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/psalm.xml b/psalm.xml index 0b2131bea4..c93ab89678 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,7 @@ + diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 81a80b59fc..734dc374c5 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -140,8 +140,6 @@ newUserMail]]> newUserMail]]> - - @@ -168,27 +166,6 @@ - - - - - - - - - - - - - - - - - - - - - From 18a882fe8c7e53741437aebffc85f3fd36c563ab Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:54:37 -0300 Subject: [PATCH 012/265] docs: update copilot instructions Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .github/copilot-instructions.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 22ee202b73..bbcabe84e4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -72,15 +72,29 @@ cd tests/integration composer i chown -R www-data: . +# List available test steps +cd tests/integration +runuser -u www-data -- vendor/bin/behat -dl + # Running integration tests (from libresign root directory) cd tests/integration runuser -u www-data -- vendor/bin/behat features/.feature +# Run with verbose output (shows nextcloud.log entries) +runuser -u www-data -- vendor/bin/behat features/.feature -v + # Example: Run specific feature file cd tests/integration runuser -u www-data -- vendor/bin/behat features/auth/login.feature ``` +**CRITICAL**: Like unit tests, ALWAYS run specific scenarios, NEVER run the entire integration test suite: +- Always specify which feature file to run and the row of scenario if needed +- Use `-dl` to list available test steps when writing new tests +- Use `-v` flag to see nextcloud.log output during test execution +- **Important**: steps with OCC command outputs (even with `-v`) don't appear in Behat output but are logged to `nextcloud.log` +- Running all integration tests is extremely slow and resource-intensive + **Frontend Testing**: ```bash npm test # Jest tests From fa620193180cfdb0d68c3dd6d383f2eb12004ec2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:08:57 -0300 Subject: [PATCH 013/265] fix: use the right type Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 14 ++-- lib/Db/File.php | 4 +- lib/ResponseDefinitions.php | 3 + openapi-full.json | 85 +++++++++++----------- openapi.json | 116 +++++++++++++++++++----------- src/types/openapi/openapi-full.ts | 28 ++++---- src/types/openapi/openapi.ts | 38 +++++----- 7 files changed, 166 insertions(+), 122 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 6e56ca954f..87428dda93 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -401,7 +401,7 @@ private function fetchPreview( * @param string $name The name of file to sign * @param LibresignFolderSettings $settings Settings to define the pattern to store the file. See more informations at FolderService::getFolderName method. * @param list $files Multiple files to create an envelope (optional, use either file or files) - * @return DataResponse, files?: array>}, array{}>|DataResponse + * @return DataResponse|DataResponse * * 200: OK * 422: Failed to save data @@ -437,7 +437,7 @@ public function save( } /** - * @return DataResponse + * @return DataResponse */ private function saveSingleFile(array $file, string $name, array $settings): DataResponse { if (empty($name)) { @@ -489,7 +489,7 @@ private function saveSingleFile(array $file, string $name, array $settings): Dat } /** - * @return DataResponse, files: array>}, array{}> + * @return DataResponse */ private function saveMultipleFiles(array $files, string $name, array $settings): DataResponse { if (!$this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)) { @@ -565,13 +565,17 @@ private function validateFilesArray(array $files): void { } } + /** + * @param FileEntity[] $files + * @return list + */ private function formatFilesResponse(array $files): array { - return array_map(fn (FileEntity $file) => [ + return array_values(array_map(fn (FileEntity $file) => [ 'id' => $file->getNodeId(), 'uuid' => $file->getUuid(), 'name' => $file->getName(), 'status' => $file->getStatus(), - ], $files); + ], $files)); } /** diff --git a/lib/Db/File.php b/lib/Db/File.php index 14c68714c0..7f0dcfddf4 100644 --- a/lib/Db/File.php +++ b/lib/Db/File.php @@ -31,7 +31,7 @@ * @method void setCreatedAt(\DateTime $createdAt) * @method \DateTime getCreatedAt() * @method void setName(string $name) - * @method string getName() + * @method non-falsy-string getName() * @method void setCallback(string $callback) * @method ?string getCallback() * @method void setStatus(int $status) @@ -45,7 +45,7 @@ * @method void setDocmdpLevel(int $docmdpLevel) * @method int getDocmdpLevel() * @method void setNodeType(string $nodeType) - * @method string getNodeType() + * @method 'file'|'envelope' getNodeType() * @method void setParentFileId(?int $parentFileId) * @method ?int getParentFileId() */ diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c2a365200d..ce77b1e961 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -47,9 +47,12 @@ * message: string, * name: non-falsy-string, * id: int, + * uuid: string, * status: int, * statusText: string, + * nodeType: 'file'|'envelope', * created_at: string, + * files: list, * } * @psalm-type LibresignIdentifyAccount = array{ * id: non-negative-int, diff --git a/openapi-full.json b/openapi-full.json index 418d421166..0b2c747cc3 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -541,9 +541,12 @@ "message", "name", "id", + "uuid", "status", "statusText", - "created_at" + "nodeType", + "created_at", + "files" ], "properties": { "message": { @@ -556,6 +559,9 @@ "type": "integer", "format": "int64" }, + "uuid": { + "type": "string" + }, "status": { "type": "integer", "format": "int64" @@ -563,8 +569,43 @@ "statusText": { "type": "string" }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, "created_at": { "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "uuid", + "name", + "status" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + } + } + } } } }, @@ -4536,47 +4577,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - }, - "name": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "integer", - "format": "int64" - }, - "statusText": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "envelope": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "files": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - } - } + "$ref": "#/components/schemas/NextcloudFile" } } } diff --git a/openapi.json b/openapi.json index c611ce1615..f82dcd3236 100644 --- a/openapi.json +++ b/openapi.json @@ -465,6 +465,80 @@ } } }, + "NextcloudFile": { + "type": "object", + "required": [ + "message", + "name", + "id", + "uuid", + "status", + "statusText", + "nodeType", + "created_at", + "files" + ], + "properties": { + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, + "created_at": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "uuid", + "name", + "status" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, "Notify": { "type": "object", "required": [ @@ -4353,47 +4427,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object", - "required": [ - "message" - ], - "properties": { - "message": { - "type": "string" - }, - "name": { - "type": "string" - }, - "id": { - "type": "integer", - "format": "int64" - }, - "status": { - "type": "integer", - "format": "int64" - }, - "statusText": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "envelope": { - "type": "object", - "additionalProperties": { - "type": "object" - } - }, - "files": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "object" - } - } - } - } + "$ref": "#/components/schemas/NextcloudFile" } } } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 9bbf6f1348..f8da576cd9 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1616,10 +1616,21 @@ export type components = { name: string; /** Format: int64 */ id: number; + uuid: string; /** Format: int64 */ status: number; statusText: string; + /** @enum {string} */ + nodeType: "file" | "envelope"; created_at: string; + files: { + /** Format: int64 */ + id: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + }[]; }; Notify: { date: string; @@ -3267,22 +3278,7 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: { - message: string; - name?: string; - /** Format: int64 */ - id?: number; - /** Format: int64 */ - status?: number; - statusText?: string; - created_at?: string; - envelope?: { - [key: string]: Record; - }; - files?: { - [key: string]: Record; - }[]; - }; + data: components["schemas"]["NextcloudFile"]; }; }; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 1e12859c10..95f68bda16 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1155,6 +1155,27 @@ export type components = { /** Format: int64 */ signingOrder?: number; }; + NextcloudFile: { + message: string; + name: string; + /** Format: int64 */ + id: number; + uuid: string; + /** Format: int64 */ + status: number; + statusText: string; + /** @enum {string} */ + nodeType: "file" | "envelope"; + created_at: string; + files: { + /** Format: int64 */ + id: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + }[]; + }; Notify: { date: string; /** @enum {string} */ @@ -2779,22 +2800,7 @@ export interface operations { "application/json": { ocs: { meta: components["schemas"]["OCSMeta"]; - data: { - message: string; - name?: string; - /** Format: int64 */ - id?: number; - /** Format: int64 */ - status?: number; - statusText?: string; - created_at?: string; - envelope?: { - [key: string]: Record; - }; - files?: { - [key: string]: Record; - }[]; - }; + data: components["schemas"]["NextcloudFile"]; }; }; }; From 838ac6e3fbd97ad90aabd36ba79e5fcfe21c7ed3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:09:20 -0300 Subject: [PATCH 014/265] fix: cs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 1 + lib/Service/EnvelopeService.php | 2 -- tests/php/Unit/Service/EnvelopeServiceTest.php | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 87428dda93..329c78e1d9 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -528,6 +528,7 @@ private function saveMultipleFiles(array $files, string $name, array $settings): ]); $envelope = $result['envelope']; + return new DataResponse( [ 'message' => $this->l10n->t('Success'), diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php index 780cd2f04d..cc5d6ff3a2 100644 --- a/lib/Service/EnvelopeService.php +++ b/lib/Service/EnvelopeService.php @@ -15,10 +15,8 @@ use OCA\Libresign\Enum\NodeType; use OCA\Libresign\Exception\LibresignException; use OCP\AppFramework\Db\DoesNotExistException; -use OCP\Files\Folder; use OCP\IAppConfig; use OCP\IL10N; -use OCP\IUser; use Sabre\DAV\UUIDUtil; /** diff --git a/tests/php/Unit/Service/EnvelopeServiceTest.php b/tests/php/Unit/Service/EnvelopeServiceTest.php index 943580ef63..d33445a6c4 100644 --- a/tests/php/Unit/Service/EnvelopeServiceTest.php +++ b/tests/php/Unit/Service/EnvelopeServiceTest.php @@ -164,4 +164,3 @@ public function testReturnsEnvelopeWhenFileHasParent(): void { $this->assertSame(5, $result->getId()); } } - From 679a57151f80724f91f5da0cc38296e1d0e48c08 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:06:38 -0300 Subject: [PATCH 015/265] fix: workaround to send the fileId ahead Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/SignRequestMapper.php | 1 + lib/Service/FileService.php | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/Db/SignRequestMapper.php b/lib/Db/SignRequestMapper.php index f86c8ae749..0226985fe3 100644 --- a/lib/Db/SignRequestMapper.php +++ b/lib/Db/SignRequestMapper.php @@ -644,6 +644,7 @@ private function getFilesAssociatedFilesWithMeStmt( private function formatListRow(array $row): array { $internalId = (int)$row['id']; + $row['fileId'] = $internalId; $row['id'] = (int)$row['node_id']; $row['status'] = (int)$row['status']; $row['statusText'] = $this->fileMapper->getTextOfStatus($row['status']); diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index de2e5ba560..14ae86ea2b 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -833,7 +833,7 @@ public function listAssociatedFilesOfSignFlow( $sort, ); - $signers = $this->signRequestMapper->getByMultipleFileId(array_column($return['data'], 'id')); + $signers = $this->signRequestMapper->getByMultipleFileId(array_column($return['data'], 'fileId')); $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers); $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers); $return['data'] = $this->associateAllAndFormat($this->me, $return['data'], $signers, $identifyMethods, $visibleElements); @@ -849,7 +849,7 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers foreach ($files as $key => $file) { $totalSigned = 0; foreach ($signers as $signerKey => $signer) { - if ($signer->getFileId() === $file['id']) { + if ($signer->getFileId() === $file['fileId']) { /** @var array */ $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? []; $data = [ @@ -956,7 +956,7 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers $files[$key]['statusText'] = $this->fileMapper->getTextOfStatus((int)$files[$key]['status']); } - unset($files[$key]['id']); + unset($files[$key]['id'], $files[$key]['fileId']); ksort($files[$key]); } return $files; From 288db149176ec480a9ae95f73e0d378816881d46 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:00:42 -0300 Subject: [PATCH 016/265] feat: add FileUploadHelper for centralized file upload validation - Create new FileUploadHelper with validateUploadedFile() and readUploadedFile() - Centralizes upload validation (error check, is_uploaded_file, filename validation, size check) - Automatic cleanup (@unlink) before throwing exceptions - Comprehensive unit tests with 7 test cases covering all edge cases - Mock is_uploaded_file() via namespace for testing - Follows Nextcloud core patterns (AvatarControllerTest) Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Helper/FileUploadHelper.php | 67 ++++++++ lib/Helper/ValidateHelper.php | 1 + lib/Service/AccountService.php | 12 +- lib/Service/FileService.php | 16 +- lib/Service/RequestSignatureService.php | 2 + lib/Service/TFile.php | 24 +++ .../php/Unit/Helper/FileUploadHelperTest.php | 155 ++++++++++++++++++ 7 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 lib/Helper/FileUploadHelper.php create mode 100644 tests/php/Unit/Helper/FileUploadHelperTest.php diff --git a/lib/Helper/FileUploadHelper.php b/lib/Helper/FileUploadHelper.php new file mode 100644 index 0000000000..b4421429df --- /dev/null +++ b/lib/Helper/FileUploadHelper.php @@ -0,0 +1,67 @@ +l10n->t('Invalid file provided')); + } + + if (!is_uploaded_file($uploadedFile['tmp_name'])) { + @unlink($uploadedFile['tmp_name']); + throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); + } + + $validator = \OCP\Server::get(FilenameValidator::class); + if ($validator->isForbidden($uploadedFile['tmp_name'])) { + @unlink($uploadedFile['tmp_name']); + throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); + } + + if ($uploadedFile['size'] > \OCP\Util::uploadLimit()) { + @unlink($uploadedFile['tmp_name']); + throw new InvalidArgumentException($this->l10n->t('File is too big')); + } + } + + /** + * Read content from uploaded file + * + * @param array $uploadedFile Single file from $_FILES + * @return string File content + * @throws InvalidArgumentException + */ + public function readUploadedFile(array $uploadedFile): string { + $content = file_get_contents($uploadedFile['tmp_name']); + if ($content === false) { + throw new InvalidArgumentException($this->l10n->t('Cannot read file')); + } + return $content; + } +} diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index fecff24eeb..d53aa07ce9 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -78,6 +78,7 @@ public function __construct( private IRootFolder $root, ) { } + public function validateNewFile(array $data, int $type = self::TYPE_TO_SIGN, ?IUser $user = null): void { $this->validateFile($data, $type, $user); if (!empty($data['file']['fileId'])) { diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index 2b24a1fad6..7a0ae00114 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -9,7 +9,6 @@ namespace OCA\Libresign\Service; use InvalidArgumentException; -use OC\Files\Filesystem; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileMapper; @@ -23,6 +22,7 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Settings\Mailer\NewUserMailHelper; use OCP\Accounts\IAccountManager; @@ -77,6 +77,7 @@ public function __construct( private FolderService $folderService, private IClientService $clientService, private ITimeFactory $timeFactory, + private FileUploadHelper $uploadHelper, ) { } @@ -509,14 +510,13 @@ public function deleteSignatureElement(?IUser $user, string $sessionId, int $nod * @throws InvalidArgumentException */ public function uploadPfx(array $file, IUser $user): void { - if ( - $file['error'] !== 0 - || !is_uploaded_file($file['tmp_name']) - || Filesystem::isFileBlacklisted($file['tmp_name']) - ) { + try { + $this->uploadHelper->validateUploadedFile($file); + } catch (InvalidArgumentException) { // TRANSLATORS Error when the uploaded certificate file is not valid throw new InvalidArgumentException($this->l10n->t('Invalid file provided. Need to be a .pfx file.')); } + if ($file['size'] > 10 * 1024) { // TRANSLATORS Error when the certificate file is bigger than normal throw new InvalidArgumentException($this->l10n->t('File is too big')); diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 14ae86ea2b..15325d4cca 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -11,7 +11,6 @@ use DateTime; use DateTimeInterface; use InvalidArgumentException; -use OC\Files\Filesystem; use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File; use OCA\Libresign\Db\FileElement; @@ -24,6 +23,7 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; @@ -93,6 +93,7 @@ public function __construct( protected LoggerInterface $logger, protected IL10N $l10n, private EnvelopeService $envelopeService, + private FileUploadHelper $uploadHelper, ) { $this->docMdpHandler = $docMdpHandler; $this->fileData = new stdClass(); @@ -206,18 +207,7 @@ public function setFileFromRequest(?array $file): self { if ($file === null) { throw new InvalidArgumentException($this->l10n->t('No file provided')); } - if ( - $file['error'] !== 0 - || !is_uploaded_file($file['tmp_name']) - || Filesystem::isFileBlacklisted($file['tmp_name']) - ) { - unlink($file['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('Invalid file provided')); - } - if ($file['size'] > \OCP\Util::uploadLimit()) { - unlink($file['tmp_name']); - throw new InvalidArgumentException($this->l10n->t('File is too big')); - } + $this->uploadHelper->validateUploadedFile($file); $this->fileContent = file_get_contents($file['tmp_name']); $mimeType = $this->mimeTypeDetector->detectString($this->fileContent); diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 1cd9eed438..7f8fb7e220 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -18,6 +18,7 @@ use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; use OCA\Libresign\Handler\DocMdpHandler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCP\AppFramework\Db\DoesNotExistException; @@ -58,6 +59,7 @@ public function __construct( protected SignRequestStatusService $signRequestStatusService, protected DocMdpConfigService $docMdpConfigService, protected EnvelopeService $envelopeService, + protected FileUploadHelper $uploadHelper, ) { } diff --git a/lib/Service/TFile.php b/lib/Service/TFile.php index 77a10f144f..9da10b4f0e 100644 --- a/lib/Service/TFile.php +++ b/lib/Service/TFile.php @@ -10,6 +10,7 @@ use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Vendor\setasign\Fpdi\PdfParserService\Type\PdfTypeException; use OCP\Files\Node; use OCP\Http\Client\IClientService; @@ -19,6 +20,7 @@ trait TFile { private $mimetype = null; protected IClientService $client; protected DocMdpHandler $docMdpHandler; + protected FileUploadHelper $uploadHelper; public function getNodeFromData(array $data): Node { if (!$this->folderService->getUserId()) { @@ -45,6 +47,28 @@ public function getNodeFromData(array $data): Node { return $folderToFile->newFile($data['name'] . '.' . $extension, $content); } + public function getNodeFromUploadedFile(array $data): Node { + if (!$this->folderService->getUserId()) { + $this->folderService->setUserId($data['userManager']->getUID()); + } + + $uploadedFile = $data['uploadedFile']; + + $this->uploadHelper->validateUploadedFile($uploadedFile); + $content = $this->uploadHelper->readUploadedFile($uploadedFile); + + $extension = $this->getExtension($content); + $this->validateFileContent($content, $extension); + + $userFolder = $this->folderService->getFolder(); + $folderName = $this->folderService->getFolderName($data, $data['userManager']); + $folderToFile = $userFolder->newFolder($folderName); + + @unlink($uploadedFile['tmp_name']); + + return $folderToFile->newFile($data['name'] . '.' . $extension, $content); + } + /** * @throws \Exception * @throws LibresignException diff --git a/tests/php/Unit/Helper/FileUploadHelperTest.php b/tests/php/Unit/Helper/FileUploadHelperTest.php new file mode 100644 index 0000000000..89de7a9137 --- /dev/null +++ b/tests/php/Unit/Helper/FileUploadHelperTest.php @@ -0,0 +1,155 @@ +l10n = $this->createMock(IL10N::class); + $this->l10n->method('t') + ->willReturnCallback(fn ($text) => $text); + + $this->helper = new FileUploadHelper($this->l10n); + + $this->tempFile = tempnam(sys_get_temp_dir(), 'upload_test_'); + file_put_contents($this->tempFile, 'test content'); + } + + protected function tearDown(): void { + if (file_exists($this->tempFile)) { + @unlink($this->tempFile); + } + parent::tearDown(); + } + + public function testValidateUploadedFileSuccess(): void { + $uploadedFile = [ + 'tmp_name' => $this->tempFile, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($this->tempFile), + ]; + + $this->helper->validateUploadedFile($uploadedFile); + + $this->assertTrue(file_exists($this->tempFile)); + } + + public function testValidateUploadedFileWithUploadError(): void { + $uploadedFile = [ + 'tmp_name' => $this->tempFile, + 'error' => UPLOAD_ERR_INI_SIZE, + 'size' => 0, + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided'); + + try { + $this->helper->validateUploadedFile($uploadedFile); + } finally { + $this->assertFalse(file_exists($this->tempFile), 'File should be deleted after error'); + } + } + + public function testValidateUploadedFileNotActuallyUploaded(): void { + $nonExistentFile = sys_get_temp_dir() . '/non_existent_file_' . time(); + + $uploadedFile = [ + 'tmp_name' => $nonExistentFile, + 'error' => UPLOAD_ERR_OK, + 'size' => 100, + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid file provided'); + + $this->helper->validateUploadedFile($uploadedFile); + } + + public function testValidateUploadedFileTooBig(): void { + $uploadedFile = [ + 'tmp_name' => $this->tempFile, + 'error' => UPLOAD_ERR_OK, + 'size' => \OCP\Util::uploadLimit() + 1, + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('File is too big'); + + try { + $this->helper->validateUploadedFile($uploadedFile); + } finally { + $this->assertFalse(file_exists($this->tempFile), 'File should be deleted when too big'); + } + } + + public function testReadUploadedFileSuccess(): void { + $expectedContent = 'test file content'; + file_put_contents($this->tempFile, $expectedContent); + + $uploadedFile = [ + 'tmp_name' => $this->tempFile, + ]; + + $content = $this->helper->readUploadedFile($uploadedFile); + + $this->assertEquals($expectedContent, $content); + } + + public function testReadUploadedFileNotReadable(): void { + $uploadedFile = [ + 'tmp_name' => '/path/that/does/not/exist.txt', + ]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Cannot read file'); + + $this->helper->readUploadedFile($uploadedFile); + } + + public function testValidateUploadedFileWithForbiddenName(): void { + $forbiddenFile = sys_get_temp_dir() . '/test.txt'; + + if (@file_put_contents($forbiddenFile, 'test') === false) { + $this->markTestSkipped('Cannot create file with forbidden characters on this OS'); + return; + } + + $uploadedFile = [ + 'tmp_name' => $forbiddenFile, + 'error' => UPLOAD_ERR_OK, + 'size' => filesize($forbiddenFile), + ]; + + try { + $this->helper->validateUploadedFile($uploadedFile); + @unlink($forbiddenFile); + } catch (InvalidArgumentException $e) { + $this->assertEquals('Invalid file provided', $e->getMessage()); + $this->assertFalse(file_exists($forbiddenFile)); + } + } +} From d97d377b9b35b524c9542f2bdade96e5f8b264c1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:26:00 -0300 Subject: [PATCH 017/265] feat: move envelope validation to FileService - Add validateEnvelopeConstraints() method for business rule validation - Validate envelope_enabled and envelope_max_files before processing files - Fix uploadHelper property conflict with TFile trait - Improves separation of concerns: FileService owns validation logic Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 86 ++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 15325d4cca..e6e62d814d 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -93,9 +93,10 @@ public function __construct( protected LoggerInterface $logger, protected IL10N $l10n, private EnvelopeService $envelopeService, - private FileUploadHelper $uploadHelper, + FileUploadHelper $uploadHelper, ) { $this->docMdpHandler = $docMdpHandler; + $this->uploadHelper = $uploadHelper; $this->fileData = new stdClass(); } @@ -1011,4 +1012,87 @@ public function delete(int $fileId): void { } catch (NotFoundException) { } } + + /** + * Process uploaded files with automatic rollback on error + * + * @param array $filesArray Normalized array of uploaded files + * @param IUser $user User who is uploading + * @param array $settings Upload settings + * @return list + * @throws LibresignException + */ + public function processUploadedFilesWithRollback(array $filesArray, IUser $user, array $settings): array { + $this->validateEnvelopeConstraints($filesArray); + + $processedFiles = []; + $createdNodes = []; + $shouldRollback = true; + + try { + foreach ($filesArray as $uploadedFile) { + $fileName = pathinfo($uploadedFile['name'], PATHINFO_FILENAME); + + $node = $this->getNodeFromUploadedFile([ + 'userManager' => $user, + 'name' => $fileName, + 'uploadedFile' => $uploadedFile, + 'settings' => $settings, + ]); + + $createdNodes[] = $node; + + $this->validateHelper->validateNewFile([ + 'file' => ['fileId' => $node->getId()], + 'userManager' => $user, + ]); + + $processedFiles[] = [ + 'fileNode' => $node, + 'name' => $fileName, + ]; + } + + $shouldRollback = false; + return $processedFiles; + } finally { + if ($shouldRollback) { + $this->rollbackCreatedNodes($createdNodes); + } + } + } + + /** + * @throws LibresignException + */ + private function validateEnvelopeConstraints(array $filesArray): void { + if (count($filesArray) <= 1) { + return; + } + + if (!$this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)) { + throw new LibresignException($this->l10n->t('Envelope feature is disabled')); + } + + $maxFiles = $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); + if (count($filesArray) > $maxFiles) { + throw new LibresignException($this->l10n->t('Maximum of %d files per envelope', [$maxFiles])); + } + } + + /** + * @param Node[] $nodes + */ + private function rollbackCreatedNodes(array $nodes): void { + foreach ($nodes as $node) { + try { + $node->delete(); + } catch (\Exception $deleteError) { + $this->logger->error('Failed to rollback uploaded file', [ + 'nodeId' => $node->getId(), + 'error' => $deleteError->getMessage(), + ]); + } + } + } } From e8d43dbdccbb330b2f9727df9d03c499c34fb49a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:32:58 -0300 Subject: [PATCH 018/265] fix: add missing Node import in FileService - Import OCP\Files\Node for proper type resolution - Fix Psalm docblock type annotations - Use fully qualified namespace in return types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index e6e62d814d..62bbdf9170 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -31,6 +31,7 @@ use OCP\AppFramework\Db\DoesNotExistException; use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotFoundException; use OCP\Http\Client\IClientService; use OCP\IAppConfig; From 4d0fa94f04a42e7f1a846d9a41223ac4827a3a0a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:33:06 -0300 Subject: [PATCH 019/265] refactor: simplify FileController and fix type annotations - Remove duplicate fileName extraction logic in saveFiles - Let prepareFileForSaving handle all name extraction - Fix Psalm type annotations with fully qualified namespaces - Add inline @var annotation for parameter type Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 188 +++++++++++++++++------------- 1 file changed, 106 insertions(+), 82 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 329c78e1d9..327ae80141 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -417,15 +417,11 @@ public function save( array $files = [], ): DataResponse { try { - if ((empty($file) && empty($files)) || (!empty($files) && count($files) === 0)) { - throw new LibresignException($this->l10n->t('File or files parameter is required')); - } + $this->validateHelper->canRequestSign($this->userSession->getUser()); - if (!empty($files)) { - return $this->saveMultipleFiles($files, $name, $settings); - } + $normalizedFiles = $this->prepareFilesForSaving($file, $files, $settings); - return $this->saveSingleFile($file, $name, $settings); + return $this->saveFiles($normalizedFiles, $name, $settings); } catch (LibresignException $e) { return new DataResponse( [ @@ -437,87 +433,119 @@ public function save( } /** - * @return DataResponse + * @return array{node: Node, name: string} */ - private function saveSingleFile(array $file, string $name, array $settings): DataResponse { + private function prepareFileForSaving(array $fileData, string $name, array $settings): array { if (empty($name)) { - if (!empty($file['url'])) { - $name = rawurldecode(pathinfo($file['url'], PATHINFO_FILENAME)); - } + $name = $this->extractFileName($fileData); } if (empty($name)) { throw new LibresignException($this->l10n->t('Name is mandatory')); } - $this->validateHelper->validateNewFile([ - 'file' => $file, - 'userManager' => $this->userSession->getUser(), - ]); - $this->validateHelper->canRequestSign($this->userSession->getUser()); + if (isset($fileData['fileNode']) && $fileData['fileNode'] instanceof Node) { + $node = $fileData['fileNode']; + $name = $fileData['name'] ?? $name; + } else { + $this->validateHelper->validateNewFile([ + 'file' => $fileData, + 'userManager' => $this->userSession->getUser(), + ]); - $node = $this->fileService->getNodeFromData([ - 'userManager' => $this->userSession->getUser(), - 'name' => $name, - 'file' => $file, - 'settings' => $settings - ]); + $node = $this->fileService->getNodeFromData([ + 'userManager' => $this->userSession->getUser(), + 'name' => $name, + 'file' => $fileData, + 'settings' => $settings + ]); + } - $data = [ - 'file' => [ - 'fileNode' => $node, - ], + return [ + 'node' => $node, 'name' => $name, - 'userManager' => $this->userSession->getUser(), - 'status' => FileEntity::STATUS_DRAFT, ]; - $savedFile = $this->requestSignatureService->save($data); + } - return new DataResponse( - [ - 'message' => $this->l10n->t('Success'), - 'id' => $savedFile->getNodeId(), - 'uuid' => $savedFile->getUuid(), - 'name' => $savedFile->getName(), - 'status' => $savedFile->getStatus(), - 'statusText' => $this->fileMapper->getTextOfStatus($savedFile->getStatus()), - 'nodeType' => $savedFile->getNodeType(), - 'created_at' => $savedFile->getCreatedAt()->format(\DateTimeInterface::ATOM), - 'files' => [$this->formatFilesResponse([$savedFile])[0]], - ], - Http::STATUS_OK + /** + * @return list Normalized files array + */ + private function prepareFilesForSaving(array $file, array $files, array $settings): array { + $uploadedFiles = $this->request->getUploadedFile('files') ?: $this->request->getUploadedFile('file'); + + if ($uploadedFiles) { + return $this->processUploadedFiles($uploadedFiles, $settings); + } + + if (!empty($files)) { + /** @var list $files */ + return $files; + } + + if (!empty($file)) { + return [$file]; + } + + throw new LibresignException($this->l10n->t('File or files parameter is required')); + } + + /** + * @return list + */ + private function processUploadedFiles(array $uploadedFiles, array $settings): array { + $filesArray = []; + + if (isset($uploadedFiles['tmp_name'])) { + if (is_array($uploadedFiles['tmp_name'])) { + $count = count($uploadedFiles['tmp_name']); + for ($i = 0; $i < $count; $i++) { + $filesArray[] = [ + 'tmp_name' => $uploadedFiles['tmp_name'][$i], + 'name' => $uploadedFiles['name'][$i], + 'type' => $uploadedFiles['type'][$i], + 'size' => $uploadedFiles['size'][$i], + 'error' => $uploadedFiles['error'][$i], + ]; + } + } else { + $filesArray[] = $uploadedFiles; + } + } + + if (empty($filesArray)) { + throw new LibresignException($this->l10n->t('No files uploaded')); + } + + return $this->fileService->processUploadedFilesWithRollback( + $filesArray, + $this->userSession->getUser(), + $settings ); } /** * @return DataResponse */ - private function saveMultipleFiles(array $files, string $name, array $settings): DataResponse { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)) { - throw new LibresignException($this->l10n->t('Envelope feature is disabled')); + private function saveFiles(array $files, string $name, array $settings): DataResponse { + if (empty($files)) { + throw new LibresignException($this->l10n->t('File or files parameter is required')); } - $this->validateFilesArray($files); - $this->validateHelper->canRequestSign($this->userSession->getUser()); - $preparedFiles = []; foreach ($files as $fileData) { - $this->validateHelper->validateNewFile([ - 'file' => $fileData, - 'userManager' => $this->userSession->getUser(), - ]); + $fileName = (count($files) === 1) ? $name : ''; + $preparedFiles[] = $this->prepareFileForSaving($fileData, $fileName, $settings); + } - $fileName = $this->extractFileName($fileData); - $node = $this->fileService->getNodeFromData([ + if (count($preparedFiles) === 1) { + $prepared = $preparedFiles[0]; + $savedFile = $this->requestSignatureService->save([ + 'file' => ['fileNode' => $prepared['node']], + 'name' => $prepared['name'], 'userManager' => $this->userSession->getUser(), - 'name' => $fileName, - 'file' => $fileData, - 'settings' => $settings + 'status' => FileEntity::STATUS_DRAFT, ]); - $preparedFiles[] = [ - 'node' => $node, - 'name' => $fileName, - ]; + return $this->formatFileResponse($savedFile, [$savedFile]); } $result = $this->requestSignatureService->saveEnvelope([ @@ -527,19 +555,26 @@ private function saveMultipleFiles(array $files, string $name, array $settings): 'settings' => $settings, ]); - $envelope = $result['envelope']; + return $this->formatFileResponse($result['envelope'], $result['files']); + } + /** + * @param FileEntity $mainEntity The main entity (file or envelope) + * @param FileEntity[] $childFiles Child files (for envelope) or same as mainEntity (for single file) + * @return DataResponse + */ + private function formatFileResponse(FileEntity $mainEntity, array $childFiles): DataResponse { return new DataResponse( [ 'message' => $this->l10n->t('Success'), - 'id' => $envelope->getNodeId(), - 'uuid' => $envelope->getUuid(), - 'name' => $envelope->getName(), - 'status' => $envelope->getStatus(), - 'statusText' => $this->fileMapper->getTextOfStatus($envelope->getStatus()), - 'nodeType' => $envelope->getNodeType(), - 'created_at' => $envelope->getCreatedAt()->format(\DateTimeInterface::ATOM), - 'files' => $this->formatFilesResponse($result['files']), + 'id' => $mainEntity->getNodeId(), + 'uuid' => $mainEntity->getUuid(), + 'name' => $mainEntity->getName(), + 'status' => $mainEntity->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($mainEntity->getStatus()), + 'nodeType' => $mainEntity->getNodeType(), + 'created_at' => $mainEntity->getCreatedAt()->format(\DateTimeInterface::ATOM), + 'files' => $this->formatFilesResponse($childFiles), ], Http::STATUS_OK ); @@ -555,17 +590,6 @@ private function extractFileName(array $fileData): string { return ''; } - private function validateFilesArray(array $files): void { - if (empty($files)) { - throw new LibresignException($this->l10n->t('At least one file is required')); - } - - $maxFiles = $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); - if (count($files) > $maxFiles) { - throw new LibresignException($this->l10n->t('Maximum of %d files per envelope', [$maxFiles])); - } - } - /** * @param FileEntity[] $files * @return list From 288a187439a6159daaa2db8e3d4662b238d09ebe Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:38:46 -0300 Subject: [PATCH 020/265] test: fix unit tests after FileUploadHelper injection - Add FileUploadHelper mock to AccountServiceTest - Add FileUploadHelper instance to FileServiceTest - Add FileUploadHelper mock to IdDocsServiceTest - Replace FileService with FileUploadHelper in RequestSignatureServiceTest - All services now correctly inject FileUploadHelper dependency Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/AccountServiceTest.php | 6 +++++- tests/php/Unit/Service/FileServiceTest.php | 4 ++++ tests/php/Unit/Service/IdDocsServiceTest.php | 6 +++++- tests/php/Unit/Service/RequestSignatureServiceTest.php | 8 ++++---- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/php/Unit/Service/AccountServiceTest.php b/tests/php/Unit/Service/AccountServiceTest.php index a8caa616fa..4bfc9e28b4 100644 --- a/tests/php/Unit/Service/AccountServiceTest.php +++ b/tests/php/Unit/Service/AccountServiceTest.php @@ -18,6 +18,7 @@ use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\FolderService; @@ -71,6 +72,7 @@ final class AccountServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private TimeFactory&MockObject $timeFactory; private RequestSignatureService&MockObject $requestSignatureService; private Pkcs12Handler&MockObject $pkcs12Handler; + private FileUploadHelper&MockObject $uploadHelper; public function setUp(): void { parent::setUp(); @@ -104,6 +106,7 @@ public function setUp(): void { $this->folderService = $this->createMock(FolderService::class); $this->clientService = $this->createMock(ClientService::class); $this->timeFactory = $this->createMock(TimeFactory::class); + $this->uploadHelper = $this->createMock(FileUploadHelper::class); } private function getService(): AccountService { @@ -134,7 +137,8 @@ private function getService(): AccountService { $this->userElementMapper, $this->folderService, $this->clientService, - $this->timeFactory + $this->timeFactory, + $this->uploadHelper ); } diff --git a/tests/php/Unit/Service/FileServiceTest.php b/tests/php/Unit/Service/FileServiceTest.php index 95119512d3..256137643a 100644 --- a/tests/php/Unit/Service/FileServiceTest.php +++ b/tests/php/Unit/Service/FileServiceTest.php @@ -28,6 +28,7 @@ function is_uploaded_file($filename) { use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\EnvelopeService; @@ -81,6 +82,7 @@ final class FileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { protected IL10N $l10n; protected EnvelopeService $envelopeService; protected vfsDirectory $tempFolder; + protected FileUploadHelper&MockObject $uploadHelper; public function setUp(): void { $this->tempFolder = vfsStream::setup('uploaded'); @@ -114,6 +116,7 @@ private function getService(): FileService { $this->logger = \OCP\Server::get(LoggerInterface::class); $this->l10n = \OCP\Server::get(IL10NFactory::class)->get(Application::APP_ID); $this->envelopeService = \OCP\Server::get(EnvelopeService::class); + $this->uploadHelper = \OCP\Server::get(FileUploadHelper::class); return new FileService( $this->fileMapper, $this->signRequestMapper, @@ -139,6 +142,7 @@ private function getService(): FileService { $this->logger, $this->l10n, $this->envelopeService, + $this->uploadHelper, ); } diff --git a/tests/php/Unit/Service/IdDocsServiceTest.php b/tests/php/Unit/Service/IdDocsServiceTest.php index ccbef03392..54336f974e 100644 --- a/tests/php/Unit/Service/IdDocsServiceTest.php +++ b/tests/php/Unit/Service/IdDocsServiceTest.php @@ -19,6 +19,7 @@ use OCA\Libresign\Db\UserElementMapper; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\FolderService; @@ -73,6 +74,7 @@ final class IdDocsServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { private TimeFactory&MockObject $timeFactory; private RequestSignatureService&MockObject $requestSignatureService; private Pkcs12Handler&MockObject $pkcs12Handler; + private FileUploadHelper&MockObject $uploadHelper; public function setUp(): void { parent::setUp(); @@ -107,6 +109,7 @@ public function setUp(): void { $this->folderService = $this->createMock(FolderService::class); $this->clientService = $this->createMock(ClientService::class); $this->timeFactory = $this->createMock(TimeFactory::class); + $this->uploadHelper = $this->createMock(FileUploadHelper::class); } private function getService(): AccountService { @@ -137,7 +140,8 @@ private function getService(): AccountService { $this->userElementMapper, $this->folderService, $this->clientService, - $this->timeFactory + $this->timeFactory, + $this->uploadHelper, ); } diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index 2ddc6235fd..0da3a70ccb 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -12,11 +12,11 @@ use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Handler\DocMdpHandler; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\DocMdpConfigService; use OCA\Libresign\Service\EnvelopeService; use OCA\Libresign\Service\FileElementService; -use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\IdentifyMethodService; @@ -61,7 +61,7 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private SignRequestStatusService&MockObject $signRequestStatusService; private DocMdpConfigService&MockObject $docMdpConfigService; private EnvelopeService&MockObject $envelopeService; - private FileService&MockObject $fileService; + private FileUploadHelper&MockObject $uploadHelper; public function setUp(): void { parent::setUp(); @@ -93,7 +93,7 @@ public function setUp(): void { $this->signRequestStatusService = $this->createMock(SignRequestStatusService::class); $this->docMdpConfigService = $this->createMock(DocMdpConfigService::class); $this->envelopeService = $this->createMock(EnvelopeService::class); - $this->fileService = $this->createMock(FileService::class); + $this->uploadHelper = $this->createMock(FileUploadHelper::class); } private function getService(): RequestSignatureService { @@ -120,7 +120,7 @@ private function getService(): RequestSignatureService { $this->signRequestStatusService, $this->docMdpConfigService, $this->envelopeService, - $this->fileService, + $this->uploadHelper, ); } From c011a238368cd976e8a246fbd5dbf187ce8c4486 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 20:57:04 -0300 Subject: [PATCH 021/265] feat: ensure all envelope files share same folder using envelope UUID - Generate folderName once in saveEnvelope() using envelope UUID - Pass same folderName to all files via settings - Pattern: envelope-{uuid} ensures uniqueness across envelopes - All files in an envelope now go to the same folder Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 7f8fb7e220..5637b0e118 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -81,12 +81,17 @@ public function saveEnvelope(array $data): array { $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId); + $envelopeFolderName = 'envelope-' . $envelope->getUuid(); + $envelopeSettings = array_merge($data['settings'] ?? [], [ + 'folderName' => $envelopeFolderName, + ]); + $files = []; foreach ($data['files'] as $fileData) { $fileEntity = $this->createFileForEnvelope( $fileData, $userManager, - $data['settings'] ?? [] + $envelopeSettings ); $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); $files[] = $fileEntity; From 9823bcdcd965921f5bf6736ff45bf08df5904e0c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:04:01 -0300 Subject: [PATCH 022/265] fix: use real FileUploadHelper instance in tests instead of mock - Changed property type from FileUploadHelper&MockObject to FileUploadHelper - Use \OCP\Server::get(FileUploadHelper::class) to get real instance - Fixes 'File is too big' test that requires actual validation logic - All 105 FileServiceTest tests now passing Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Service/FileServiceTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/php/Unit/Service/FileServiceTest.php b/tests/php/Unit/Service/FileServiceTest.php index 256137643a..feb9d98e12 100644 --- a/tests/php/Unit/Service/FileServiceTest.php +++ b/tests/php/Unit/Service/FileServiceTest.php @@ -82,7 +82,7 @@ final class FileServiceTest extends \OCA\Libresign\Tests\Unit\TestCase { protected IL10N $l10n; protected EnvelopeService $envelopeService; protected vfsDirectory $tempFolder; - protected FileUploadHelper&MockObject $uploadHelper; + protected FileUploadHelper $uploadHelper; public function setUp(): void { $this->tempFolder = vfsStream::setup('uploaded'); From 15718b731db947b6eb9142bc81a9e45fd8e60452 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:23:42 -0300 Subject: [PATCH 023/265] fix: resolve test warnings and risky tests - Suppress file_get_contents warning in FileUploadHelper::readUploadedFile() - Fix risky test in FileUploadHelperTest::testValidateUploadedFileWithForbiddenName - Add explicit escape parameter to str_getcsv in FooterHandlerTest (PHP 8.4 compat) - Tests now properly skip when OS-specific conditions aren't met Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Helper/FileUploadHelper.php | 2 +- tests/php/Unit/Handler/FooterHandlerTest.php | 2 +- tests/php/Unit/Helper/FileUploadHelperTest.php | 11 +++++++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/lib/Helper/FileUploadHelper.php b/lib/Helper/FileUploadHelper.php index b4421429df..3a187b1602 100644 --- a/lib/Helper/FileUploadHelper.php +++ b/lib/Helper/FileUploadHelper.php @@ -58,7 +58,7 @@ public function validateUploadedFile(array $uploadedFile): void { * @throws InvalidArgumentException */ public function readUploadedFile(array $uploadedFile): string { - $content = file_get_contents($uploadedFile['tmp_name']); + $content = @file_get_contents($uploadedFile['tmp_name']); if ($content === false) { throw new InvalidArgumentException($this->l10n->t('Cannot read file')); } diff --git a/tests/php/Unit/Handler/FooterHandlerTest.php b/tests/php/Unit/Handler/FooterHandlerTest.php index bc83f924c7..6dd71eac0d 100644 --- a/tests/php/Unit/Handler/FooterHandlerTest.php +++ b/tests/php/Unit/Handler/FooterHandlerTest.php @@ -229,7 +229,7 @@ private function extractPdfContent(string $content, array $keys, string $directi $this->assertNotEmpty($text, 'PDF without text'); $content = explode("\n", $text); $this->assertNotEmpty($content, 'PDF without any row'); - $content = array_map(fn ($row) => str_getcsv($row, ':'), $content); + $content = array_map(fn ($row) => str_getcsv($row, ':', '"', '\\'), $content); // Necessary flip key/value when the language is LTR $columnKey = $direction === 'rtl' ? 1 : 0; diff --git a/tests/php/Unit/Helper/FileUploadHelperTest.php b/tests/php/Unit/Helper/FileUploadHelperTest.php index 89de7a9137..56458bd326 100644 --- a/tests/php/Unit/Helper/FileUploadHelperTest.php +++ b/tests/php/Unit/Helper/FileUploadHelperTest.php @@ -144,12 +144,19 @@ public function testValidateUploadedFileWithForbiddenName(): void { 'size' => filesize($forbiddenFile), ]; + $exceptionThrown = false; try { $this->helper->validateUploadedFile($uploadedFile); - @unlink($forbiddenFile); } catch (InvalidArgumentException $e) { + $exceptionThrown = true; $this->assertEquals('Invalid file provided', $e->getMessage()); - $this->assertFalse(file_exists($forbiddenFile)); + $this->assertFalse(file_exists($forbiddenFile), 'File should be deleted after validation fails'); + } finally { + @unlink($forbiddenFile); + } + + if (!$exceptionThrown) { + $this->markTestSkipped('FilenameValidator does not consider this filename as forbidden on this OS'); } } } From 2a89d8a8ccd0b0a8d60fab139ba7c75e046bdfee Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:45:45 -0300 Subject: [PATCH 024/265] feat: add validateUploadedFile method to FileService Expose file validation without node creation, allowing controllers to validate uploaded files before deciding the file creation strategy. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 62bbdf9170..b4e57defb0 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -205,6 +205,10 @@ public function setFileByType(string $type, $identifier): self { return $this; } + public function validateUploadedFile(array $file): void { + $this->uploadHelper->validateUploadedFile($file); + } + public function setFileFromRequest(?array $file): self { if ($file === null) { throw new InvalidArgumentException($this->l10n->t('No file provided')); From 84c5d4fcec7d34f29639363dcd8db5c2343be618 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:45:55 -0300 Subject: [PATCH 025/265] refactor: delay node creation until envelope folder is known Changed processUploadedFiles to only validate and return file data instead of creating nodes immediately. This ensures nodes are created with the correct envelope folder path in saveEnvelope. - processUploadedFiles now returns uploadedFile data structure - Node creation moved to RequestSignatureService.saveEnvelope - Fixed return type annotation to include uploadedFile field Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 37 ++++++++++++++++--------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 327ae80141..c9b8541e61 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -467,7 +467,7 @@ private function prepareFileForSaving(array $fileData, string $name, array $sett } /** - * @return list Normalized files array + * @return list Normalized files array */ private function prepareFilesForSaving(array $file, array $files, array $settings): array { $uploadedFiles = $this->request->getUploadedFile('files') ?: $this->request->getUploadedFile('file'); @@ -489,7 +489,7 @@ private function prepareFilesForSaving(array $file, array $files, array $setting } /** - * @return list + * @return list */ private function processUploadedFiles(array $uploadedFiles, array $settings): array { $filesArray = []; @@ -498,16 +498,25 @@ private function processUploadedFiles(array $uploadedFiles, array $settings): ar if (is_array($uploadedFiles['tmp_name'])) { $count = count($uploadedFiles['tmp_name']); for ($i = 0; $i < $count; $i++) { - $filesArray[] = [ + $uploadedFile = [ 'tmp_name' => $uploadedFiles['tmp_name'][$i], 'name' => $uploadedFiles['name'][$i], 'type' => $uploadedFiles['type'][$i], 'size' => $uploadedFiles['size'][$i], 'error' => $uploadedFiles['error'][$i], ]; + $this->fileService->validateUploadedFile($uploadedFile); + $filesArray[] = [ + 'uploadedFile' => $uploadedFile, + 'name' => pathinfo($uploadedFile['name'], PATHINFO_FILENAME), + ]; } } else { - $filesArray[] = $uploadedFiles; + $this->fileService->validateUploadedFile($uploadedFiles); + $filesArray[] = [ + 'uploadedFile' => $uploadedFiles, + 'name' => pathinfo($uploadedFiles['name'], PATHINFO_FILENAME), + ]; } } @@ -515,11 +524,7 @@ private function processUploadedFiles(array $uploadedFiles, array $settings): ar throw new LibresignException($this->l10n->t('No files uploaded')); } - return $this->fileService->processUploadedFilesWithRollback( - $filesArray, - $this->userSession->getUser(), - $settings - ); + return $filesArray; } /** @@ -530,14 +535,10 @@ private function saveFiles(array $files, string $name, array $settings): DataRes throw new LibresignException($this->l10n->t('File or files parameter is required')); } - $preparedFiles = []; - foreach ($files as $fileData) { - $fileName = (count($files) === 1) ? $name : ''; - $preparedFiles[] = $this->prepareFileForSaving($fileData, $fileName, $settings); - } - - if (count($preparedFiles) === 1) { - $prepared = $preparedFiles[0]; + if (count($files) === 1) { + $fileData = $files[0]; + $fileName = $name; + $prepared = $this->prepareFileForSaving($fileData, $fileName, $settings); $savedFile = $this->requestSignatureService->save([ 'file' => ['fileNode' => $prepared['node']], 'name' => $prepared['name'], @@ -549,7 +550,7 @@ private function saveFiles(array $files, string $name, array $settings): DataRes } $result = $this->requestSignatureService->saveEnvelope([ - 'files' => $preparedFiles, + 'files' => $files, 'name' => $name, 'userManager' => $this->userSession->getUser(), 'settings' => $settings, From c7265386e4e1e020b86899a12a2760ad3fe43aa8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:46:07 -0300 Subject: [PATCH 026/265] feat: implement atomic envelope creation with rollback Envelope creation now ensures atomicity: - Generate envelope UUID and folderName before creating nodes - Create nodes with correct settings containing folderName - Rollback all changes (nodes, files, envelope) on any error Added dedicated rollback methods: - rollbackEnvelopeCreation: orchestrates complete rollback - rollbackCreatedNodes: removes filesystem nodes - rollbackCreatedFiles: removes file entities from database - rollbackEnvelope: removes envelope entity from database This prevents partial envelope creation and ensures data consistency. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 106 +++++++++++++++++++----- 1 file changed, 86 insertions(+), 20 deletions(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 5637b0e118..94a40a199f 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -79,28 +79,93 @@ public function saveEnvelope(array $data): array { $userManager = $data['userManager'] ?? null; $userId = $userManager instanceof IUser ? $userManager->getUID() : null; - $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId); + $envelope = null; + $files = []; + $createdNodes = []; - $envelopeFolderName = 'envelope-' . $envelope->getUuid(); - $envelopeSettings = array_merge($data['settings'] ?? [], [ - 'folderName' => $envelopeFolderName, - ]); + try { + $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId); + + $envelopeFolderName = 'envelope-' . $envelope->getUuid(); + $envelopeSettings = array_merge($data['settings'] ?? [], [ + 'folderName' => $envelopeFolderName, + ]); + + foreach ($data['files'] as $fileData) { + if (isset($fileData['uploadedFile'])) { + $node = $this->getNodeFromUploadedFile([ + 'userManager' => $userManager, + 'name' => $fileData['name'], + 'uploadedFile' => $fileData['uploadedFile'], + 'settings' => $envelopeSettings, + ]); + $fileData['node'] = $node; + $createdNodes[] = $node; + } + $fileEntity = $this->createFileForEnvelope( + $fileData, + $userManager, + $envelopeSettings + ); + $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); + $files[] = $fileEntity; + } - $files = []; - foreach ($data['files'] as $fileData) { - $fileEntity = $this->createFileForEnvelope( - $fileData, - $userManager, - $envelopeSettings - ); - $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); - $files[] = $fileEntity; - } - - return [ - 'envelope' => $envelope, - 'files' => $files, - ]; + return [ + 'envelope' => $envelope, + 'files' => $files, + ]; + } catch (\Throwable $e) { + $this->rollbackEnvelopeCreation($envelope, $files, $createdNodes); + throw $e; + } + } + + private function rollbackEnvelopeCreation(?FileEntity $envelope, array $files, array $createdNodes): void { + $this->rollbackCreatedNodes($createdNodes); + $this->rollbackCreatedFiles($files); + $this->rollbackEnvelope($envelope); + } + + private function rollbackCreatedNodes(array $nodes): void { + foreach ($nodes as $node) { + try { + $node->delete(); + } catch (\Throwable $deleteError) { + $this->logger->error('Failed to rollback created node in envelope', [ + 'nodeId' => $node->getId(), + 'error' => $deleteError->getMessage(), + ]); + } + } + } + + private function rollbackCreatedFiles(array $files): void { + foreach ($files as $file) { + try { + $this->fileMapper->delete($file); + } catch (\Throwable $deleteError) { + $this->logger->error('Failed to rollback created file entity in envelope', [ + 'fileId' => $file->getId(), + 'error' => $deleteError->getMessage(), + ]); + } + } + } + + private function rollbackEnvelope(?FileEntity $envelope): void { + if ($envelope === null) { + return; + } + + try { + $this->fileMapper->delete($envelope); + } catch (\Throwable $deleteError) { + $this->logger->error('Failed to rollback created envelope', [ + 'envelopeId' => $envelope->getId(), + 'error' => $deleteError->getMessage(), + ]); + } } private function createFileForEnvelope(array $fileData, ?IUser $userManager, array $settings): FileEntity { @@ -116,6 +181,7 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr 'name' => $fileName, 'userManager' => $userManager, 'status' => FileEntity::STATUS_DRAFT, + 'settings' => $settings, ]); } From ed4e2a463d3788a42390ff1d53b27c456b6af954 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:50:33 -0300 Subject: [PATCH 027/265] feat: add envelope feature validation - Validate envelope_enabled config before creating envelopes - Throw descriptive exception when feature is disabled - Keep original error message for maximum files exceeded Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/EnvelopeService.php | 3 --- lib/Service/RequestSignatureService.php | 5 +++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/Service/EnvelopeService.php b/lib/Service/EnvelopeService.php index cc5d6ff3a2..444b366648 100644 --- a/lib/Service/EnvelopeService.php +++ b/lib/Service/EnvelopeService.php @@ -19,9 +19,6 @@ use OCP\IL10N; use Sabre\DAV\UUIDUtil; -/** - * Manage envelopes (DocuSign-style digital containers for multiple documents) - */ class EnvelopeService { public function __construct( protected FileMapper $fileMapper, diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 94a40a199f..dd14eb105b 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -75,6 +75,11 @@ public function save(array $data): FileEntity { } public function saveEnvelope(array $data): array { + $isEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + if (!$isEnabled) { + throw new \Exception($this->l10n->t('Envelope feature is disabled')); + } + $envelopeName = $data['name'] ?: $this->l10n->t('Envelope %s', [date('Y-m-d H:i:s')]); $userManager = $data['userManager'] ?? null; $userId = $userManager instanceof IUser ? $userManager->getUID() : null; From 59fc9424ccd542aaab8bc0be4b2b7da7b9230056 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:50:42 -0300 Subject: [PATCH 028/265] test: fix expected error message in envelope feature test Update expected message to match actual error message: 'Maximum number of files per envelope (2) exceeded' Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/integration/features/file/envelope.feature | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/features/file/envelope.feature b/tests/integration/features/file/envelope.feature index fae9aeff0d..c60510a327 100644 --- a/tests/integration/features/file/envelope.feature +++ b/tests/integration/features/file/envelope.feature @@ -47,8 +47,8 @@ Feature: envelope | name | Too Many Files | Then the response should have a status code 422 And the response should be a JSON array with the following mandatory values - | key | value | - | (jq).ocs.data.message | Maximum of 2 files per envelope | + | key | value | + | (jq).ocs.data.message | Maximum number of files per envelope (2) exceeded | Scenario: Successfully save single file Given as user "admin" From 1b9302bddff5236f9b98be876a4560fee3898acf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 21:59:35 -0300 Subject: [PATCH 029/265] fix: handle uploadedFile in getNodeFromData Detect and delegate uploadedFile processing to getNodeFromUploadedFile method instead of trying to extract base64 from non-existent file data. This fixes TypeError when uploading single files via file upload. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/TFile.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/Service/TFile.php b/lib/Service/TFile.php index 9da10b4f0e..1d0955506e 100644 --- a/lib/Service/TFile.php +++ b/lib/Service/TFile.php @@ -26,6 +26,11 @@ public function getNodeFromData(array $data): Node { if (!$this->folderService->getUserId()) { $this->folderService->setUserId($data['userManager']->getUID()); } + + if (isset($data['uploadedFile'])) { + return $this->getNodeFromUploadedFile($data); + } + if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) { return $data['file']['fileNode']; } From 43d9adb726c6d7b7013dccc3efa432bc082c2eb7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:00:22 -0300 Subject: [PATCH 030/265] fix: support uploadedFile in single file save flow Added handling for uploadedFile case in prepareFileForSaving method. When a single file is uploaded via file upload, it now correctly validates and creates the node using the uploadedFile data. Also fixed list endpoint to skip envelopes when loading user settings, preventing 'File not found' errors for envelope nodes. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 36 +++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index c9b8541e61..2e55ffc7a8 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -281,16 +281,25 @@ public function list( $return = $this->fileService->listAssociatedFilesOfSignFlow($page, $length, $filter, $sort); if ($user && !empty($return['data'])) { - $firstFile = $return['data'][0]; - $fileSettings = $this->fileService - ->setFileByType('FileId', $firstFile['nodeId']) - ->showSettings() - ->toArray(); + $firstFile = null; + foreach ($return['data'] as $file) { + if (($file['nodeType'] ?? 'file') !== 'envelope') { + $firstFile = $file; + break; + } + } - $return['settings'] = [ - 'needIdentificationDocuments' => $fileSettings['settings']['needIdentificationDocuments'] ?? false, - 'identificationDocumentsWaitingApproval' => $fileSettings['settings']['identificationDocumentsWaitingApproval'] ?? false, - ]; + if ($firstFile) { + $fileSettings = $this->fileService + ->setFileByType('FileId', $firstFile['nodeId']) + ->showSettings() + ->toArray(); + + $return['settings'] = [ + 'needIdentificationDocuments' => $fileSettings['settings']['needIdentificationDocuments'] ?? false, + 'identificationDocumentsWaitingApproval' => $fileSettings['settings']['identificationDocumentsWaitingApproval'] ?? false, + ]; + } } return new DataResponse($return, Http::STATUS_OK); @@ -446,6 +455,15 @@ private function prepareFileForSaving(array $fileData, string $name, array $sett if (isset($fileData['fileNode']) && $fileData['fileNode'] instanceof Node) { $node = $fileData['fileNode']; $name = $fileData['name'] ?? $name; + } elseif (isset($fileData['uploadedFile'])) { + $this->fileService->validateUploadedFile($fileData['uploadedFile']); + + $node = $this->fileService->getNodeFromData([ + 'userManager' => $this->userSession->getUser(), + 'name' => $name, + 'uploadedFile' => $fileData['uploadedFile'], + 'settings' => $settings + ]); } else { $this->validateHelper->validateNewFile([ 'file' => $fileData, From 9d8dd45c98bdd58301565a5a8a5fa49be5b7a2a7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:09:44 -0300 Subject: [PATCH 031/265] feat: expose envelope_enabled config to frontend Add envelope_enabled to initial state in PageController and TemplateLoader, allowing frontend components to respect the administrative configuration for envelope support. This enables proper control of multiple file uploads based on whether the envelope feature is enabled or disabled. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 2 ++ lib/Files/TemplateLoader.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 04d3ef795f..5e31cb6ab3 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -93,6 +93,8 @@ public function index(): TemplateResponse { $this->initialState->provideInitialState('can_request_sign', false); } + $this->initialState->provideInitialState('envelope_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)); + $this->provideSignerSignatues(); $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)); diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php index 8832fb1df3..cc40feb184 100644 --- a/lib/Files/TemplateLoader.php +++ b/lib/Files/TemplateLoader.php @@ -68,5 +68,7 @@ public function handle(Event $event): void { } catch (LibresignException) { $this->initialState->provideInitialState('can_request_sign', false); } + + $this->initialState->provideInitialState('envelope_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true)); } } From a089451f908798d594a8f318c44d6b548bb62546 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:09:55 -0300 Subject: [PATCH 032/265] feat: respect envelope_enabled config in file upload Load envelope_enabled from initial state and use it to control whether multiple file selection is allowed in the upload dialog. When envelope feature is disabled, only single file uploads are permitted. When enabled, users can select multiple files to create an envelope. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/Request/RequestPicker.vue | 40 ++++++++++++++---------- src/store/files.js | 27 ++++++++++------ 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/src/Components/Request/RequestPicker.vue b/src/Components/Request/RequestPicker.vue index 309e014661..2587a7e03d 100644 --- a/src/Components/Request/RequestPicker.vue +++ b/src/Components/Request/RequestPicker.vue @@ -94,14 +94,6 @@ import NcTextField from '@nextcloud/vue/components/NcTextField' import { useActionsMenuStore } from '../../store/actionsmenu.js' import { useFilesStore } from '../../store/files.js' -const loadFileToBase64 = file => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.readAsDataURL(file) - reader.onload = () => resolve(reader.result) - reader.onerror = (error) => reject(error) - }) -} export default { name: 'RequestPicker', components: { @@ -142,6 +134,7 @@ export default { loading: false, openedMenu: false, canRequestSign: loadState('libresign', 'can_request_sign', false), + envelopeEnabled: loadState('libresign', 'envelope_enabled', true), } }, computed: { @@ -189,13 +182,23 @@ export default { this.modalUploadFromUrl = false this.loading = false }, - async upload(file) { + async upload(files) { this.loading = true - const data = await loadFileToBase64(file) - await this.filesStore.upload({ - name: file.name.replace(/\.pdf$/i, ''), - file: data, - }) + + const formData = new FormData() + + if (files.length === 1) { + const name = files[0].name.replace(/\.pdf$/i, '') + formData.append('name', name) + formData.append('file', files[0]) + } else { + formData.append('name', '') + files.forEach((file) => { + formData.append('files[]', file) + }) + } + + await this.filesStore.upload(formData) .then((response) => { this.filesStore.addFile({ nodeId: response.id, @@ -203,6 +206,8 @@ export default { status: response.status, statusText: response.statusText, created_at: response.created_at, + ...(response.nodeType && { nodeType: response.nodeType }), + ...(response.files && { files: response.files }), }) this.filesStore.selectFile(response.id) }) @@ -216,12 +221,13 @@ export default { const input = document.createElement('input') input.accept = 'application/pdf' input.type = 'file' + input.multiple = this.envelopeEnabled input.onchange = async (ev) => { - const file = ev.target.files[0] + const files = Array.from(ev.target.files) - if (file) { - this.upload(file) + if (files.length > 0) { + await this.upload(files) } input.remove() diff --git a/src/store/files.js b/src/store/files.js index 913d121424..21538681e9 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -287,14 +287,21 @@ export const useFilesStore = function(...args) { } this.loading = false }, - async upload({ file, name }) { - const { data } = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), { - file: { base64: file }, - name, - settings: { - folderName: `requests/${Date.now().toString(16)}-${slugfy(name)}`, - }, - }) + async upload(payload) { + if (payload instanceof FormData) { + const { data } = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), payload, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return { ...data.ocs.data } + } + + const requestData = { + ...payload, + } + + const { data } = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), requestData) return { ...data.ocs.data } }, async getAllFiles(filter) { @@ -417,13 +424,13 @@ export const useFilesStore = function(...args) { }, async updateSignatureRequest({ visibleElements = [], signers = null, uuid = null, nodeId = null, status = 1, signatureFlow = null }) { const file = this.getFile() - + let flowValue = signatureFlow || file.signatureFlow if (typeof flowValue === 'number') { const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' } flowValue = flowMap[flowValue] || 'parallel' } - + const config = { url: generateOcsUrl('/apps/libresign/api/v1/request-signature'), method: uuid || file.uuid ? 'patch' : 'post', From 9dd1f20722eb62dd5b3fbd631be7864123e5ceb2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:24:28 -0300 Subject: [PATCH 033/265] fix: allow thumbnail generation for files without sign requests - Changed getThumbnail to use fileMapper->getByFileId instead of fileService->getMyLibresignFile - getMyLibresignFile depends on signRequestMapper which requires sign requests to exist - DRAFT files without signers were returning 404 on thumbnail endpoint - Now uses direct file lookup with ownership verification Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/FileController.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 2e55ffc7a8..ff9f85880b 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -340,10 +340,11 @@ public function getThumbnail( } try { - $myLibreSignFile = $this->fileService - ->setMe($this->userSession->getUser()) - ->getMyLibresignFile($nodeId); - $node = $this->accountService->getPdfByUuid($myLibreSignFile->getUuid()); + $libreSignFile = $this->fileMapper->getByFileId($nodeId); + if ($libreSignFile->getUserId() !== $this->userSession->getUser()->getUID()) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + $node = $this->accountService->getPdfByUuid($libreSignFile->getUuid()); } catch (DoesNotExistException) { return new DataResponse([], Http::STATUS_NOT_FOUND); } From 8850495cc11a45889d13607354ba2c7f83784cc3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 19 Dec 2025 22:28:10 -0300 Subject: [PATCH 034/265] feat: display folder icon for envelopes in file list - Added FolderIcon component to FileEntryPreview - Envelopes now show folder icon instead of file icon or thumbnail - Added isEnvelope computed property to check source.isEnvelope - Backend already sends isEnvelope flag in SignRequestMapper::formatListRow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/FilesList/FileEntry/FileEntryPreview.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/views/FilesList/FileEntry/FileEntryPreview.vue b/src/views/FilesList/FileEntry/FileEntryPreview.vue index d9774d7820..7ae7449bf7 100644 --- a/src/views/FilesList/FileEntry/FileEntryPreview.vue +++ b/src/views/FilesList/FileEntry/FileEntryPreview.vue @@ -5,7 +5,7 @@ -
-
- - - {{ isSignElementsAvailable() ? t('libresign', 'Setup signature positions') : t('libresign', 'Save') }} - - - - {{ t('libresign', 'Request signatures') }} - -
-
- - - {{ t('libresign', 'Sign document') }} - -
-
- - - {{ t('libresign', 'Add file to envelope') }} - - - - {{ t('libresign', 'Validation info') }} - - - - {{ t('libresign', 'Open file') }} - -
-
+ + + {{ t('libresign', 'Add signer') }} + + + + {{ t('libresign', 'Manage files ({count})', { count: envelopeFilesCount }) }} + + + + + + {{ isSignElementsAvailable() ? t('libresign', 'Setup signature positions') : t('libresign', 'Save') }} + + + + {{ t('libresign', 'Request signatures') }} + + + + + + {{ t('libresign', 'Sign document') }} + + + + + + {{ t('libresign', 'Validation info') }} + + + + {{ t('libresign', 'Open file') }} + + + + + diff --git a/src/store/files.js b/src/store/files.js index fdc39d6cdc..539488ee11 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -77,7 +77,7 @@ export const useFilesStore = function(...args) { }) this.addFile(files[this.selectedNodeId]) }, - async addFilesToEnvelope(envelopeUuid, formData) { + async addFilesToEnvelope(envelopeUuid, formData, options = {}) { return await axios.post( generateOcsUrl('/apps/libresign/api/v1/file/{uuid}/add-file', { uuid: envelopeUuid }), formData, @@ -85,6 +85,8 @@ export const useFilesStore = function(...args) { headers: { 'Content-Type': 'multipart/form-data', }, + signal: options.signal, + onUploadProgress: options.onUploadProgress, }, ) .then(({ data }) => { @@ -104,6 +106,13 @@ export const useFilesStore = function(...args) { } }) .catch((error) => { + if (error.code === 'ERR_CANCELED') { + return { + success: false, + message: 'Upload cancelled', + error, + } + } const message = error.response?.data?.ocs?.data?.message || 'Failed to add files to envelope' return { success: false, @@ -355,17 +364,29 @@ export const useFilesStore = function(...args) { } this.loading = false }, - async upload(payload) { + async upload(payload, options = {}) { let data + + const axiosConfig = {} + + if (options.onUploadProgress) { + axiosConfig.onUploadProgress = options.onUploadProgress + } + + if (options.signal) { + axiosConfig.signal = options.signal + } + if (payload instanceof FormData) { const response = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), payload, { + ...axiosConfig, headers: { 'Content-Type': 'multipart/form-data', }, }) data = response.data } else { - const response = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), payload) + const response = await axios.post(generateOcsUrl('/apps/libresign/api/v1/file'), payload, axiosConfig) data = response.data } From 149cafe30f5967dcaa0866a8b96ac2f23890186c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 20 Dec 2025 19:58:51 -0300 Subject: [PATCH 089/265] fix: use nodeId instead of file_id Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 864befbcbb..85caed9fe6 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -644,7 +644,7 @@ public function unassociateToUser(int $fileId, int $signRequestId): void { $deletedOrder = $signRequest->getSigningOrder(); $groupedIdentifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequestId); - $this->dispatchCancellationEventIfNeeded($signRequest, $file->getId(), $groupedIdentifyMethods); + $this->dispatchCancellationEventIfNeeded($signRequest, $file->getNodeId(), $groupedIdentifyMethods); try { $this->signRequestMapper->delete($signRequest); From acaaf485d952fa0ab00ba1a87087d7e7a52c96cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 20 Dec 2025 20:10:17 -0300 Subject: [PATCH 090/265] chore: add back the add signer to top and add an icon to this button Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.vue | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Components/RightSidebar/RequestSignatureTab.vue b/src/Components/RightSidebar/RequestSignatureTab.vue index 58d14d865c..accf00e22c 100644 --- a/src/Components/RightSidebar/RequestSignatureTab.vue +++ b/src/Components/RightSidebar/RequestSignatureTab.vue @@ -10,6 +10,14 @@ {{ t('libresign', 'Some signers use identification methods that have been disabled. Please remove or update them before requesting signatures.') }} + + + {{ t('libresign', 'Add signer') }} + - - - {{ t('libresign', 'Add signer') }} - - + @@ -245,6 +247,7 @@ import svgSms from '@mdi/svg/svg/message-processing.svg?raw' import svgWhatsapp from '@mdi/svg/svg/whatsapp.svg?raw' import svgXmpp from '@mdi/svg/svg/xmpp.svg?raw' +import AccountPlus from 'vue-material-design-icons/AccountPlus.vue' import Bell from 'vue-material-design-icons/Bell.vue' import ChartGantt from 'vue-material-design-icons/ChartGantt.vue' import Delete from 'vue-material-design-icons/Delete.vue' @@ -310,6 +313,7 @@ export default { name: 'RequestSignatureTab', mixins: [signingOrderMixin], components: { + AccountPlus, Bell, ChartGantt, Delete, From 6b33bc2847660f6a4806ceaf7b7df84198557630 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:31:44 -0300 Subject: [PATCH 091/265] fix: return filesCount from metadata and always return empty the files Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index e2e113b3ca..90be5055cf 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -716,6 +716,12 @@ private function loadLibreSignData(): void { $this->fileData->docmdpLevel = $this->file->getDocmdpLevel(); $this->fileData->nodeType = $this->file->getNodeType(); + if ($this->file->getNodeType() === 'envelope') { + $metadata = $this->file->getMetadata(); + $this->fileData->filesCount = $metadata['filesCount'] ?? 0; + $this->fileData->files = []; + } + $this->fileData->requested_by = [ 'userId' => $this->file->getUserId(), 'displayName' => $this->userManager->get($this->file->getUserId())->getDisplayName(), @@ -749,20 +755,15 @@ private function loadEnvelopeData(): void { return; } - $envelopeFiles = $this->fileMapper->getChildrenFiles($envelope->getId()); + $envelopeMetadata = $envelope->getMetadata(); $this->fileData->envelope = [ 'id' => $envelope->getId(), 'uuid' => $envelope->getUuid(), 'name' => $envelope->getName(), 'status' => $envelope->getStatus(), 'statusText' => $this->fileMapper->getTextOfStatus($envelope->getStatus()), - 'filesCount' => count($envelopeFiles), - 'files' => array_map(fn (File $file) => [ - 'id' => $file->getId(), - 'uuid' => $file->getUuid(), - 'name' => $file->getName(), - 'status' => $file->getStatus(), - ], $envelopeFiles), + 'filesCount' => $envelopeMetadata['filesCount'] ?? 0, + 'files' => [], ]; } From 88d9fdf2a482ab6608630d79412314c5c51ac3b1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:35:11 -0300 Subject: [PATCH 092/265] feat: return uuid of document at objet of visible element Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 90be5055cf..a824555078 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -970,6 +970,8 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers */ private function formatVisibleElementsToArray(array $visibleElements, array $metadata): array { return array_map(function (FileElement $visibleElement) use ($metadata) { + $libresignFile = $this->fileMapper->getById($visibleElement->getFileId()); + $element = [ 'elementId' => $visibleElement->getId(), 'signRequestId' => $visibleElement->getSignRequestId(), @@ -980,7 +982,8 @@ private function formatVisibleElementsToArray(array $visibleElements, array $met 'ury' => $visibleElement->getUry(), 'llx' => $visibleElement->getLlx(), 'lly' => $visibleElement->getLly() - ] + ], + 'uuid' => $libresignFile->getUuid(), ]; $dimension = $metadata['d'][$element['coordinates']['page'] - 1]; From 24b27716c22dce60e9d181f913454301ce3c5888 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 12:43:39 -0300 Subject: [PATCH 093/265] fix: add typing to return Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 1 + lib/Service/FileService.php | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 278e46be1c..f08e191fd7 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -136,6 +136,7 @@ * signRequestId: non-negative-int, * type: string, * coordinates: LibresignCoordinate, + * uuid: string, * } * @psalm-type LibresignSignatureMethod = array{ * enabled: bool, diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index a824555078..db24f88747 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -965,8 +965,8 @@ private function associateAllAndFormat(IUser $user, array $files, array $signers /** * @param FileElement[] $visibleElements - * @param array - * @return array + * @param array $metadata + * @return LibresignVisibleElement[] */ private function formatVisibleElementsToArray(array $visibleElements, array $metadata): array { return array_map(function (FileElement $visibleElement) use ($metadata) { From 90c2b0ce82caefeae251e8d6a1852aebfca5fe9f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:24:05 -0300 Subject: [PATCH 094/265] fix: normalize types of return Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 22 ++++++++++----------- lib/Service/FileService.php | 39 +++++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index f08e191fd7..b7c5786c3f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -121,19 +121,19 @@ * mandatory: non-negative-int, * } * @psalm-type LibresignCoordinate = array{ - * page?: non-negative-int, - * urx?: non-negative-int, - * ury?: non-negative-int, - * llx?: non-negative-int, - * lly?: non-negative-int, - * top?: non-negative-int, - * left?: non-negative-int, - * width?: non-negative-int, - * height?: non-negative-int, + * page?: int, + * urx?: int, + * ury?: int, + * llx?: int, + * lly?: int, + * top?: int, + * left?: int, + * width?: int, + * height?: int, * } * @psalm-type LibresignVisibleElement = array{ - * elementId: non-negative-int, - * signRequestId: non-negative-int, + * elementId: int, + * signRequestId: int, * type: string, * coordinates: LibresignCoordinate, * uuid: string, diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index db24f88747..e8ebe0f5c5 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -46,6 +46,7 @@ /** * @psalm-import-type LibresignValidateFile from ResponseDefinitions + * @psalm-import-type LibresignVisibleElement from ResponseDefinitions */ class FileService { use TFile; @@ -972,27 +973,35 @@ private function formatVisibleElementsToArray(array $visibleElements, array $met return array_map(function (FileElement $visibleElement) use ($metadata) { $libresignFile = $this->fileMapper->getById($visibleElement->getFileId()); - $element = [ + $page = $visibleElement->getPage(); + $urx = (int)$visibleElement->getUrx(); + $ury = (int)$visibleElement->getUry(); + $llx = (int)$visibleElement->getLlx(); + $lly = (int)$visibleElement->getLly(); + + $dimension = $metadata['d'][$page - 1]; + $height = abs($ury - $lly); + $width = $urx - $llx; + $top = (int)$dimension['h'] - $ury; + $left = $llx; + + return [ 'elementId' => $visibleElement->getId(), 'signRequestId' => $visibleElement->getSignRequestId(), 'type' => $visibleElement->getType(), + 'uuid' => $libresignFile->getUuid(), 'coordinates' => [ - 'page' => $visibleElement->getPage(), - 'urx' => $visibleElement->getUrx(), - 'ury' => $visibleElement->getUry(), - 'llx' => $visibleElement->getLlx(), - 'lly' => $visibleElement->getLly() + 'page' => $page, + 'urx' => $urx, + 'ury' => $ury, + 'llx' => $llx, + 'lly' => $lly, + 'left' => $left, + 'top' => $top, + 'width' => $width, + 'height' => $height, ], - 'uuid' => $libresignFile->getUuid(), ]; - $dimension = $metadata['d'][$element['coordinates']['page'] - 1]; - - $element['coordinates']['left'] = $element['coordinates']['llx']; - $element['coordinates']['height'] = abs($element['coordinates']['ury'] - $element['coordinates']['lly']); - $element['coordinates']['top'] = $dimension['h'] - $element['coordinates']['ury']; - $element['coordinates']['width'] = $element['coordinates']['urx'] - $element['coordinates']['llx']; - - return $element; }, $visibleElements); } From 727b005eed86869319a2542bf461658af7ff7353 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:24:23 -0300 Subject: [PATCH 095/265] chore: update documentation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 6 +++++- openapi.json | 6 +++++- src/types/openapi/openapi-full.ts | 1 + src/types/openapi/openapi.ts | 1 + 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/openapi-full.json b/openapi-full.json index 22ccb39f77..1656323eab 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1262,7 +1262,8 @@ "elementId", "signRequestId", "type", - "coordinates" + "coordinates", + "uuid" ], "properties": { "elementId": { @@ -1280,6 +1281,9 @@ }, "coordinates": { "$ref": "#/components/schemas/Coordinate" + }, + "uuid": { + "type": "string" } } } diff --git a/openapi.json b/openapi.json index 0e51d6b33d..65cd9122e3 100644 --- a/openapi.json +++ b/openapi.json @@ -1112,7 +1112,8 @@ "elementId", "signRequestId", "type", - "coordinates" + "coordinates", + "uuid" ], "properties": { "elementId": { @@ -1130,6 +1131,9 @@ }, "coordinates": { "$ref": "#/components/schemas/Coordinate" + }, + "uuid": { + "type": "string" } } } diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 0a05d86c59..37b29b24f7 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1845,6 +1845,7 @@ export type components = { signRequestId: number; type: string; coordinates: components["schemas"]["Coordinate"]; + uuid: string; }; }; responses: never; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 20ba81c1f8..14b0bf6966 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1367,6 +1367,7 @@ export type components = { signRequestId: number; type: string; coordinates: components["schemas"]["Coordinate"]; + uuid: string; }; }; responses: never; From 71f04ca22d6b9cd463120216fd68117a03d0218b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:39:01 -0300 Subject: [PATCH 096/265] fix: send the UUID of file to prevent save to diferent file Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 85caed9fe6..d5942d9366 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -567,8 +567,11 @@ private function saveVisibleElements(array $data, FileEntity $file): array { } $elements = $data['visibleElements']; foreach ($elements as $key => $element) { - $element['fileId'] = $file->getId(); - $elements[$key] = $this->fileElementService->saveVisibleElement($element); + if (empty($element['uuid']) && empty($element['fileId'])) { + $element['fileId'] = $file->getId(); + } + $uuid = $element['uuid'] ?? ''; + $elements[$key] = $this->fileElementService->saveVisibleElement($element, $uuid); } return $elements; } From 6721142f0ee59a5db95a81c375794a2cbedae0d6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 13:45:07 -0300 Subject: [PATCH 097/265] chore: update documentation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 33 +++++++++++---------------------- openapi.json | 33 +++++++++++---------------------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/openapi-full.json b/openapi-full.json index 1656323eab..0cdf1ce8b4 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -191,48 +191,39 @@ "properties": { "page": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "urx": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "ury": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "llx": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "lly": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "top": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "left": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "width": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "height": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" } } }, @@ -1268,13 +1259,11 @@ "properties": { "elementId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "signRequestId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "type": { "type": "string" diff --git a/openapi.json b/openapi.json index 65cd9122e3..219cb5c7e0 100644 --- a/openapi.json +++ b/openapi.json @@ -146,48 +146,39 @@ "properties": { "page": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "urx": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "ury": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "llx": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "lly": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "top": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "left": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "width": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "height": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" } } }, @@ -1118,13 +1109,11 @@ "properties": { "elementId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "signRequestId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "type": { "type": "string" From 5e0c1c948c57d3ca618f2674e250f0ff69e3af19 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 15:04:38 -0300 Subject: [PATCH 098/265] feat: add support to multiple documents when add visible elements Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/PdfEditor/PdfEditor.vue | 6 + src/Components/Request/VisibleElements.vue | 216 ++++++++++++++++++--- 2 files changed, 195 insertions(+), 27 deletions(-) diff --git a/src/Components/PdfEditor/PdfEditor.vue b/src/Components/PdfEditor/PdfEditor.vue index 39e5ff65c4..28bb9596e7 100644 --- a/src/Components/PdfEditor/PdfEditor.vue +++ b/src/Components/PdfEditor/PdfEditor.vue @@ -83,9 +83,15 @@ export default { x: signer.element.coordinates.llx, y: signer.element.coordinates.ury, } + + const docIndex = signer.element.documentIndex !== undefined + ? signer.element.documentIndex + : this.$refs.vuePdfEditor.selectedDocIndex + this.$refs.vuePdfEditor.addObjectToPage( object, signer.element.coordinates.page - 1, + docIndex, ) }, }, diff --git a/src/Components/Request/VisibleElements.vue b/src/Components/Request/VisibleElements.vue index e222f659ce..5dc0d47e84 100644 --- a/src/Components/Request/VisibleElements.vue +++ b/src/Components/Request/VisibleElements.vue @@ -54,11 +54,12 @@
-
@@ -108,6 +109,11 @@ export default { signerSelected: null, width: getCapabilities().libresign.config['sign-elements']['full-signature-width'], height: getCapabilities().libresign.config['sign-elements']['full-signature-height'], + envelopeFiles: [], + filePagesMap: {}, + envelopeFilesReady: false, + elementsLoaded: false, + loadedPdfsCount: 0, } }, computed: { @@ -126,8 +132,31 @@ export default { document() { return this.filesStore.getFile() }, + isEnvelope() { + return this.document?.nodeType === 'envelope' + }, + pdfFiles() { + if (this.isEnvelope) { + if (!this.envelopeFilesReady) return [] + return this.envelopeFiles.map(f => f.file) + } + return [this.document.file] + }, + pdfFileNames() { + if (this.isEnvelope) { + if (!this.envelopeFilesReady) return [] + return this.envelopeFiles.map(f => { + const metadata = typeof f.metadata === 'string' ? JSON.parse(f.metadata) : f.metadata + return `${f.name}.${metadata?.extension || 'pdf'}` + }) + } + return [this.documentNameWithExtension] + }, documentNameWithExtension() { const doc = this.document + if (!doc.metadata?.extension) { + return doc.name + } return `${doc.name}.${doc.metadata.extension}` }, canSign() { @@ -168,7 +197,7 @@ export default { unsubscribe('libresign:visible-elements-select-signer') }, methods: { - showModal() { + async showModal() { if (!this.canRequestSign) { return } @@ -177,17 +206,103 @@ export default { } this.modal = true this.filesStore.loading = true + + if (this.isEnvelope) { + await this.loadEnvelopeFiles() + } + + this.filesStore.loading = false + }, + buildFilePagesMap() { + if (!this.isEnvelope || this.envelopeFiles.length === 0) { + return + } + + let currentPage = 1 + this.envelopeFiles.forEach((file, index) => { + const metadata = typeof file.metadata === 'string' ? JSON.parse(file.metadata) : file.metadata + const pageCount = metadata?.p || 0 + + for (let i = 0; i < pageCount; i++) { + this.filePagesMap[currentPage + i] = { + uuid: file.uuid, + fileIndex: index, + startPage: currentPage, + fileName: file.name, + } + } + currentPage += pageCount + }) }, closeModal() { this.modal = false this.filesStore.loading = false + this.envelopeFilesReady = false + this.elementsLoaded = false + this.loadedPdfsCount = 0 + }, + async loadEnvelopeFiles() { + if (!this.document?.nodeId) { + this.filesStore.loading = false + return + } + + try { + const url = generateOcsUrl('/apps/libresign/api/v1/file/list') + const params = new URLSearchParams({ + page: '1', + length: '100', + parentNodeId: this.document.nodeId.toString(), + }) + + const { data } = await axios.get(`${url}?${params.toString()}`) + if (data.ocs?.data?.data) { + this.envelopeFiles = data.ocs.data.data + this.envelopeFilesReady = true + + this.buildFilePagesMap() + } + } catch (error) { + showError(this.$t('libresign', 'Failed to load envelope files')) + this.filesStore.loading = false + } }, updateSigners(data) { + this.loadedPdfsCount++ + + if (this.isEnvelope) { + const expectedPdfsCount = this.envelopeFiles.length + + if (this.elementsLoaded || this.loadedPdfsCount < expectedPdfsCount) { + return + } + } + this.document.signers.forEach(signer => { if (this.document.visibleElements) { Object.values(this.document.visibleElements).forEach(element => { if (element.signRequestId === signer.signRequestId) { const object = structuredClone(signer) + + if (this.isEnvelope && element.uuid) { + const fileInfo = this.envelopeFiles.find(f => f.uuid === element.uuid) + + if (fileInfo) { + for (const [page, info] of Object.entries(this.filePagesMap)) { + if (info.uuid === element.uuid) { + object.element = { + ...element, + documentIndex: info.fileIndex, + } + object.element.coordinates.ury = Math.round(data.measurement[element.coordinates.page].height) + - element.coordinates.ury + this.$refs.pdfEditor.addSigner(object) + return + } + } + } + } + element.coordinates.ury = Math.round(data.measurement[element.coordinates.page].height) - element.coordinates.ury object.element = element @@ -196,6 +311,11 @@ export default { }) } }) + + if (this.isEnvelope) { + this.elementsLoaded = true + } + this.filesStore.loading = false }, onSelectSigner(signer) { @@ -210,11 +330,25 @@ export default { }, doSelectSigner(event) { const canvasList = this.$refs.pdfEditor.$refs.vuePdfEditor.$refs.pdfBody.querySelectorAll('canvas') - const page = Array.from(canvasList).indexOf(event.target) - this.addSignerToPosition(event, page) + const canvasIndex = Array.from(canvasList).indexOf(event.target) + const globalPageNumber = canvasIndex + 1 // 1-based + + let documentIndex = 0 + let pageInDocument = globalPageNumber + + if (this.isEnvelope && this.filePagesMap[globalPageNumber]) { + const pageInfo = this.filePagesMap[globalPageNumber] + documentIndex = pageInfo.fileIndex + pageInDocument = globalPageNumber - pageInfo.startPage + 1 + console.log(`Canvas ${canvasIndex} (global page ${globalPageNumber}) → documentIndex: ${documentIndex}, page: ${pageInDocument}`) + } else { + console.log(`Canvas ${canvasIndex} → page: ${pageInDocument}`) + } + + this.addSignerToPosition(event, pageInDocument, documentIndex) this.stopAddSigner() }, - addSignerToPosition(event, page) { + addSignerToPosition(event, pageInDocument, documentIndex) { const canvas = event.target const rect = canvas.getBoundingClientRect() const scale = this.$refs.pdfEditor.$refs.vuePdfEditor.scale || 1 @@ -232,13 +366,18 @@ export default { this.signerSelected.element = { coordinates: { - page: page + 1, + page: pageInDocument, llx: normalizedX - this.width / 2, ury: normalizedY - this.height / 2, width: this.width, height: this.height, }, } + + if (this.isEnvelope && documentIndex > 0) { + this.signerSelected.element.documentIndex = documentIndex + } + this.$refs.pdfEditor.addSigner(this.signerSelected) }, stopAddSigner() { @@ -282,26 +421,49 @@ export default { }, buildVisibleElements() { const visibleElements = [] - const objects = this.$refs.pdfEditor.$refs.vuePdfEditor.getAllObjects() - - objects.forEach(object => { - if (!object.signer) return - - visibleElements.push({ - type: 'signature', - signRequestId: object.signer.signRequestId, - elementId: object.signer.element.elementId, - coordinates: { - page: object.pageNumber, - width: object.normalizedCoordinates.width, - height: object.normalizedCoordinates.height, - llx: object.normalizedCoordinates.llx, - lly: object.normalizedCoordinates.lly, - ury: object.normalizedCoordinates.ury, - urx: object.normalizedCoordinates.urx, - }, + + const numDocuments = this.isEnvelope ? this.envelopeFiles.length : 1 + + for (let docIndex = 0; docIndex < numDocuments; docIndex++) { + const objects = this.$refs.pdfEditor.$refs.vuePdfEditor.getAllObjects(docIndex) + + objects.forEach(object => { + if (!object.signer) return + + let globalPageNumber = object.pageNumber + if (this.isEnvelope && docIndex > 0) { + for (const [page, info] of Object.entries(this.filePagesMap)) { + if (info.fileIndex === docIndex) { + globalPageNumber = info.startPage + object.pageNumber - 1 + break + } + } + } + + const element = { + type: 'signature', + signRequestId: object.signer.signRequestId, + elementId: object.signer.element.elementId, + coordinates: { + page: globalPageNumber, + width: object.normalizedCoordinates.width, + height: object.normalizedCoordinates.height, + llx: object.normalizedCoordinates.llx, + lly: object.normalizedCoordinates.lly, + ury: object.normalizedCoordinates.ury, + urx: object.normalizedCoordinates.urx, + }, + } + + if (this.isEnvelope && this.filePagesMap[globalPageNumber]) { + element.uuid = this.filePagesMap[globalPageNumber].uuid + element.coordinates.page = globalPageNumber - this.filePagesMap[globalPageNumber].startPage + 1 + } + + visibleElements.push(element) }) - }) + } + return visibleElements }, }, From db222d6304dfb730d5e7ecc854e786b548fbb2ef Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:55:47 -0300 Subject: [PATCH 099/265] feat: add method to get child sign requests by envelope and identify method Add getByEnvelopeChildrenAndIdentifyMethod() method to retrieve sign requests of child files from an envelope for the same signer, matching by identify method. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/SignRequestMapper.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/Db/SignRequestMapper.php b/lib/Db/SignRequestMapper.php index 30134c614f..32de9abfd1 100644 --- a/lib/Db/SignRequestMapper.php +++ b/lib/Db/SignRequestMapper.php @@ -189,6 +189,39 @@ public function getById(int $signRequestId): SignRequest { return $signRequest; } + /** + * Get sign requests of child files from an envelope for the same signer + * + * @return SignRequest[] + */ + public function getByEnvelopeChildrenAndIdentifyMethod(int $parentFileId, int $signRequestId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('sr.*') + ->from('libresign_file', 'f') + ->innerJoin('f', $this->getTableName(), 'sr', $qb->expr()->eq('sr.file_id', 'f.id')) + ->innerJoin('sr', 'libresign_identify_method', 'im', $qb->expr()->eq('im.sign_request_id', 'sr.id')) + ->innerJoin('im', 'libresign_identify_method', 'im2', + $qb->expr()->andX( + $qb->expr()->eq('im2.sign_request_id', $qb->createNamedParameter($signRequestId, IQueryBuilder::PARAM_INT)), + $qb->expr()->eq('im2.identifier_key', 'im.identifier_key'), + $qb->expr()->eq('im2.identifier_value', 'im.identifier_value') + ) + ) + ->where( + $qb->expr()->eq('f.parent_file_id', $qb->createNamedParameter($parentFileId, IQueryBuilder::PARAM_INT)) + ); + + /** @var SignRequest[] */ + $signRequests = $this->findEntities($qb); + foreach ($signRequests as $signRequest) { + if (!isset($this->signers[$signRequest->getId()])) { + $this->signers[$signRequest->getId()] = $signRequest; + } + } + return $signRequests; + } + /** * @return \Generator */ From f97ead5b73ef9ca371c38ad62f25627af926b2a0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:55:58 -0300 Subject: [PATCH 100/265] feat: add getPdfUrlsForSigning method for envelope support - Return array of PDF URLs for signing - Handle envelopes by fetching child sign requests - Maintain single file behavior - Refactor getFileUrl() to use fileId and uuid parameters Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignFileService.php | 109 ++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 26 deletions(-) diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 0d517059b3..d67ec9662c 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -368,7 +368,11 @@ protected function validateDocMdpAllowsSignatures(): void { * @throws LibresignException */ protected function getLibreSignFileAsResource() { - $fileToSign = $this->getNextcloudFile($this->libreSignFile); + $files = $this->getNextcloudFiles($this->libreSignFile); + if (empty($files)) { + throw new LibresignException('File not found'); + } + $fileToSign = current($files); $content = $fileToSign->getContent(); $resource = fopen('php://memory', 'r+'); if ($resource === false) { @@ -834,7 +838,22 @@ public function getIdDocById(int $fileId): IdDocs { return $this->idDocsMapper->getByFileId($fileId); } - public function getNextcloudFile(FileEntity $fileData): File { + /** + * @return File[] Array of files + */ + public function getNextcloudFiles(FileEntity $fileData): array { + if ($fileData->getNodeType() === 'envelope') { + $children = $this->fileMapper->getChildrenFiles($fileData->getId()); + $files = []; + foreach ($children as $child) { + $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($child->getNodeId()); + if ($file instanceof File) { + $files[] = $file; + } + } + return $files; + } + $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($fileData->getNodeId()); if (!$fileToSign instanceof File) { throw new LibresignException(json_encode([ @@ -842,7 +861,33 @@ public function getNextcloudFile(FileEntity $fileData): File { 'errors' => [['message' => $this->l10n->t('File not found')]], ]), AppFrameworkHttp::STATUS_NOT_FOUND); } - return $fileToSign; + return [$fileToSign]; + } + + /** + * @return array + */ + public function getNextcloudFilesWithEntities(FileEntity $fileData): array { + if ($fileData->getNodeType() === 'envelope') { + $children = $this->fileMapper->getChildrenFiles($fileData->getId()); + $result = []; + foreach ($children as $child) { + $file = $this->root->getUserFolder($child->getUserId())->getFirstNodeById($child->getNodeId()); + if ($file instanceof File) { + $result[] = $child; + } + } + return $result; + } + + $fileToSign = $this->root->getUserFolder($fileData->getUserId())->getFirstNodeById($fileData->getNodeId()); + if (!$fileToSign instanceof File) { + throw new LibresignException(json_encode([ + 'action' => JSActions::ACTION_DO_NOTHING, + 'errors' => [['message' => $this->l10n->t('File not found')]], + ]), AppFrameworkHttp::STATUS_NOT_FOUND); + } + return [$fileData]; } public function validateSigner(string $uuid, ?IUser $user = null): void { @@ -874,30 +919,42 @@ public function getAvailableIdentifyMethodsFromSettings(): array { return $return; } + public function getFileUrl(int $fileId, string $uuid): string { + try { + $this->idDocsMapper->getByFileId($fileId); + return $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid]); + } catch (DoesNotExistException) { + return $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid]); + } + } + /** - * @psalm-return array{file?: File, nodeId?: int, url?: string, base64?: string} + * Get PDF URLs for signing + * For envelopes: returns URLs for all child files + * For regular files: returns URL for the file itself + * + * @return string[] */ - public function getFileUrl(string $format, FileEntity $fileEntity, File $fileToSign, string $uuid): array { - $url = []; - switch ($format) { - case 'base64': - $url = ['base64' => base64_encode($fileToSign->getContent())]; - break; - case 'url': - try { - $this->idDocsMapper->getByFileId($fileEntity->getId()); - $url = ['url' => $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $uuid])]; - } catch (DoesNotExistException) { - $url = ['url' => $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $uuid])]; - } - break; - case 'nodeId': - $url = ['nodeId' => $fileToSign->getId()]; - break; - case 'file': - $url = ['file' => $fileToSign]; - break; - } - return $url; + public function getPdfUrlsForSigning(FileEntity $fileEntity, SignRequestEntity $signRequestEntity): array { + if (!$fileEntity->isEnvelope()) { + return [ + $this->getFileUrl($fileEntity->getId(), $signRequestEntity->getUuid()) + ]; + } + + $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod( + $fileEntity->getId(), + $signRequestEntity->getId() + ); + + $pdfUrls = []; + foreach ($childSignRequests as $childSignRequest) { + $pdfUrls[] = $this->getFileUrl( + $childSignRequest->getFileId(), + $childSignRequest->getUuid() + ); + } + + return $pdfUrls; } } From 949f5d9ad935ff78261732b679e7dc7f6ad858da Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:56:05 -0300 Subject: [PATCH 101/265] refactor: change getNextcloudFile to return array - Rename getNextcloudFile() to getNextcloudFiles() - Return array of files to support envelopes - Update interface and trait implementation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/ISignatureUuid.php | 6 +++++- lib/Controller/LibresignTrait.php | 15 +++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/Controller/ISignatureUuid.php b/lib/Controller/ISignatureUuid.php index 2825efd62d..66c7862d02 100644 --- a/lib/Controller/ISignatureUuid.php +++ b/lib/Controller/ISignatureUuid.php @@ -18,5 +18,9 @@ public function validateRenewSigner(string $uuid): void; public function loadNextcloudFileFromSignRequestUuid(string $uuid): void; public function getSignRequestEntity(): ?SignRequestEntity; public function getFileEntity(): ?FileEntity; - public function getNextcloudFile(): ?File; + + /** + * @return File[] + */ + public function getNextcloudFiles(): array; } diff --git a/lib/Controller/LibresignTrait.php b/lib/Controller/LibresignTrait.php index 238725912a..3b646aa614 100644 --- a/lib/Controller/LibresignTrait.php +++ b/lib/Controller/LibresignTrait.php @@ -25,7 +25,6 @@ trait LibresignTrait { protected IUserSession $userSession; private ?SignRequestEntity $signRequestEntity = null; private ?FileEntity $fileEntity = null; - private ?File $nextcloudFile = null; /** * @throws LibresignException @@ -54,7 +53,6 @@ private function loadEntitiesFromUuid(string $uuid): void { public function validateSignRequestUuid(string $uuid): void { $this->loadEntitiesFromUuid($uuid); $this->signFileService->validateSigner($uuid, $this->userSession->getUser()); - $this->nextcloudFile = $this->signFileService->getNextcloudFile($this->fileEntity); } /** @@ -63,7 +61,6 @@ public function validateSignRequestUuid(string $uuid): void { public function validateRenewSigner(string $uuid): void { $this->loadEntitiesFromUuid($uuid); $this->signFileService->validateRenewSigner($uuid, $this->userSession->getUser()); - $this->nextcloudFile = $this->signFileService->getNextcloudFile($this->fileEntity); } /** @@ -71,7 +68,6 @@ public function validateRenewSigner(string $uuid): void { */ public function loadNextcloudFileFromSignRequestUuid(string $uuid): void { $this->loadEntitiesFromUuid($uuid); - $this->nextcloudFile = $this->signFileService->getNextcloudFile($this->fileEntity); } public function getSignRequestEntity(): ?SignRequestEntity { @@ -82,7 +78,14 @@ public function getFileEntity(): ?FileEntity { return $this->fileEntity; } - public function getNextcloudFile(): ?File { - return $this->nextcloudFile; + /** + * @return File[] Array of files, empty if no file entity loaded + */ + public function getNextcloudFiles(): array { + if (!$this->fileEntity) { + return []; + } + + return $this->signFileService->getNextcloudFiles($this->fileEntity); } } From 9d6b0d7a0bf9fd2b9784353bafc8baacebfa7cfb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:56:14 -0300 Subject: [PATCH 102/265] feat: delegate PDF URL generation to service layer - Add getPdfUrls() method in PageController - Delegate to SignFileService::getPdfUrlsForSigning() - Provide pdfs array to initial state instead of single pdf - Adapt getPdfFile() to use array response Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 34 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 04d3ef795f..2e71b1c1ca 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -329,9 +329,7 @@ public function sign(string $uuid): TemplateResponse { $this->provideSignerSignatues(); $this->initialState->provideInitialState('token_length', TokenService::TOKEN_LENGTH); $this->initialState->provideInitialState('description', $this->getSignRequestEntity()->getDescription() ?? ''); - $this->initialState->provideInitialState('pdf', - $this->signFileService->getFileUrl('url', $this->getFileEntity(), $this->getNextcloudFile(), $uuid) - ); + $this->initialState->provideInitialState('pdfs', $this->getPdfUrls()); $this->initialState->provideInitialState('nodeId', $this->getFileEntity()->getNodeId()); Util::addScript(Application::APP_ID, 'libresign-external'); @@ -354,6 +352,16 @@ private function provideSignerSignatues(): void { $this->initialState->provideInitialState('user_signatures', $signatures); } + /** + * @return string[] Array of PDF URLs + */ + private function getPdfUrls(): array { + return $this->signFileService->getPdfUrlsForSigning( + $this->getFileEntity(), + $this->getSignRequestEntity() + ); + } + /** * Show signature page * @@ -409,10 +417,9 @@ public function signIdDoc($uuid): TemplateResponse { $this->initialState->provideInitialState('signature_methods', $signatureMethods); $this->initialState->provideInitialState('token_length', TokenService::TOKEN_LENGTH); $this->initialState->provideInitialState('description', ''); - $nextcloudFile = $this->signFileService->getNextcloudFile($fileEntity); - $this->initialState->provideInitialState('pdf', - $this->signFileService->getFileUrl('url', $fileEntity, $nextcloudFile, $uuid) - ); + $this->initialState->provideInitialState('pdf', [ + 'url' => $this->signFileService->getFileUrl($fileEntity->getId(), $uuid) + ]); Util::addScript(Application::APP_ID, 'libresign-external'); $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE); @@ -469,7 +476,14 @@ public function getPdf($uuid) { #[AnonRateLimit(limit: 30, period: 60)] #[FrontpageRoute(verb: 'GET', url: '/pdf/{uuid}')] public function getPdfFile($uuid): FileDisplayResponse { - $file = $this->getNextcloudFile(); + $files = $this->getNextcloudFiles(); + if (empty($files)) { + throw new LibresignException(json_encode([ + 'action' => JSActions::ACTION_DO_NOTHING, + 'errors' => [['message' => $this->l10n->t('File not found')]], + ]), Http::STATUS_NOT_FOUND); + } + $file = current($files); return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => $file->getMimeType()]); } @@ -498,9 +512,7 @@ public function validation(): TemplateResponse { 'description' => $this->getSignRequestEntity()?->getDescription(), ]); $this->initialState->provideInitialState('filename', $this->getFileEntity()?->getName()); - $this->initialState->provideInitialState('pdf', - $this->signFileService->getFileUrl('url', $this->getFileEntity(), $this->getNextcloudFile(), $this->request->getParam('uuid')) - ); + $this->initialState->provideInitialState('pdfs', $this->getPdfUrls()); $this->initialState->provideInitialState('signer', $this->signFileService->getSignerData( $this->userSession->getUser(), From 47c1a36e400ca50ec73261067e2b769f3dde8cf4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:56:21 -0300 Subject: [PATCH 103/265] feat: validate all files in envelope before signing Check file existence for all child files when validating envelope signature. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../IdentifyMethod/AbstractIdentifyMethod.php | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php index e5e64a5c4a..ada688ed42 100644 --- a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php @@ -153,14 +153,36 @@ protected function throwIfFileNotFound(): void { $signRequest = $this->identifyService->getSignRequestMapper()->getById($this->getEntity()->getSignRequestId()); $fileEntity = $this->identifyService->getFileMapper()->getById($signRequest->getFileId()); - $nodeId = $fileEntity->getNodeId(); + $filesToCheck = []; + + if ($fileEntity->getNodeType() === 'envelope') { + $children = $this->identifyService->getFileMapper()->getChildrenFiles($fileEntity->getId()); + foreach ($children as $child) { + $filesToCheck[] = [ + 'nodeId' => $child->getNodeId(), + 'userId' => $child->getUserId(), + 'name' => $child->getName(), + ]; + } + } else { + $filesToCheck[] = [ + 'nodeId' => $fileEntity->getNodeId(), + 'userId' => $fileEntity->getUserId(), + 'name' => $fileEntity->getName(), + ]; + } - $fileToSign = $this->identifyService->getRootFolder()->getUserFolder($fileEntity->getUserId())->getFirstNodeById($nodeId); - if (!$fileToSign instanceof \OCP\Files\File) { - throw new LibresignException(json_encode([ - 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->identifyService->getL10n()->t('File not found')]], - ])); + foreach ($filesToCheck as $fileInfo) { + $fileToSign = $this->identifyService->getRootFolder() + ->getUserFolder($fileInfo['userId']) + ->getFirstNodeById($fileInfo['nodeId']); + + if (!$fileToSign instanceof \OCP\Files\File) { + throw new LibresignException(json_encode([ + 'action' => JSActions::ACTION_DO_NOTHING, + 'errors' => [['message' => $this->identifyService->getL10n()->t('File not found')]], + ])); + } } } From 06848a0acb807ab02a00dbc4618bf6fc8c866b9d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:56:30 -0300 Subject: [PATCH 104/265] feat: add multi-PDF support in SignPDF component - Load multiple PDFs from initial state or store - Add loadEnvelopePdfs() to fetch envelope children via API - Add loadPdfsFromStore() for private access path - Handle both envelope and single file scenarios Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/SignPDF/SignPDF.vue | 101 ++++++++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 24 deletions(-) diff --git a/src/views/SignPDF/SignPDF.vue b/src/views/SignPDF/SignPDF.vue index 30f5ef42d8..7128a15192 100644 --- a/src/views/SignPDF/SignPDF.vue +++ b/src/views/SignPDF/SignPDF.vue @@ -7,12 +7,12 @@ -
@@ -40,6 +40,7 @@ import NcButton from '@nextcloud/vue/components/NcButton' import PdfEditor from '../../Components/PdfEditor/PdfEditor.vue' import TopBar from '../../Components/TopBar/TopBar.vue' +import { loadState } from '@nextcloud/initial-state' import { useFilesStore } from '../../store/files.js' import { useSidebarStore } from '../../store/sidebar.js' import { useSignStore } from '../../store/sign.js' @@ -66,13 +67,14 @@ export default { data() { return { mounted: false, - pdfBlob: null, + pdfBlobs: [], } }, computed: { pdfFileName() { const doc = this.signStore.document - return `${doc.name}.${doc.metadata.extension}` + const extension = doc.metadata?.extension || 'pdf' + return `${doc.name}.${extension}` }, }, async created() { @@ -87,6 +89,14 @@ export default { if (this.isMobile){ this.toggleSidebar(); } + + const pdfs = loadState('libresign', 'pdfs', []) + if (pdfs.length > 0) { + await this.handleInitialStatePdfs(pdfs) + } else { + await this.loadPdfsFromStore() + } + this.mounted = true }, beforeRouteLeave(to, from, next) { this.sidebarStore.hideSidebar() @@ -98,8 +108,6 @@ export default { if (!this.signStore.document.uuid) { this.signStore.document.uuid = this.$route.params.uuid } - await this.fetchPdfAsBlob(this.signStore.document.url) - this.mounted = true }, async initSignInternal() { const files = await this.fileStore.getAllFiles({ @@ -108,10 +116,8 @@ export default { for (const nodeId in files) { const signer = files[nodeId].signers.find(row => row.me) || {} if (Object.keys(signer).length > 0) { - this.signStore.setDocumentToSign(files[nodeId]) + this.signStore.setFileToSign(files[nodeId]) this.fileStore.selectedNodeId = nodeId - await this.fetchPdfAsBlob(this.signStore.document.url) - this.mounted = true return } } @@ -120,27 +126,74 @@ export default { const response = await axios.get( generateOcsUrl('/apps/libresign/api/v1/file/validate/uuid/{uuid}', { uuid: this.$route.params.uuid }) ) - this.signStore.setDocumentToSign(response.data.ocs.data) + this.signStore.setFileToSign(response.data.ocs.data) this.fileStore.selectedNodeId = response.data.ocs.data.nodeId - await this.fetchPdfAsBlob(this.signStore.document.url) - this.mounted = true }, - async fetchPdfAsBlob(url) { - const response = await fetch(url) - const contentType = response.headers.get('Content-Type') || '' + async handleInitialStatePdfs(urls) { + if (!Array.isArray(urls) || urls.length === 0) { + return + } + + const blobs = [] + for (const url of urls) { + const response = await fetch(url) + const contentType = response.headers.get('Content-Type') || '' - if (contentType.includes('application/json')) { - const data = await response.json() - this.sidebarStore.hideSidebar() - if (data?.errors?.[0]?.message.length > 0) { - this.signStore.errors = data.errors + if (contentType.includes('application/json')) { + const data = await response.json() + this.sidebarStore.hideSidebar() + if (data?.errors?.[0]?.message.length > 0) { + this.signStore.errors = data.errors + } else { + this.signStore.errors = [{ message: t('libresign', 'File not found') }] + } return } - this.signStore.errors = [{ message: t('libresign', 'File not found') }] + + const blob = await response.blob() + blobs.push(new File([blob], 'arquivo.pdf', { type: 'application/pdf' })) + } + + this.pdfBlobs = blobs + }, + async loadPdfsFromStore() { + const doc = this.signStore.document + + if (!doc || !doc.nodeId) { + this.signStore.errors = [{ message: t('libresign', 'Document not found') }] return } - const blob = await response.blob() - this.pdfBlob = new File([blob], 'arquivo.pdf', { type: 'application/pdf' }) + + // Check if it's an envelope + if (doc.nodeType === 'envelope') { + await this.loadEnvelopePdfs(doc.nodeId) + } else if (doc.url) { + // Single document + await this.handleInitialStatePdfs([doc.url]) + } else { + this.signStore.errors = [{ message: t('libresign', 'Document URL not found') }] + } + }, + async loadEnvelopePdfs(parentNodeId) { + try { + const url = generateOcsUrl('/apps/libresign/api/v1/file/list') + const params = new URLSearchParams({ + page: '1', + length: '100', + parentNodeId: parentNodeId.toString(), + signer_uuid: this.$route.params.uuid, + }) + + const { data } = await axios.get(`${url}?${params.toString()}`) + if (data.ocs?.data?.data) { + const urls = data.ocs.data.data.map(file => file.file) + await this.handleInitialStatePdfs(urls) + } else { + this.signStore.errors = [{ message: t('libresign', 'Failed to load envelope files') }] + } + } catch (error) { + this.signStore.errors = [{ message: t('libresign', 'Failed to load envelope files') }] + } }, updateSigners(data) { const currentSigner = this.signStore.document.signers.find(signer => signer.me) From 8538d991db977a291e868fb0220cb8bf2757cf17 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:56:37 -0300 Subject: [PATCH 105/265] refactor: rename setDocumentToSign to setFileToSign Improve semantics to work for both single files and envelopes. Update all call sites across the codebase. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.vue | 2 +- src/store/sign.js | 14 +++++++------- src/views/FilesList/FileEntry/FileEntryActions.vue | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Components/RightSidebar/RequestSignatureTab.vue b/src/Components/RightSidebar/RequestSignatureTab.vue index accf00e22c..24f5d7121e 100644 --- a/src/Components/RightSidebar/RequestSignatureTab.vue +++ b/src/Components/RightSidebar/RequestSignatureTab.vue @@ -903,7 +903,7 @@ export default { this.modalSrc = route.href return } - this.signStore.setDocumentToSign(this.filesStore.getFile()) + this.signStore.setFileToSign(this.filesStore.getFile()) this.$router.push({ name: 'SignPDF', params: { uuid } }) }, diff --git a/src/store/sign.js b/src/store/sign.js index 1f786dd981..270e7ec729 100644 --- a/src/store/sign.js +++ b/src/store/sign.js @@ -21,6 +21,7 @@ const defaultState = { statusText: '', url: '', nodeId: 0, + nodeType: 'file', uuid: '', signers: [], }, @@ -33,32 +34,31 @@ export const useSignStore = defineStore('sign', { actions: { initFromState() { this.errors = loadState('libresign', 'errors', []) - const pdf = loadState('libresign', 'pdf', []) + const file = { name: loadState('libresign', 'filename', ''), description: loadState('libresign', 'description', ''), status: loadState('libresign', 'status', ''), statusText: loadState('libresign', 'statusText', ''), - url: pdf.url, nodeId: loadState('libresign', 'nodeId', 0), uuid: loadState('libresign', 'uuid', null), signers: loadState('libresign', 'signers', []), } - this.setDocumentToSign(file) + this.setFileToSign(file) const filesStore = useFilesStore() filesStore.addFile(file) filesStore.selectedNodeId = file.nodeId }, - setDocumentToSign(document) { - if (document) { + setFileToSign(file) { + if (file) { this.errors = [] - set(this, 'document', document) + set(this, 'document', file) const sidebarStore = useSidebarStore() sidebarStore.activeSignTab() const signMethodsStore = useSignMethodsStore() - const signer = document.signers.find(row => row.me) || {} + const signer = file.signers.find(row => row.me) || {} signMethodsStore.settings = signer.signatureMethods return } diff --git a/src/views/FilesList/FileEntry/FileEntryActions.vue b/src/views/FilesList/FileEntry/FileEntryActions.vue index 6d74cd7611..326233492e 100644 --- a/src/views/FilesList/FileEntry/FileEntryActions.vue +++ b/src/views/FilesList/FileEntry/FileEntryActions.vue @@ -191,7 +191,7 @@ export default { signer_uuid: signUuid, force_fetch: true, }) - this.signStore.setDocumentToSign(files[this.source.nodeId]) + this.signStore.setFileToSign(files[this.source.nodeId]) this.$router.push({ name: 'SignPDF', params: { From fac4e76e4078bcf0baa4947d5be2643e741ff9bd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:11:26 -0300 Subject: [PATCH 106/265] chore: make compatible with sign multiple files Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignFileService.php | 104 +++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 8 deletions(-) diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index d67ec9662c..29ca920eb0 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -320,18 +320,106 @@ public function getVisibleElements(): array { return $this->elements; } - public function sign(): File { - $this->validateDocMdpAllowsSignatures(); - $signedFile = $this->getEngine()->sign(); + public function sign(): void { + $originalLibreSignFile = $this->libreSignFile; + $originalSignRequest = $this->signRequest; - $hash = $this->computeHash($signedFile); + $signRequests = $this->getSignRequestsToSign(); - $this->updateSignRequest($hash); - $this->updateLibreSignFile($hash); + if (empty($signRequests)) { + throw new LibresignException('No sign requests found to process'); + } + + foreach ($signRequests as $signRequestData) { + $this->libreSignFile = $signRequestData['file']; + $this->signRequest = $signRequestData['signRequest']; + $this->engine = null; + $this->elements = []; + + $this->validateDocMdpAllowsSignatures(); + $signedFile = $this->getEngine()->sign(); + + $hash = $this->computeHash($signedFile); + + $this->updateSignRequest($hash); + $this->updateLibreSignFile($hash); + + $this->dispatchSignedEvent(); + } + + $this->libreSignFile = $originalLibreSignFile; + $this->signRequest = $originalSignRequest; + + if ($originalLibreSignFile->isEnvelope()) { + $this->updateEnvelopeStatus(); + } + } + + /** + * @return array Array of ['file' => FileEntity, 'signRequest' => SignRequestEntity] + */ + private function getSignRequestsToSign(): array { + if (!$this->libreSignFile->isEnvelope()) { + return [[ + 'file' => $this->libreSignFile, + 'signRequest' => $this->signRequest, + ]]; + } - $this->dispatchSignedEvent(); + $childFiles = $this->fileMapper->getChildrenFiles($this->libreSignFile->getId()); + + if (empty($childFiles)) { + throw new LibresignException('No files found in envelope'); + } + + $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod( + $this->libreSignFile->getId(), + $this->signRequest->getId() + ); - return $signedFile; + if (empty($childSignRequests)) { + throw new LibresignException('No sign requests found for envelope files'); + } + + $signRequestsData = []; + foreach ($childSignRequests as $childSignRequest) { + $childFile = $this->array_find( + $childFiles, + fn(FileEntity $file) => $file->getId() === $childSignRequest->getFileId() + ); + + if ($childFile) { + $signRequestsData[] = [ + 'file' => $childFile, + 'signRequest' => $childSignRequest, + ]; + } + } + + return $signRequestsData; + } + + private function updateEnvelopeStatus(): void { + $childFiles = $this->fileMapper->getChildrenFiles($this->libreSignFile->getId()); + + $allSigned = true; + $anySigned = false; + + foreach ($childFiles as $childFile) { + if ($childFile->getStatus() === FileEntity::STATUS_SIGNED) { + $anySigned = true; + } else { + $allSigned = false; + } + } + + if ($allSigned) { + $this->libreSignFile->setStatus(FileEntity::STATUS_SIGNED); + } elseif ($anySigned) { + $this->libreSignFile->setStatus(FileEntity::STATUS_PARTIAL_SIGNED); + } + + $this->fileMapper->update($this->libreSignFile); } /** From 0f3d7f3464bc5b64d344fa250cebd111bce6f72a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 22 Dec 2025 22:16:35 -0300 Subject: [PATCH 107/265] fix: cs Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignFileService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 29ca920eb0..e7802933e0 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -385,7 +385,7 @@ private function getSignRequestsToSign(): array { foreach ($childSignRequests as $childSignRequest) { $childFile = $this->array_find( $childFiles, - fn(FileEntity $file) => $file->getId() === $childSignRequest->getFileId() + fn (FileEntity $file) => $file->getId() === $childSignRequest->getFileId() ); if ($childFile) { From abd68ccbc5f7500744b34461149e1a3b037c96c9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:49:33 -0300 Subject: [PATCH 108/265] fix(FileService): clear mapper cache for fresh nodeType read Fixes envelope detection returning stale cached entity with old node_type='file' value when database has 'envelope'. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/FileService.php | 206 +++++++++++++++++++++++++++++++++++- 1 file changed, 205 insertions(+), 1 deletion(-) diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index e8ebe0f5c5..3d63cb3936 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -447,6 +447,7 @@ private function loadLibreSignSigners(): void { if ($this->identifyMethodId === $entity->getId() || $this->me?->getUID() === $entity->getIdentifierValue() || $this->me?->getEMailAddress() === $entity->getIdentifierValue() + || ($this->signRequest && $signer->getId() === $this->signRequest->getId()) ) { $this->fileData->signers[$index]['me'] = true; if (!$signer->getSigned()) { @@ -716,11 +717,34 @@ private function loadLibreSignData(): void { $this->fileData->signatureFlow = $this->file->getSignatureFlow(); $this->fileData->docmdpLevel = $this->file->getDocmdpLevel(); $this->fileData->nodeType = $this->file->getNodeType(); + $this->file = $this->fileMapper->getById($this->file->getId()); - if ($this->file->getNodeType() === 'envelope') { + if ($this->fileData->nodeType !== 'envelope' && !$this->file->getParentFileId()) { + $fileId = $this->file->getId(); + + $childrenFiles = $this->fileMapper->getChildrenFiles($fileId); + + if (!empty($childrenFiles)) { + $this->file->setNodeType('envelope'); + $this->fileMapper->update($this->file); + + $this->fileData->nodeType = 'envelope'; + $this->fileData->filesCount = count($childrenFiles); + $this->fileData->files = []; + } + } + + if ($this->fileData->nodeType === 'envelope') { $metadata = $this->file->getMetadata(); $this->fileData->filesCount = $metadata['filesCount'] ?? 0; $this->fileData->files = []; + $this->loadEnvelopeFiles(); + if ($this->file->getStatus() === File::STATUS_SIGNED) { + $latestSignedDate = $this->getLatestSignedDateFromEnvelope(); + if ($latestSignedDate) { + $this->fileData->signedDate = $latestSignedDate->format(DateTimeInterface::ATOM); + } + } } $this->fileData->requested_by = [ @@ -746,6 +770,110 @@ private function loadLibreSignData(): void { } } + private function getLatestSignedDateFromEnvelope(): ?\DateTime { + if (!$this->file || $this->file->getNodeType() !== 'envelope') { + return null; + } + + $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId()); + $latestDate = null; + + foreach ($childrenFiles as $childFile) { + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + foreach ($signRequests as $signRequest) { + $signed = $signRequest->getSigned(); + if ($signed && (!$latestDate || $signed > $latestDate)) { + $latestDate = $signed; + } + } + } + + return $latestDate; + } + + private function loadEnvelopeFiles(): void { + if (!$this->file || $this->file->getNodeType() !== 'envelope') { + return; + } + + $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId()); + foreach ($childrenFiles as $childFile) { + // Create a new FileService instance for each child file to load complete validation data + $childFileService = \OCP\Server::get(self::class); + + try { + // Load complete file data including validation info + $childFileService + ->setFile($childFile) + ->setHost($this->host) + ->showValidateFile() + ->showSigners(); + + $childData = $childFileService->toArray(); + + // Extract relevant fields for envelope display + $fileData = [ + 'id' => $childFile->getId(), + 'uuid' => $childFile->getUuid(), + 'name' => $childFile->getName(), + 'status' => $childFile->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($childFile->getStatus()), + 'nodeId' => $childFile->getNodeId(), + 'signers' => $childData['signers'] ?? [], + ]; + + $this->fileData->files[] = $fileData; + } catch (\Throwable $e) { + + $fileData = [ + 'id' => $childFile->getId(), + 'uuid' => $childFile->getUuid(), + 'name' => $childFile->getName(), + 'status' => $childFile->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($childFile->getStatus()), + 'nodeId' => $childFile->getNodeId(), + 'signers' => [], + ]; + + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + foreach ($signRequests as $signRequest) { + $identifyMethods = $this->identifyMethodService + ->setIsRequest(false) + ->getIdentifyMethodsFromSignRequestId($signRequest->getId()); + + $signerData = [ + 'signRequestId' => $signRequest->getId(), + 'displayName' => $signRequest->getDisplayName(), + 'email' => '', + 'signed' => null, + 'status' => $signRequest->getStatus(), + 'statusText' => $this->signRequestMapper->getTextOfSignerStatus($signRequest->getStatus()), + ]; + + foreach ($identifyMethods[IdentifyMethodService::IDENTIFY_EMAIL] ?? [] as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + if ($entity->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { + $signerData['email'] = $entity->getIdentifierValue(); + break; + } + } + + if ($signRequest->getSigned()) { + $signerData['signed'] = $signRequest->getSigned()->format(DateTimeInterface::ATOM); + } + + if (empty($signerData['displayName'])) { + $signerData['displayName'] = $signerData['email']; + } + + $fileData['signers'][] = $signerData; + } + + $this->fileData->files[] = $fileData; + } + } + } + private function loadEnvelopeData(): void { if (!$this->file->hasParent()) { return; @@ -802,11 +930,87 @@ public function toArray(): array { $this->loadSettings(); $this->loadSigners(); $this->loadMessages(); + $this->computeEnvelopeSignersProgress(); $return = json_decode(json_encode($this->fileData), true); ksort($return); return $return; } + private function computeEnvelopeSignersProgress(): void { + if (!$this->file || !$this->file->getParentFileId()) { + return; + } + if (empty($this->fileData->signers)) { + return; + } + + $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getParentFileId()); + if (empty($childrenFiles)) { + return; + } + + $signerProgress = []; + foreach ($childrenFiles as $childFile) { + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + foreach ($signRequests as $signRequest) { + $signRequestId = $signRequest->getId(); + + $identifyMethods = $this->identifyMethodService + ->setIsRequest(false) + ->getIdentifyMethodsFromSignRequestId($signRequestId); + + $signerKey = $this->buildSignerKey($identifyMethods); + + if (!isset($signerProgress[$signerKey])) { + $signerProgress[$signerKey] = [ + 'total' => 0, + 'signed' => 0, + ]; + } + + $signerProgress[$signerKey]['total']++; + if ($signRequest->getSigned()) { + $signerProgress[$signerKey]['signed']++; + } + } + } + + foreach ($this->fileData->signers as $index => $signer) { + $signerKey = $this->buildSignerKeyFromEnvelopeSigner($signer); + if (isset($signerProgress[$signerKey])) { + $this->fileData->signers[$index]['totalDocuments'] = $signerProgress[$signerKey]['total']; + $this->fileData->signers[$index]['documentsSignedCount'] = $signerProgress[$signerKey]['signed']; + } else { + $this->fileData->signers[$index]['totalDocuments'] = 0; + $this->fileData->signers[$index]['documentsSignedCount'] = 0; + } + } + } + + private function buildSignerKey(array $identifyMethods): string { + $keys = []; + foreach ($identifyMethods as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + $keys[] = $entity->getIdentifierKey() . ':' . $entity->getIdentifierValue(); + } + } + sort($keys); + return implode('|', $keys); + } + + private function buildSignerKeyFromEnvelopeSigner(array $signer): string { + if (empty($signer['identifyMethods'])) { + return ''; + } + $keys = []; + foreach ($signer['identifyMethods'] as $method) { + $keys[] = $method['method'] . ':' . $method['value']; + } + sort($keys); + return implode('|', $keys); + } + public function setFileByPath(string $path): self { $node = $this->folderService->getFileByPath($path); $this->setFileByType('FileId', $node->getId()); From eff0ef0c20085468dc22106275bed0600dcd75cb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:01 -0300 Subject: [PATCH 109/265] refactor(PageController): adjust envelope validation logic Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 44 ++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 2e71b1c1ca..f9d71f87fc 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -9,6 +9,8 @@ namespace OCA\Libresign\Controller; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Helper\ValidateHelper; @@ -43,6 +45,7 @@ use OCP\IURLGenerator; use OCP\IUserSession; use OCP\Util; +use Psr\Log\LoggerInterface; class PageController extends AEnvironmentPageAwareController { public function __construct( @@ -58,6 +61,9 @@ public function __construct( private IdentifyMethodService $identifyMethodService, private IAppConfig $appConfig, private FileService $fileService, + private FileMapper $fileMapper, + private SignRequestMapper $signRequestMapper, + private LoggerInterface $logger, private ValidateHelper $validateHelper, private IEventDispatcher $eventDispatcher, private IURLGenerator $urlGenerator, @@ -329,8 +335,15 @@ public function sign(string $uuid): TemplateResponse { $this->provideSignerSignatues(); $this->initialState->provideInitialState('token_length', TokenService::TOKEN_LENGTH); $this->initialState->provideInitialState('description', $this->getSignRequestEntity()->getDescription() ?? ''); - $this->initialState->provideInitialState('pdfs', $this->getPdfUrls()); + if ($this->getFileEntity()->getNodeType() === 'envelope') { + $this->initialState->provideInitialState('pdfs', []); + $this->initialState->provideInitialState('envelopeFiles', $this->getEnvelopeChildFiles()); + } else { + $this->initialState->provideInitialState('pdfs', $this->getPdfUrls()); + $this->initialState->provideInitialState('envelopeFiles', []); + } $this->initialState->provideInitialState('nodeId', $this->getFileEntity()->getNodeId()); + $this->initialState->provideInitialState('nodeType', $this->getFileEntity()->getNodeType()); Util::addScript(Application::APP_ID, 'libresign-external'); $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE); @@ -362,10 +375,35 @@ private function getPdfUrls(): array { ); } + private function getEnvelopeChildFiles(): array { + $childFiles = $this->fileMapper->getChildrenFiles($this->getFileEntity()->getId()); + $result = []; + + foreach ($childFiles as $childFile) { + + $childSignRequest = $this->signRequestMapper->getByFileIdAndSignRequestId( + $childFile->getId(), + $this->getSignRequestEntity()->getId() + ); + + $fileData = $this->fileService + ->setFile($childFile) + ->setHost($this->request->getServerHost()) + ->setSignerIdentified() + ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId()) + ->setSignRequest($childSignRequest) + ->showSigners() + ->toArray(); + + $result[] = $fileData; + } + + return $result; + } + /** - * Show signature page + * Show signature page for identification document approval * - * @param string $uuid Sign request uuid * @return TemplateResponse * * 200: OK From 0a201b1404fb9048e11ec0adfaaeeaa749d86e3e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:01 -0300 Subject: [PATCH 110/265] refactor(Pkcs12Handler): update signature handling Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Handler/SignEngine/Pkcs12Handler.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Handler/SignEngine/Pkcs12Handler.php b/lib/Handler/SignEngine/Pkcs12Handler.php index d5040814ec..f73e266714 100644 --- a/lib/Handler/SignEngine/Pkcs12Handler.php +++ b/lib/Handler/SignEngine/Pkcs12Handler.php @@ -136,7 +136,8 @@ private function applyLibreSignRootCAFlag(array $signer): array { } foreach ($signer['chain'] as $key => $cert) { - if ($cert['isLibreSignRootCA'] + if ($cert['isLibreSignRootCA'] + && isset($cert['certificate_validation']) && $cert['certificate_validation']['id'] !== 1 ) { $signer['chain'][$key]['certificate_validation'] = [ From 143fcd720e576da306d5cc1dfdb810c1a51a37bc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:01 -0300 Subject: [PATCH 111/265] refactor(MailNotifyListener): adjust notification flow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Listener/MailNotifyListener.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Listener/MailNotifyListener.php b/lib/Listener/MailNotifyListener.php index db07391552..bb237c68eb 100644 --- a/lib/Listener/MailNotifyListener.php +++ b/lib/Listener/MailNotifyListener.php @@ -105,6 +105,9 @@ protected function sendSignedMailNotification( IUser $user, ): void { try { + if ($libreSignFile->hasParent()) { + return; + } if ($identifyMethod->getEntity()->isDeletedAccount()) { return; } From 893e17b7a3777401fb709825cf3b76a3c087c7a5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:18 -0300 Subject: [PATCH 112/265] refactor(IdentifyService): update identification method Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../IdentifyMethod/IdentifyService.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/lib/Service/IdentifyMethod/IdentifyService.php b/lib/Service/IdentifyMethod/IdentifyService.php index cf43e98a24..1111dee882 100644 --- a/lib/Service/IdentifyMethod/IdentifyService.php +++ b/lib/Service/IdentifyMethod/IdentifyService.php @@ -47,12 +47,50 @@ public function save(IdentifyMethod $identifyMethod): void { $this->refreshIdFromDatabaseIfNecessary($identifyMethod); if ($identifyMethod->getId()) { $this->identifyMethodMapper->update($identifyMethod); + $this->propagateIdentifiedDateToEnvelopeChildren($identifyMethod); return; } $this->identifyMethodMapper->insertOrUpdate($identifyMethod); + $this->propagateIdentifiedDateToEnvelopeChildren($identifyMethod); return; } + private function propagateIdentifiedDateToEnvelopeChildren(IdentifyMethod $identifyMethod): void { + if (!$identifyMethod->getIdentifiedAtDate()) { + return; + } + + if (!$identifyMethod->getSignRequestId()) { + return; + } + + $signRequest = $this->signRequestMapper->getById($identifyMethod->getSignRequestId()); + $fileEntity = $this->fileMapper->getById($signRequest->getFileId()); + + if (method_exists($fileEntity, 'getNodeType') && $fileEntity->getNodeType() !== 'envelope') { + return; + } + + $children = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod( + $fileEntity->getId(), + $signRequest->getId(), + ); + + foreach ($children as $childSignRequest) { + $childMethods = $this->identifyMethodMapper->getIdentifyMethodsFromSignRequestId($childSignRequest->getId()); + + foreach ($childMethods as $childEntity) { + if ( + $childEntity->getIdentifierKey() === $identifyMethod->getIdentifierKey() + && $childEntity->getIdentifierValue() === $identifyMethod->getIdentifierValue() + ) { + $childEntity->setIdentifiedAtDate($identifyMethod->getIdentifiedAtDate()); + $this->identifyMethodMapper->update($childEntity); + } + } + } + } + public function delete(IdentifyMethod $identifyMethod): void { if ($identifyMethod->getId()) { $this->identifyMethodMapper->delete($identifyMethod); From 40cca8105361651deb045635b07eae99c974313e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:18 -0300 Subject: [PATCH 113/265] refactor(SignFileService): adjust file signing logic Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/SignFileService.php | 65 +++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index e7802933e0..0ca37e3f74 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -323,6 +323,8 @@ public function getVisibleElements(): array { public function sign(): void { $originalLibreSignFile = $this->libreSignFile; $originalSignRequest = $this->signRequest; + $envelopeLastSignedDate = null; + $lastSignedFile = null; $signRequests = $this->getSignRequestsToSign(); @@ -335,14 +337,17 @@ public function sign(): void { $this->signRequest = $signRequestData['signRequest']; $this->engine = null; $this->elements = []; + $this->fileToSign = null; $this->validateDocMdpAllowsSignatures(); $signedFile = $this->getEngine()->sign(); + $lastSignedFile = $signedFile; $hash = $this->computeHash($signedFile); + $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate(); $this->updateSignRequest($hash); - $this->updateLibreSignFile($hash); + $this->updateLibreSignFile($signedFile->getId(), $hash); $this->dispatchSignedEvent(); } @@ -351,7 +356,27 @@ public function sign(): void { $this->signRequest = $originalSignRequest; if ($originalLibreSignFile->isEnvelope()) { + if ($envelopeLastSignedDate) { + $this->signRequest->setSigned($envelopeLastSignedDate); + $this->signRequest->setStatusEnum(\OCA\Libresign\Enum\SignRequestStatus::SIGNED); + $this->signRequestMapper->update($this->signRequest); + $this->sequentialSigningService + ->setFile($this->libreSignFile) + ->releaseNextOrder( + $this->signRequest->getFileId(), + $this->signRequest->getSigningOrder() + ); + } $this->updateEnvelopeStatus(); + + if ($lastSignedFile instanceof File) { + $event = $this->signedEventFactory->make( + $this->signRequest, + $this->libreSignFile, + $lastSignedFile, + ); + $this->eventDispatcher->dispatchTyped($event); + } } } @@ -402,20 +427,27 @@ private function getSignRequestsToSign(): array { private function updateEnvelopeStatus(): void { $childFiles = $this->fileMapper->getChildrenFiles($this->libreSignFile->getId()); - $allSigned = true; - $anySigned = false; + $totalSignRequests = 0; + $signedSignRequests = 0; foreach ($childFiles as $childFile) { - if ($childFile->getStatus() === FileEntity::STATUS_SIGNED) { - $anySigned = true; - } else { - $allSigned = false; + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + $totalSignRequests += count($signRequests); + + foreach ($signRequests as $signRequest) { + if ($signRequest->getSigned()) { + $signedSignRequests++; + } } } - if ($allSigned) { + if ($totalSignRequests === 0) { + $this->libreSignFile->setStatus(FileEntity::STATUS_DRAFT); + } elseif ($signedSignRequests === 0) { + $this->libreSignFile->setStatus(FileEntity::STATUS_ABLE_TO_SIGN); + } elseif ($signedSignRequests === $totalSignRequests) { $this->libreSignFile->setStatus(FileEntity::STATUS_SIGNED); - } elseif ($anySigned) { + } else { $this->libreSignFile->setStatus(FileEntity::STATUS_PARTIAL_SIGNED); } @@ -491,8 +523,7 @@ protected function updateSignRequest(string $hash): void { ); } - protected function updateLibreSignFile(string $hash): void { - $nodeId = $this->getEngine()->getInputFile()->getId(); + protected function updateLibreSignFile(int $nodeId, string $hash): void { $this->libreSignFile->setSignedNodeId($nodeId); $this->libreSignFile->setSignedHash($hash); $this->setNewStatusIfNecessary(); @@ -773,7 +804,17 @@ public function requestCode( public function getSignRequestToSign(FileEntity $libresignFile, ?string $signRequestUuid, ?IUser $user): SignRequestEntity { $this->validateHelper->fileCanBeSigned($libresignFile); try { - $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId()); + if ($libresignFile->isEnvelope()) { + $childFiles = $this->fileMapper->getChildrenFiles($libresignFile->getId()); + $allSignRequests = []; + foreach ($childFiles as $childFile) { + $childSignRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + $allSignRequests = array_merge($allSignRequests, $childSignRequests); + } + $signRequests = $allSignRequests; + } else { + $signRequests = $this->signRequestMapper->getByFileId($libresignFile->getId()); + } if (!empty($signRequestUuid)) { $signRequest = $this->getSignRequestByUuid($signRequestUuid); From 338d446a016999343a4f53f1fe54dfd96ee5003a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:38 -0300 Subject: [PATCH 114/265] refactor(VisibleElements): update component structure Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/Request/VisibleElements.vue | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Components/Request/VisibleElements.vue b/src/Components/Request/VisibleElements.vue index 5dc0d47e84..2386b7469f 100644 --- a/src/Components/Request/VisibleElements.vue +++ b/src/Components/Request/VisibleElements.vue @@ -340,9 +340,6 @@ export default { const pageInfo = this.filePagesMap[globalPageNumber] documentIndex = pageInfo.fileIndex pageInDocument = globalPageNumber - pageInfo.startPage + 1 - console.log(`Canvas ${canvasIndex} (global page ${globalPageNumber}) → documentIndex: ${documentIndex}, page: ${pageInDocument}`) - } else { - console.log(`Canvas ${canvasIndex} → page: ${pageInDocument}`) } this.addSignerToPosition(event, pageInDocument, documentIndex) From 9826150a399c375049ba33dedc7440743bacc122 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:38 -0300 Subject: [PATCH 115/265] refactor(RequestSignatureTab): adjust sidebar layout Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/RightSidebar/RequestSignatureTab.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/RightSidebar/RequestSignatureTab.vue b/src/Components/RightSidebar/RequestSignatureTab.vue index 24f5d7121e..06e9c07ff7 100644 --- a/src/Components/RightSidebar/RequestSignatureTab.vue +++ b/src/Components/RightSidebar/RequestSignatureTab.vue @@ -948,7 +948,6 @@ export default { mime: 'application/pdf', fileid: file.nodeId, } - console.table(fileInfo) OCA.Viewer.open({ fileInfo, list: [fileInfo], From 164a950c9e05b2dae00eac63526f5a93bddf7f28 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:45 -0300 Subject: [PATCH 116/265] feat(validation): add EnvelopeValidation component Add new Vue component to display envelope validation with per-document signer progress and metadata. Component handles: - Multiple documents within an envelope - Individual document signer progress - Document metadata display - Certificate chain validation per document Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../validation/CertificateChain.vue | 186 +++++++ .../validation/EnvelopeValidation.vue | 377 ++++++++++++++ src/components/validation/FileValidation.vue | 479 ++++++++++++++++++ src/components/validation/SignerDetails.vue | 428 ++++++++++++++++ src/components/validation/SignersList.vue | 123 +++++ 5 files changed, 1593 insertions(+) create mode 100644 src/components/validation/CertificateChain.vue create mode 100644 src/components/validation/EnvelopeValidation.vue create mode 100644 src/components/validation/FileValidation.vue create mode 100644 src/components/validation/SignerDetails.vue create mode 100644 src/components/validation/SignersList.vue diff --git a/src/components/validation/CertificateChain.vue b/src/components/validation/CertificateChain.vue new file mode 100644 index 0000000000..b1d8b27c66 --- /dev/null +++ b/src/components/validation/CertificateChain.vue @@ -0,0 +1,186 @@ + + + + + + diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue new file mode 100644 index 0000000000..7ea0579ac7 --- /dev/null +++ b/src/components/validation/EnvelopeValidation.vue @@ -0,0 +1,377 @@ + + + + + + diff --git a/src/components/validation/FileValidation.vue b/src/components/validation/FileValidation.vue new file mode 100644 index 0000000000..c480dc53c6 --- /dev/null +++ b/src/components/validation/FileValidation.vue @@ -0,0 +1,479 @@ + + + + + + diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue new file mode 100644 index 0000000000..773fe07047 --- /dev/null +++ b/src/components/validation/SignerDetails.vue @@ -0,0 +1,428 @@ + + + + + + diff --git a/src/components/validation/SignersList.vue b/src/components/validation/SignersList.vue new file mode 100644 index 0000000000..833ae23773 --- /dev/null +++ b/src/components/validation/SignersList.vue @@ -0,0 +1,123 @@ + + + + + + From c47cbdd2a2ca12e556a216f772fd995bb411d86c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:53 -0300 Subject: [PATCH 117/265] refactor(store/sign): update state management Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/store/sign.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/store/sign.js b/src/store/sign.js index 270e7ec729..e9d766afa3 100644 --- a/src/store/sign.js +++ b/src/store/sign.js @@ -41,6 +41,7 @@ export const useSignStore = defineStore('sign', { status: loadState('libresign', 'status', ''), statusText: loadState('libresign', 'statusText', ''), nodeId: loadState('libresign', 'nodeId', 0), + nodeType: loadState('libresign', 'nodeType', ''), uuid: loadState('libresign', 'uuid', null), signers: loadState('libresign', 'signers', []), } @@ -59,7 +60,9 @@ export const useSignStore = defineStore('sign', { const signMethodsStore = useSignMethodsStore() const signer = file.signers.find(row => row.me) || {} - signMethodsStore.settings = signer.signatureMethods + + signMethodsStore.settings = signer.signatureMethods || {} + return } this.reset() From 880faa8fe42af6de72b100087bdc0c45c1457ee7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:50:53 -0300 Subject: [PATCH 118/265] refactor(store/signMethods): adjust methods store Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/store/signMethods.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/store/signMethods.js b/src/store/signMethods.js index 18b2694bb9..0675e330ef 100644 --- a/src/store/signMethods.js +++ b/src/store/signMethods.js @@ -19,7 +19,7 @@ export const useSignMethodsStore = defineStore('signMethods', { sms: false, uploadCertificate: false, }, - settings: [], + settings: {}, certificateEngine: loadState('libresign', 'certificate_engine', ''), }), actions: { From ddec35dd66dcc88d18430b488961b3aec472fcd9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:51:03 -0300 Subject: [PATCH 119/265] feat(Validation): integrate EnvelopeValidation component Update Validation view to conditionally render EnvelopeValidation for envelope-type files. Changes: - Import and register EnvelopeValidation component - Add conditional rendering based on nodeType - Maintain backward compatibility with single-file validation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/Validation.vue | 703 ++++----------------------------------- 1 file changed, 57 insertions(+), 646 deletions(-) diff --git a/src/views/Validation.vue b/src/views/Validation.vue index f461436b68..8354848c1c 100644 --- a/src/views/Validation.vue +++ b/src/views/Validation.vue @@ -8,27 +8,21 @@
-
+

{{ t('libresign', 'Validate signature') }}

{{ validationErrorMessage }} - - + + {{ t('libresign', 'From UUID') }} - + {{ t('libresign', 'Upload') }} -

{{ t('libresign', 'Validate signature') }}

+ :helper-text="helperTextValidation" :error="!!uuidToValidate && !canValidate" /> -
-

{{ t('libresign', 'Signatories of this document') }}

-
    - -
+
+
@@ -180,6 +178,7 @@ import { import Moment from '@nextcloud/moment' import { fileStatus } from '../../helpers/fileStatus.js' import SignerDetails from './SignerDetails.vue' +import DocumentValidationDetails from './DocumentValidationDetails.vue' export default { name: 'EnvelopeValidation', @@ -192,6 +191,7 @@ export default { NcNoteCard, NcRichText, SignerDetails, + DocumentValidationDetails, }, props: { document: { type: Object, required: true }, @@ -248,9 +248,21 @@ export default { return n('libresign', '{progress} of {total} document signed', '{progress} of {total} documents signed', total, { progress, total }) }, viewFile(file) { - if (file.uuid) { - // Navigate to the validation view for the specific file - window.location.href = generateUrl(`/apps/libresign/validation/${file.uuid}`) + if (OCA?.Viewer !== undefined) { + const fileUrl = generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: file.uuid }) + const fileInfo = { + source: fileUrl, + basename: file.name, + mime: 'application/pdf', + fileid: file.nodeId, + } + OCA.Viewer.open({ + fileInfo, + list: [fileInfo], + }) + } else { + const fileUrl = generateUrl('/apps/libresign/p/pdf/{uuid}', { uuid: file.uuid }) + window.open(`${fileUrl}?_t=${Date.now()}`) } }, }, diff --git a/src/components/validation/FileValidation.vue b/src/components/validation/FileValidation.vue index c480dc53c6..0fdfd552ab 100644 --- a/src/components/validation/FileValidation.vue +++ b/src/components/validation/FileValidation.vue @@ -4,7 +4,6 @@ --> @@ -391,89 +77,5 @@ export default { margin: 0; } } - - ul { - list-style: none; - padding: 0; - margin: 0; - - &.signers > li { - margin-bottom: 12px; - } - } - - .extra { - padding-left: 16px; - } - - .extra-chain { - padding-left: 32px; - } - - .info-document { - display: flex; - flex-direction: column; - gap: 16px; - margin-top: 16px; - - .legal-information { - padding: 12px; - background-color: var(--color-background-hover); - border-radius: var(--border-radius-large); - } - } - - .certificate-item { - .cert-details { - display: flex; - flex-direction: column; - gap: 4px; - - .cert-issuer { - color: var(--color-text-maxcontrast); - } - - .serial-hex { - color: var(--color-text-maxcontrast); - font-size: 0.9em; - } - } - } - - .extension-value { - word-break: break-all; - } -} - -.icon-success { - color: var(--color-success); -} - -.icon-error { - color: var(--color-error); -} - -.icon-warning { - color: var(--color-warning); -} - -.icon-default { - color: var(--color-text-maxcontrast); -} - -@media (max-width: 768px) { - .section { - .header h1 { - font-size: 18px; - } - - .extra { - padding-left: 8px; - } - - .extra-chain { - padding-left: 16px; - } - } } From 871d25dc4bbb7812a5842a415f31e702b0a620f2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:06:31 -0300 Subject: [PATCH 236/265] fix(validation): render DocMDP status using status constants Treat allowed modifications as success; avoid magic numbers by relying on status values. Align icon and color mapping. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/SignerDetails.vue | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue index 773fe07047..e63a16327f 100644 --- a/src/components/validation/SignerDetails.vue +++ b/src/components/validation/SignerDetails.vue @@ -300,6 +300,9 @@ export default { validationStatusOpen: false, docMdpOpen: false, chainOpen: false, + MODIFICATION_UNMODIFIED: 1, + MODIFICATION_ALLOWED: 2, + MODIFICATION_VIOLATION: 3, crlStatusMap: { CRL_VERIFIED_VALID: { icon: mdiCheckCircle, text: t('libresign', 'CRL: Certificate Valid'), class: 'icon-success' }, CRL_VERIFIED_REVOKED: { icon: mdiCloseCircle, text: t('libresign', 'CRL: Certificate Revoked'), class: 'icon-error' }, @@ -371,12 +374,19 @@ export default { }, getModificationStatusIcon(signer) { if (!signer.modification_validation) return null - if (signer.modification_validation.id === 1) return mdiCheckCircle + const status = signer.modification_validation.status + if (status === this.MODIFICATION_UNMODIFIED || status === this.MODIFICATION_ALLOWED) { + return mdiCheckCircle + } return mdiAlertCircle }, getModificationStatusClass(signer) { if (!signer.modification_validation) return '' - return signer.modification_validation.id === 1 ? 'icon-success' : 'icon-error' + const status = signer.modification_validation.status + if (status === this.MODIFICATION_UNMODIFIED || status === this.MODIFICATION_ALLOWED) { + return 'icon-success' + } + return 'icon-error' }, dateFromSqlAnsi(date) { if (!date) return '' From 9e5cd4c6b822b1bedf57c3477976889ba6105ada Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:06:53 -0300 Subject: [PATCH 237/265] chore: update documentation Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 13 +++++++++++++ openapi.json | 13 +++++++++++++ src/types/openapi/openapi-full.ts | 5 +++++ src/types/openapi/openapi.ts | 5 +++++ 4 files changed, 36 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 05d38264b7..6d17cab9e9 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -285,6 +285,19 @@ "type": "integer", "format": "int64" }, + "totalPages": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "pdfVersion": { + "type": "string" + }, "signers": { "type": "array", "items": { diff --git a/openapi.json b/openapi.json index 43e0ceb947..e6493e427f 100644 --- a/openapi.json +++ b/openapi.json @@ -215,6 +215,19 @@ "type": "integer", "format": "int64" }, + "totalPages": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "pdfVersion": { + "type": "string" + }, "signers": { "type": "array", "items": { diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index a58a2f00ee..e271654512 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1541,6 +1541,11 @@ export type components = { statusText: string; /** Format: int64 */ nodeId: number; + /** Format: int64 */ + totalPages?: number; + /** Format: int64 */ + size?: number; + pdfVersion?: string; signers: components["schemas"]["EnvelopeChildSignerSummary"][]; metadata?: components["schemas"]["ValidateMetadata"]; }; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 29181942ab..17e6b31d80 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1085,6 +1085,11 @@ export type components = { statusText: string; /** Format: int64 */ nodeId: number; + /** Format: int64 */ + totalPages?: number; + /** Format: int64 */ + size?: number; + pdfVersion?: string; signers: components["schemas"]["EnvelopeChildSignerSummary"][]; metadata?: components["schemas"]["ValidateMetadata"]; }; From 39abf2869a21eb601f2c4b645a9e008f93aefce6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:10:44 -0300 Subject: [PATCH 238/265] style(validation): align single-file card UI with envelope view Apply envelope-style card background, padding, radius, and shadow to FileValidation section for consistent appearance. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/FileValidation.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/validation/FileValidation.vue b/src/components/validation/FileValidation.vue index 0fdfd552ab..39515785c0 100644 --- a/src/components/validation/FileValidation.vue +++ b/src/components/validation/FileValidation.vue @@ -63,13 +63,17 @@ export default { From 74884270b2e564f5b7174add9f6cb0cf16aa161a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:35:50 -0300 Subject: [PATCH 239/265] style(validation): encapsulate NcListItem wrapper normalization in DocumentValidationDetails Neutralize internal list-item negative margins to prevent horizontal overflow inside cards; ensure proper border radius and box-sizing for consistent layout. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/components/validation/DocumentValidationDetails.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/validation/DocumentValidationDetails.vue b/src/components/validation/DocumentValidationDetails.vue index b9cf626ebf..31c3c5d67a 100644 --- a/src/components/validation/DocumentValidationDetails.vue +++ b/src/components/validation/DocumentValidationDetails.vue @@ -154,5 +154,12 @@ export default { border-radius: var(--border-radius-large); } } + + :deep(.list-item__wrapper) { + margin-left: 0; + margin-right: 0; + border-radius: 8px; + box-sizing: border-box; + } } From 45f713f2ae02be74f86291aac37978673191aaf7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sun, 28 Dec 2025 22:35:59 -0300 Subject: [PATCH 240/265] style(envelope): normalize NcListItem layout in sections and prevent clipping Add card-list-context to sections and adjust list-item wrapper margins; allow document-item overflow for expanded details; minor comment cleanup. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../validation/EnvelopeValidation.vue | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue index 3a723ee4f8..fdeff07789 100644 --- a/src/components/validation/EnvelopeValidation.vue +++ b/src/components/validation/EnvelopeValidation.vue @@ -5,7 +5,7 @@ + + + +
From b4803d9bc8f740d46544c6c1c650a24e6eb56ebb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:24:51 -0300 Subject: [PATCH 245/265] feat(api): add name parameter to PATCH request-signature endpoint Add optional 'name' parameter to updateSign() method allowing envelope name updates via PATCH requests. Includes PHPDoc documentation for the new parameter. This enables updating envelope names without requiring signers to be provided in the request. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/RequestSignatureController.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index 88110a271a..0deafbfe15 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -135,6 +135,7 @@ public function request( * @param LibresignNewFile|array|null $file File object. * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration + * @param string|null $name The name of file to sign * @return DataResponse|DataResponse}, array{}> * * 200: OK @@ -151,6 +152,7 @@ public function updateSign( ?array $file = [], ?int $status = null, ?string $signatureFlow = null, + ?string $name = null, ): DataResponse { $user = $this->userSession->getUser(); $data = [ @@ -161,6 +163,7 @@ public function updateSign( 'status' => $status, 'visibleElements' => $visibleElements, 'signatureFlow' => $signatureFlow, + 'name' => $name, ]; try { $this->validateHelper->validateExistingFile($data); From 3643bce58cfec62c30622bcb82342c3f8a1928b1 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:25:06 -0300 Subject: [PATCH 246/265] fix(validation): skip signer validation when users array is empty Make validateIdentifySigners() return early if users array is empty, allowing PATCH requests to update envelope properties (like name) without requiring signers to be provided. This prevents 'No signers' validation errors when updating envelope metadata without modifying the signer list. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Helper/ValidateHelper.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index 5a3b269f47..2ff3c34293 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -510,6 +510,10 @@ public function validateFileStatus(array $data): void { } public function validateIdentifySigners(array $data): void { + if (empty($data['users'])) { + return; + } + $this->validateSignersDataStructure($data); foreach ($data['users'] as $signer) { From 275e93b488e4b819383f5cdab30dffdb45efca7c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:25:30 -0300 Subject: [PATCH 247/265] feat(service): implement envelope name update in saveFile method Add support for updating envelope name when uuid is provided and name is present in the request data. Updates the file entity and persists the change to the database. This allows existing envelopes to be renamed via PATCH requests without affecting their status or signers. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 4a44d24a3b..f6c8aa256b 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -280,6 +280,10 @@ public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { $file = $this->fileMapper->getByUuid($data['uuid']); $this->updateSignatureFlowIfAllowed($file, $data); + if (!empty($data['name'])) { + $file->setName($data['name']); + $this->fileMapper->update($file); + } return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } $fileId = null; From 79e1f83cd40315e25735eac210fbd9af108c6de0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:26:10 -0300 Subject: [PATCH 248/265] feat(ui): create reusable EditNameDialog component Create standalone dialog component for editing names with: - Character counter (0-255 chars) - Min/max length validation (3-255 chars) - Success/error message display - Disabled save button for invalid input - Generic and reusable for various use cases The component encapsulates all validation logic, styling, and event handling, allowing reuse across RequestPicker and other parts of the application. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/Common/EditNameDialog.vue | 205 +++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/Components/Common/EditNameDialog.vue diff --git a/src/Components/Common/EditNameDialog.vue b/src/Components/Common/EditNameDialog.vue new file mode 100644 index 0000000000..9980a9af30 --- /dev/null +++ b/src/Components/Common/EditNameDialog.vue @@ -0,0 +1,205 @@ + + + + + + From 012948a24dd884ef83e1b5cf26666eabdd0783d9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:26:26 -0300 Subject: [PATCH 249/265] refactor(ui): use EditNameDialog in RequestPicker for envelope naming Replace inline NcDialog form with reusable EditNameDialog component: - Removes duplicate code (~17 lines) - Adds consistent validation (3-255 chars) - Improves UX with character counter and validation feedback - Simplifies confirmEnvelopeName to receive name as parameter Maintains all existing functionality while improving code maintainability and user experience. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/Components/Request/RequestPicker.vue | 38 ++++++++---------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/Components/Request/RequestPicker.vue b/src/Components/Request/RequestPicker.vue index 1782e04472..c5e208b112 100644 --- a/src/Components/Request/RequestPicker.vue +++ b/src/Components/Request/RequestPicker.vue @@ -74,27 +74,15 @@
- - - - +
@@ -71,4 +147,14 @@ button.files-list__row-name-link { background-color: unset !important; } } + +.files-list__row-rename { + display: contents; + + :deep(input) { + padding: 4px 8px; + border: 2px solid var(--color-primary-element); + border-radius: var(--border-radius-large); + } +} From 65a6b731e8d54506ab0a6fd35d57187f024290e5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:53:30 -0300 Subject: [PATCH 254/265] feat(ui): add rename action to FileEntryActions menu - Import pencil-outline icon (mdi-pencil-outline) - Register 'rename' action in mounted hook - Add rename action handler in onActionClick to emit 'start-rename' event - Implement doRename(newName) method that calls filesStore.rename() - Add 'rename' and 'start-rename' to component emits Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../FilesList/FileEntry/FileEntryActions.vue | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/views/FilesList/FileEntry/FileEntryActions.vue b/src/views/FilesList/FileEntry/FileEntryActions.vue index e08e3c804f..0f74c18d73 100644 --- a/src/views/FilesList/FileEntry/FileEntryActions.vue +++ b/src/views/FilesList/FileEntry/FileEntryActions.vue @@ -58,6 +58,7 @@ From 0e4bafd9afda24c66bb063178f723eed959147e9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 00:53:46 -0300 Subject: [PATCH 255/265] feat(ui): connect rename events and manage rename state in FileEntry - Add renamingSaving state to show spinner only during API request - Listen to 'rename' event from FileEntryName and trigger doRename() - Listen to 'renaming' event from FileEntryName to track editing state - Listen to 'start-rename' event from FileEntryActions to activate form - Close rename form after successful save with stopRenaming() - Show success notification with renamed filename information - Update checkbox loading state to include renamingSaving indicator Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/FilesList/FileEntry/FileEntry.vue | 39 +++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/views/FilesList/FileEntry/FileEntry.vue b/src/views/FilesList/FileEntry/FileEntry.vue index e68345b16e..de8bafbd90 100644 --- a/src/views/FilesList/FileEntry/FileEntry.vue +++ b/src/views/FilesList/FileEntry/FileEntry.vue @@ -6,7 +6,7 @@ - + :extension="fileExtension" + @rename="onRename" + @renaming="onFileRenaming" /> @@ -22,7 +24,9 @@ :class="`files-list__row-actions-${source.id}`" :opened.sync="openedMenu" :source="source" - :loading="loading" /> + :loading="loading" + @rename="onRename" + @start-rename="onStartRename" /> From 8e14a317325e339b9733ba7a153a246f4330b8cd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Mon, 29 Dec 2025 01:20:43 -0300 Subject: [PATCH 256/265] feat(fileslist): right-click menu at cursor with clean reset\n\n- Position NcActions using CSS vars on .app-content (non-scoped transform)\n- Constrain popper with boundaries/container to .app-content > .files-list\n- Clear --mouse-pos-x/y on NcActions @closed only when no menu open\n- Close previous menu and reopen on nextTick to avoid stale content\n\nfix(actions): hide "Open file" for envelopes; DRY file lookup\n\n- Do not show "Open file" when source.nodeType is envelope\n- Add computed file and refactor visibleIf to reuse it\n\nMatches Nextcloud Files behavior and prevents flicker/jump. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../FilesList/FileEntry/FileEntryActions.vue | 32 ++++++++++++++----- .../FilesList/FileEntry/FileEntryMixin.js | 12 ++++--- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/views/FilesList/FileEntry/FileEntryActions.vue b/src/views/FilesList/FileEntry/FileEntryActions.vue index 0f74c18d73..1b1e851fa5 100644 --- a/src/views/FilesList/FileEntry/FileEntryActions.vue +++ b/src/views/FilesList/FileEntry/FileEntryActions.vue @@ -6,10 +6,13 @@ + @close="openedMenu = null" + @closed="onMenuClosed"> this.visibleIf(action)) }, + file() { + return this.filesStore.files[this.source.id] + }, + boundariesElement() { + return document.querySelector('.app-content > .files-list') + }, }, mounted() { this.registerAction({ @@ -170,18 +179,17 @@ export default { }, methods: { visibleIf(action) { - const file = this.filesStore.files[this.source.id] let visible = false if (action.id === 'rename') { visible = true } else if (action.id === 'sign') { - visible = this.filesStore.canSign(file) + visible = this.filesStore.canSign(this.file) } else if (action.id === 'validate') { - visible = this.filesStore.canValidate(file) + visible = this.filesStore.canValidate(this.file) } else if (action.id === 'delete') { - visible = this.filesStore.canDelete(file) + visible = this.filesStore.canDelete(this.file) } else if (action.id === 'open') { - visible = true + visible = this.source?.nodeType !== 'envelope' } return visible }, @@ -250,14 +258,22 @@ export default { doRename(newName) { return this.filesStore.rename(this.source.uuid, newName) }, + onMenuClosed() { + if (this.actionsMenuStore.opened === null) { + const root = this.$el?.closest('.app-content') + if (root) { + root.style.removeProperty('--mouse-pos-x') + root.style.removeProperty('--mouse-pos-y') + } + } + }, }, }