From 3dfcf58bff229c67d57172974e5071a6a6dbd748 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 450c7126ba4bf1676530611eba6134f4193d2dd3 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 c0d7e618dd565a9430bb6a3542626538ce01ca4c 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 f71f3d2c10ee29c7a80233d487c0444be49ed215 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 9d100a892d9b45b15e72907d35b3ca090b150261 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 8b2642d4f884fc8764d166882c3606902f69d36c 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 e684ac45d5670b9b4fde4b26b4ffb85efe9117e8 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 3e23e2942067fa0f00c50fff21e605f86484eb7f 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 33df7c722f5e0cd5517fd6c867fb46205ba352af 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 f8e993fc8b..aae3b5a41b 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 { @@ -114,6 +116,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, @@ -138,6 +141,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 016859be079ad4aa66f1ae28c256aa15ff978e49 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 3bf6f82595b627ad1f555a78b6740fbf87d73bef 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 | 25 +------------------------ 2 files changed, 2 insertions(+), 24 deletions(-) diff --git a/psalm.xml b/psalm.xml index d34d237308..5a52f1b2d7 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,7 @@ + diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 834ea76535..9b1fb648fc 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -140,8 +140,6 @@ newUserMail]]> newUserMail]]> - - @@ -168,27 +166,6 @@ - - - - - - - - - - - - - - - - - - - - - From 7c2a27ed0492deca47f75d594d7c9669e3c34056 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 ded431fa9ceccfb24f1866f4fc05a1259b335dce 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 3bd44ac67bbd7518f2b0f1e2bb5e483eaa0b3f7d 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 76f9e8af9317dfcb5a5bb1d6eb281506c81968e6 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 cc8faa0f78fd3f7a6d22d3e493bb882ded24f8eb 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 1374c7d83de9d9359d91dd20058b035365e5f682 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 167f765ba93b90aeb58883f170a0fa3b2d7b8c12 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 d7084035df390a32d72d2352b979725a07b61b37 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 6725fe1404dc01477dbbcb8c3a3efed710413f3f 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 aae3b5a41b..e2e7fd2b9a 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 { // Disable lazy objects to avoid PHP 8.4 dependency injection issues in tests @@ -117,6 +119,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, @@ -142,6 +145,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 e7e5cb26b2ea821a5439494eda0d2ec2b0822ef6 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 55205db6538bfeafefca0c24b32e75688e47144b 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 e2e7fd2b9a..239bb3c56d 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 { // Disable lazy objects to avoid PHP 8.4 dependency injection issues in tests From f3c54c370add883a3e7f2e119ccdf21b67e77395 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 22eea4c1ba2b76aaccfeabd9963ce80bd070d464 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 4516c1c558a7527df4af5763dda8d63baec92ae2 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 6ed6629a83404db6f88c19e624d7bd4174ba8827 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 383cbe7d376f448768031fdbf19878ba71bcb4c7 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 75f3213dd8b1f842bc3344ce38c3ddcac01d09fc 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 61a2952089130cbb4f1d378d9fbdf4024db3e134 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 8da1f0a36157b3e2b502883de62305ff5665691d 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 fd19eb29741be7b0375a79d8b9ddc552973d6eab 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 1f6488a668278cabc472824800796b11375caf5b 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 5ec46571d2c5461aae13835ce08ecc1fc90bf23c 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 3eb6c2d7e56504b11fa6fcc600f0ff7f94ff1f2e 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 d56bfccca62c4546e44027434035b911512814f3 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 419c7b14e9d20077017223d093d77633bb58b13f 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 9ca320f68a5dc74d558b144901de207023dd838f 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 12e4f73adcbb181864e9a1dafbc01314b739d7db 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 b762529b2cb81d7c90cbf6f10bdfd5ef162bbd52 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 2282235c5d224b0f5b8242ccc63212396e7a289d 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 2e80086bf2098f35818ee7afcb92bd08a2490a3d 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 d9742bba780110d1e9d0dd089445f2d3f3b4be84 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 d50f41f96b5ad13dc65b5a3e8065a3afd5c2d642 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 3f11b39bd2ac327f3ee2606d32118e6a9d262988 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 690271369e1605bb4b4d2f8ddaaf2a4081c07ea1 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 c6a7cc8357bcf73b778f9879f09b9c74670a0e30 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 a33751d2dd3f6ee9d7c7ef7c0c5dfe91b4ea34bb 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 be177b8555f7f960161fb16219ac958d6ca9215c 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 29204c516159c278cfb6190e8ae38d3fc4bb5dda 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 ef9a0d1b47fd6854d720d9cc354dfc4f0090b1e2 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 b09abc670ac6d69db424dbbee447c523d91906c2 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 508346cb702f758a60173d950014decb2cfbc5e4 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 ba2ee7a605c611e6b950ffabf3328546aeaa764e 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 ce9c07cc724e2d0a6e3e4e821953f54f499001b9 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 d5b8752085732da194ee90353ffb972cf9a41c3f 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 bf5e214176c32b5357cd2bc736689825473e00f5 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 6c0d40bd0320b36260285c35b09b13e689858ffb 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 e7d808db4a184b2bc42e7b6d3fef1d5600dbaeec 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 612f1194231d7c9a4e757ec9eefcecc495fa417a 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 abbb5f27a0f4318d66f5508c784a4adfbbf40df3 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 49a6e83083d06097f94a0edb931320dfddf5e94b 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 7e253470b24503da18e3796932362260ef02c1b6 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 e1eb3581d181b15a1633a093f7cdd542100f1e81 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 8f4756c08f50f23c6ef56daa43567f9c1fd7a160 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 faf5e89c9d7b1909b7363202ecea811a23dbcc8d 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 292cb08aa36b8d0cebc14c8c42e9f15e4dc6c9a6 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 c04c504bb3e6f6c0c5576297fa0124a9453c9d59 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 eea478ec3358de60f2b23a2e2709b34efb8a4361 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 e2f83b183c6c3c1b184dff5ebfdea6cfd14c4871 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 917a00974ede94e008f99b656112bcb3ad9c70a0 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 d3e8b3ea52936ed1d46395ed440a0737e58416fa 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 ae3abce5abd65a3190b3fe744acc9d05ae90b2da 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 80083b3fde31029e967692f40ffd6134dc9e8656 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 c68fe193c553607bf9bbc6ed69afa603f3c836c4 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 8596cd02d20f8df0e71bfc69201c678ed8a9a495 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 086bbd9ab2b91f5050c0744ee139dad2840c6fdf 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 30dd3977fcca8a813148d4267630c3b93259cc98 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 7e477aa54e0bec962143e5764e03f5775c2ac418 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') + } + } + }, }, }