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 diff --git a/appinfo/info.xml b/appinfo/info.xml index 2f7f30187c..9051497e77 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -25,7 +25,7 @@ Developed with ❤️ by [LibreCode](https://librecode.coop). Help us transform * [Donate with GitHub Sponsor: ![Donate using GitHub Sponsor](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/libresign) ]]> - 13.0.0-dev.3 + 13.0.0-dev.5 agpl LibreCode diff --git a/lib/Capabilities.php b/lib/Capabilities.php index a844f05c6a..a112d93165 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -8,6 +8,7 @@ namespace OCA\Libresign; +use OCA\Libresign\Service\EnvelopeService; use OCA\Libresign\Service\SignatureTextService; use OCA\Libresign\Service\SignerElementsService; use OCP\App\IAppManager; @@ -25,6 +26,7 @@ public function __construct( protected SignerElementsService $signerElementsService, protected SignatureTextService $signatureTextService, protected IAppManager $appManager, + protected EnvelopeService $envelopeService, ) { } @@ -46,6 +48,9 @@ public function getCapabilities(): array { 'signature-width' => $this->signatureTextService->getSignatureWidth(), 'signature-height' => $this->signatureTextService->getSignatureHeight(), ], + 'envelope' => [ + 'is-available' => $this->envelopeService->isEnabled(), + ], ], 'version' => $this->appManager->getAppVersion('libresign'), ]; diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index dc2cf23192..3973b2be65 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -14,6 +14,7 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Helper\ValidateHelper; @@ -21,8 +22,9 @@ use OCA\Libresign\Middleware\Attribute\RequireManager; use OCA\Libresign\ResponseDefinitions; use OCA\Libresign\Service\AccountService; +use OCA\Libresign\Service\File\FileListService; +use OCA\Libresign\Service\File\SettingsLoader; use OCA\Libresign\Service\FileService; -use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCP\AppFramework\Db\DoesNotExistException; @@ -37,23 +39,30 @@ use OCP\Files\File; use OCP\Files\Node; use OCP\Files\NotFoundException; -use OCP\IAppConfig; use OCP\IL10N; use OCP\IPreview; use OCP\IRequest; +use OCP\IURLGenerator; use OCP\IUserSession; use OCP\Preview\IMimeIconProvider; use Psr\Log\LoggerInterface; /** * @psalm-import-type LibresignFile from ResponseDefinitions - * @psalm-import-type LibresignNewFile from ResponseDefinitions + * @psalm-import-type LibresignFileDetail from ResponseDefinitions * @psalm-import-type LibresignFolderSettings from ResponseDefinitions + * @psalm-import-type LibresignNewFile from ResponseDefinitions + * @psalm-import-type LibresignNextcloudFile from ResponseDefinitions * @psalm-import-type LibresignNextcloudFile from ResponseDefinitions * @psalm-import-type LibresignPagination from ResponseDefinitions * @psalm-import-type LibresignSettings from ResponseDefinitions * @psalm-import-type LibresignSigner from ResponseDefinitions + * @psalm-import-type LibresignSigner from ResponseDefinitions * @psalm-import-type LibresignValidateFile from ResponseDefinitions + * @psalm-import-type LibresignValidateMetadata from ResponseDefinitions + * @psalm-import-type LibresignValidateMetadata from ResponseDefinitions + * @psalm-import-type LibresignVisibleElement from ResponseDefinitions + * @psalm-import-type LibresignVisibleElement from ResponseDefinitions */ class FileController extends AEnvironmentAwareController { public function __construct( @@ -64,14 +73,15 @@ public function __construct( private SessionService $sessionService, private SignRequestMapper $signRequestMapper, private FileMapper $fileMapper, - private IdentifyMethodService $identifyMethodService, private RequestSignatureService $requestSignatureService, private AccountService $accountService, private IPreview $preview, - private IAppConfig $appConfig, private IMimeIconProvider $mimeIconProvider, private FileService $fileService, + private fileListService $fileListService, private ValidateHelper $validateHelper, + private SettingsLoader $settingsLoader, + private IURLGenerator $urlGenerator, ) { parent::__construct(Application::APP_ID, $request); } @@ -80,6 +90,8 @@ public function __construct( * Validate a file using Uuid * * Validate a file returning file data. + * When `nodeType` is `envelope`, the response includes `filesCount` + * and `files` as a list of envelope child files. * * @param string $uuid The UUID of the LibreSign file * @return DataResponse|DataResponse, messages?: array{type: string, message: string}[]}, array{}> @@ -101,6 +113,8 @@ public function validateUuid(string $uuid): DataResponse { * Validate a file using FileId * * Validate a file returning file data. + * When `nodeType` is `envelope`, the response includes `filesCount` + * and `files` as a list of envelope child files. * * @param int $fileId The identifier value of the LibreSign file * @return DataResponse|DataResponse, messages?: array{type: string, message: string}[]}, array{}> @@ -122,7 +136,9 @@ public function validateFileId(int $fileId): DataResponse { * Validate a binary file * * Validate a binary file returning file data. - * Use field 'file' for the file upload + * Use field 'file' for the file upload. + * When `nodeType` is `envelope`, the response includes `filesCount` + * and `files` as a list of envelope child files. * * @return DataResponse|DataResponse, messages?: array{type: string, message: string}[], message?: string}, array{}> * @@ -178,29 +194,22 @@ private function validate(?string $type = null, $identifier = null): DataRespons try { if ($type === 'Uuid' && !empty($identifier)) { try { - $this->fileService - ->setFileByType('Uuid', $identifier); + $this->fileService->setFileByUuid((string)$identifier); } catch (LibresignException) { - $this->fileService - ->setFileByType('SignerUuid', $identifier); + $this->fileService->setFileBySignerUuid((string)$identifier); } - } elseif (!empty($type) && !empty($identifier)) { - $this->fileService - ->setFileByType($type, $identifier); - } elseif ($this->request->getParam('path')) { - $this->fileService - ->setMe($this->userSession->getUser()) - ->setFileByPath($this->request->getParam('path')); + } elseif ($type === 'SignerUuid' && !empty($identifier)) { + $this->fileService->setFileBySignerUuid((string)$identifier); + } elseif ($type === 'FileId' && !empty($identifier)) { + $this->fileService->setFileById((int)$identifier); } elseif ($this->request->getParam('fileId')) { - $this->fileService->setFileByType( - 'FileId', - $this->request->getParam('fileId') - ); + $this->fileService->setFileById((int)$this->request->getParam('fileId')); } elseif ($this->request->getParam('uuid')) { - $this->fileService->setFileByType( - 'Uuid', - $this->request->getParam('uuid') - ); + try { + $this->fileService->setFileByUuid((string)$this->request->getParam('uuid')); + } catch (LibresignException) { + $this->fileService->setFileBySignerUuid((string)$this->request->getParam('uuid')); + } } $return = $this->fileService @@ -246,7 +255,8 @@ private function validate(?string $type = null, $identifier = null): DataRespons * @param int|null $end End date of signature request (UNIX timestamp) * @param string|null $sortBy Name of the column to sort by * @param string|null $sortDirection Ascending or descending order - * @return DataResponse + * @param int|null $parentFileId Filter files by parent envelope file ID + * @return DataResponse, settings?: LibresignSettings}, array{}> * * 200: OK */ @@ -263,6 +273,7 @@ public function list( ?int $end = null, ?string $sortBy = null, ?string $sortDirection = null, + ?int $parentFileId = null, ): DataResponse { $filter = array_filter([ 'signer_uuid' => $signer_uuid, @@ -270,6 +281,7 @@ public function list( 'status' => $status, 'start' => $start, 'end' => $end, + 'parentFileId' => $parentFileId, ], static fn ($var) => $var !== null); $sort = [ 'sortBy' => $sortBy, @@ -277,20 +289,10 @@ public function list( ]; $user = $this->userSession->getUser(); - $this->fileService->setMe($user); - $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(); + $return = $this->fileListService->listAssociatedFilesOfSignFlow($user, $page, $length, $filter, $sort); - $return['settings'] = [ - 'needIdentificationDocuments' => $fileSettings['settings']['needIdentificationDocuments'] ?? false, - 'identificationDocumentsWaitingApproval' => $fileSettings['settings']['identificationDocumentsWaitingApproval'] ?? false, - ]; + if ($user) { + $return['settings'] = $this->settingsLoader->getUserIdentificationSettings($user->getUID()); } return new DataResponse($return, Http::STATUS_OK); @@ -331,10 +333,22 @@ public function getThumbnail( } try { - $myLibreSignFile = $this->fileService - ->setMe($this->userSession->getUser()) - ->getMyLibresignFile($nodeId); - $node = $this->accountService->getPdfByUuid($myLibreSignFile->getUuid()); + $libreSignFile = $this->fileMapper->getByNodeId($nodeId); + if ($libreSignFile->getUserId() !== $this->userSession->getUser()->getUID()) { + return new DataResponse([], Http::STATUS_FORBIDDEN); + } + + if ($libreSignFile->getNodeType() === 'envelope') { + if ($mimeFallback) { + $url = $this->mimeIconProvider->getMimeIconUrl('folder'); + if ($url) { + return new RedirectResponse($url); + } + } + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + + $node = $this->accountService->getPdfByUuid($libreSignFile->getUuid()); } catch (DoesNotExistException) { return new DataResponse([], Http::STATUS_NOT_FOUND); } @@ -395,11 +409,13 @@ private function fetchPreview( /** * Send a file * - * Send a new file to Nextcloud and return the fileId to request signature + * Send a new file to Nextcloud and return the fileId to request signature. + * Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'. * * @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. + * @param list $files Multiple files to create an envelope (optional, use either file or files) * @return DataResponse|DataResponse * * 200: OK @@ -409,58 +425,309 @@ 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)); - } + $this->validateHelper->canRequestSign($this->userSession->getUser()); + + $normalizedFiles = $this->prepareFilesForSaving($file, $files, $settings); + + return $this->saveFiles($normalizedFiles, $name, $settings); + } catch (LibresignException $e) { + return new DataResponse( + [ + 'message' => $e->getMessage(), + ], + Http::STATUS_UNPROCESSABLE_ENTITY, + ); + } + } + + /** + * Add file to envelope + * + * Add one or more files to an existing envelope that is in DRAFT status. + * Files must be uploaded as multipart/form-data with field name 'files[]'. + * + * @param string $uuid The UUID of the envelope + * @return DataResponse|DataResponse + * + * 200: Files added successfully + * 400: Invalid request + * 404: Envelope not found + * 422: Cannot add files (envelope not in DRAFT status or validation failed) + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[RequireManager] + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file/{uuid}/add-file', requirements: ['apiVersion' => '(v1)'])] + public function addFileToEnvelope(string $uuid): DataResponse { + try { + $this->validateHelper->canRequestSign($this->userSession->getUser()); + + $envelope = $this->fileMapper->getByUuid($uuid); + + if ($envelope->getNodeType() !== 'envelope') { + throw new LibresignException($this->l10n->t('This is not an envelope')); } - 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 ($envelope->getStatus() !== FileEntity::STATUS_DRAFT) { + throw new LibresignException($this->l10n->t('Cannot add files to an envelope that is not in draft status')); } + + $settings = $envelope->getMetadata()['settings'] ?? []; + + $uploadedFiles = $this->request->getUploadedFile('files'); + if (!$uploadedFiles) { + throw new LibresignException($this->l10n->t('No files uploaded')); + } + + $normalizedFiles = $this->processUploadedFiles($uploadedFiles); + + $addedFiles = []; + foreach ($normalizedFiles as $fileData) { + $prepared = $this->prepareFileForSaving($fileData, '', $settings); + + $childFile = $this->requestSignatureService->save([ + 'file' => ['fileNode' => $prepared['node']], + 'name' => $prepared['name'], + 'userManager' => $this->userSession->getUser(), + 'status' => FileEntity::STATUS_DRAFT, + 'parentFileId' => $envelope->getId(), + ]); + + $addedFiles[] = $childFile; + } + + $this->fileService->updateEnvelopeFilesCount($envelope, count($addedFiles)); + + $envelope = $this->fileMapper->getById($envelope->getId()); + return $this->formatFileResponse($envelope, $addedFiles); + + } catch (DoesNotExistException $e) { + return new DataResponse( + ['message' => $this->l10n->t('Envelope not found')], + Http::STATUS_NOT_FOUND, + ); + } catch (LibresignException $e) { + return new DataResponse( + ['message' => $e->getMessage()], + Http::STATUS_UNPROCESSABLE_ENTITY, + ); + } catch (\Exception $e) { + $this->logger->error('Failed to add file to envelope', [ + 'exception' => $e, + ]); + return new DataResponse( + ['message' => $this->l10n->t('Failed to add file to envelope')], + Http::STATUS_BAD_REQUEST, + ); + } + } + + /** + * @return array{node: Node, name: string} + */ + private function prepareFileForSaving(array $fileData, string $name, array $settings): array { + if (empty($name)) { + $name = $this->extractFileName($fileData); + } + if (empty($name)) { + throw new LibresignException($this->l10n->t('Name is mandatory')); + } + + 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' => $file, + 'file' => $fileData, 'userManager' => $this->userSession->getUser(), ]); - $this->validateHelper->canRequestSign($this->userSession->getUser()); $node = $this->fileService->getNodeFromData([ 'userManager' => $this->userSession->getUser(), 'name' => $name, - 'file' => $file, + 'file' => $fileData, 'settings' => $settings ]); - $data = [ - 'file' => [ - 'fileNode' => $node, - ], - 'name' => $name, - 'userManager' => $this->userSession->getUser(), - 'status' => FileEntity::STATUS_DRAFT, - ]; - $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, - ); + return [ + 'node' => $node, + 'name' => $name, + ]; + } + + /** + * @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); + } + + 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 { + $filesArray = []; + + if (isset($uploadedFiles['tmp_name'])) { + if (is_array($uploadedFiles['tmp_name'])) { + $count = count($uploadedFiles['tmp_name']); + for ($i = 0; $i < $count; $i++) { + $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 { + $this->fileService->validateUploadedFile($uploadedFiles); + $filesArray[] = [ + 'uploadedFile' => $uploadedFiles, + 'name' => pathinfo($uploadedFiles['name'], PATHINFO_FILENAME), + ]; + } + } + + if (empty($filesArray)) { + throw new LibresignException($this->l10n->t('No files uploaded')); + } + + return $filesArray; + } + + /** + * @return DataResponse + */ + 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')); + } + + $result = $this->requestSignatureService->saveFiles([ + 'files' => $files, + 'name' => $name, + 'userManager' => $this->userSession->getUser(), + 'settings' => $settings, + ]); + + return $this->formatFileResponse($result['file'], $result['children']); + } + + /** + * @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 { + $rawMetadata = $mainEntity->getMetadata() ?? []; + $rawMetadata['extension'] = (string)($rawMetadata['extension'] ?? pathinfo($mainEntity->getName(), PATHINFO_EXTENSION)); + $rawMetadata['p'] = isset($rawMetadata['p']) ? (int)$rawMetadata['p'] : 0; + /** @psalm-var LibresignValidateMetadata $metadata */ + $metadata = $rawMetadata; + + /** @psalm-var list $visibleElements */ + $visibleElements = []; + /** @psalm-var list $signers */ + $signers = []; + + $rawFilesCount = $rawMetadata['filesCount'] ?? null; + $filesCount = is_numeric($rawFilesCount) ? (int)$rawFilesCount : count($childFiles); + $filesCount = max(0, $filesCount); + /** @var int<0, max> $filesCount */ + + /** @psalm-var LibresignNextcloudFile $response */ + $response = [ + 'message' => $this->l10n->t('Success'), + 'id' => $mainEntity->getId(), + 'nodeId' => $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), + 'file' => $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $mainEntity->getUuid()]), + 'metadata' => $metadata, + 'signatureFlow' => SignatureFlow::fromNumeric($mainEntity->getSignatureFlow())->value, + 'visibleElements' => $visibleElements, + 'signers' => $signers, + 'requested_by' => [ + 'userId' => $mainEntity->getUserId(), + 'displayName' => $this->userSession->getUser()?->getDisplayName() ?? $mainEntity->getUserId(), + ], + ]; + + if ($mainEntity->getNodeType() === 'envelope') { + $response['filesCount'] = $filesCount; + $response['files'] = !empty($childFiles) ? $this->formatFilesResponse($childFiles) : []; + } else { + $response['filesCount'] = 1; + $response['files'] = $this->formatFilesResponse($childFiles); + } + + return new DataResponse($response, 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 ''; + } + + /** + * @param FileEntity[] $files + * @return list + */ + private function formatFilesResponse(array $files): array { + return array_values(array_map(fn (FileEntity $file) => [ + 'nodeId' => $file->getNodeId(), + 'uuid' => $file->getUuid(), + 'name' => $file->getName(), + 'status' => $file->getStatus(), + 'statusText' => $this->fileMapper->getTextOfStatus($file->getStatus()), + ], $files)); } /** @@ -468,7 +735,7 @@ public function save(array $file, string $name = '', array $settings = []): Data * * This will delete the file and all data * - * @param integer $fileId Node id of a Nextcloud file + * @param integer $fileId LibreSign file ID * @return DataResponse|DataResponse|DataResponse}, array{}> * * 200: OK diff --git a/lib/Controller/FileElementController.php b/lib/Controller/FileElementController.php index 55de02562a..42e14d3b01 100644 --- a/lib/Controller/FileElementController.php +++ b/lib/Controller/FileElementController.php @@ -43,6 +43,7 @@ public function __construct( * @param string $uuid UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID. * @param integer $signRequestId Id of sign request * @param integer|null $elementId ID of visible element. Each element has an ID that is returned on validation endpoints. + * @param integer|null $fileId File ID when using node identifier instead of UUID * @param string $type The type of element to create, sginature, sinitial, date, datetime, text * @param array{} $metadata Metadata of visible elements to associate with the document * @param LibresignCoordinate $coordinates Coortinates of a visible element on PDF @@ -54,14 +55,15 @@ public function __construct( #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/file-element/{uuid}', requirements: ['apiVersion' => '(v1)'])] - public function post(string $uuid, int $signRequestId, ?int $elementId = null, string $type = '', array $metadata = [], array $coordinates = []): DataResponse { + public function post(string $uuid, int $signRequestId, ?int $elementId = null, ?int $fileId = null, string $type = '', array $metadata = [], array $coordinates = []): DataResponse { $visibleElement = [ 'elementId' => $elementId, 'type' => $type, 'signRequestId' => $signRequestId, 'coordinates' => $coordinates, 'metadata' => $metadata, - 'fileUuid' => $uuid, + 'uuid' => $uuid, + 'fileId' => $fileId, ]; try { $this->validateHelper->validateVisibleElement($visibleElement, ValidateHelper::TYPE_VISIBLE_ELEMENT_PDF); @@ -69,8 +71,7 @@ public function post(string $uuid, int $signRequestId, ?int $elementId = null, s 'uuid' => $uuid, 'userManager' => $this->userSession->getUser() ]); - $fileElement = $this->fileElementService->saveVisibleElement($visibleElement, $uuid); - $statusCode = Http::STATUS_OK; + $fileElement = $this->fileElementService->saveVisibleElement($visibleElement); return new DataResponse([ 'fileElementId' => $fileElement->getId(), ]); @@ -93,6 +94,7 @@ public function post(string $uuid, int $signRequestId, ?int $elementId = null, s * @param string $uuid UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID. * @param integer $signRequestId Id of sign request * @param integer|null $elementId ID of visible element. Each element has an ID that is returned on validation endpoints. + * @param integer|null $fileId File ID when using node identifier instead of UUID * @param string $type The type of element to create, sginature, sinitial, date, datetime, text * @param array{} $metadata Metadata of visible elements to associate with the document * @param LibresignCoordinate $coordinates Coortinates of a visible element on PDF @@ -104,8 +106,8 @@ public function post(string $uuid, int $signRequestId, ?int $elementId = null, s #[NoAdminRequired] #[NoCSRFRequired] #[ApiRoute(verb: 'PATCH', url: '/api/{apiVersion}/file-element/{uuid}/{elementId}', requirements: ['apiVersion' => '(v1)'])] - public function patch(string $uuid, int $signRequestId, ?int $elementId = null, string $type = '', array $metadata = [], array $coordinates = []): DataResponse { - return $this->post($uuid, $signRequestId, $elementId, $type, $metadata, $coordinates); + public function patch(string $uuid, int $signRequestId, ?int $elementId = null, ?int $fileId = null, string $type = '', array $metadata = [], array $coordinates = []): DataResponse { + return $this->post($uuid, $signRequestId, $elementId, $fileId, $type, $metadata, $coordinates); } /** 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); } } diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 04d3ef795f..dac3f51777 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, @@ -184,7 +190,7 @@ public function indexFPath(string $path): TemplateResponse { try { $this->initialState->provideInitialState('file_info', $this->fileService - ->setFileByType('uuid', $matches['uuid']) + ->setFileByUuid($matches['uuid']) ->setIdentifyMethodId($this->sessionService->getIdentifyMethodId()) ->setHost($this->request->getServerHost()) ->setMe($this->userSession->getUser()) @@ -320,6 +326,8 @@ public function sign(string $uuid): TemplateResponse { $this->initialState->provideInitialState('config', [ 'identificationDocumentsFlow' => $file['settings']['needIdentificationDocuments'] ?? false, ]); + $this->initialState->provideInitialState('id', $file['id']); + $this->initialState->provideInitialState('nodeId', $file['nodeId']); $this->initialState->provideInitialState('needIdentificationDocuments', $file['settings']['needIdentificationDocuments'] ?? false); $this->initialState->provideInitialState('identificationDocumentsWaitingApproval', $file['settings']['identificationDocumentsWaitingApproval'] ?? false); $this->initialState->provideInitialState('status', $file['status']); @@ -329,10 +337,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('pdf', - $this->signFileService->getFileUrl('url', $this->getFileEntity(), $this->getNextcloudFile(), $uuid) - ); + 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); @@ -355,9 +368,45 @@ private function provideSignerSignatues(): void { } /** - * Show signature page + * @return string[] Array of PDF URLs + */ + private function getPdfUrls(): array { + return $this->signFileService->getPdfUrlsForSigning( + $this->getFileEntity(), + $this->getSignRequestEntity() + ); + } + + 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 for identification document approval * - * @param string $uuid Sign request uuid + * @param string $uuid File UUID for the identification document approval * @return TemplateResponse * * 200: OK @@ -367,7 +416,6 @@ private function provideSignerSignatues(): void { #[NoCSRFRequired] #[RequireSetupOk] #[FrontpageRoute(verb: 'GET', url: '/p/id-docs/approve/{uuid}')] - #[FrontpageRoute(verb: 'GET', url: '/p/id-docs/approve/{uuid}/{path}', requirements: ['path' => '.+'], postfix: 'private')] public function signIdDoc($uuid): TemplateResponse { try { $fileEntity = $this->signFileService->getFileByUuid($uuid); @@ -399,7 +447,8 @@ public function signIdDoc($uuid): TemplateResponse { ->showVisibleElements() ->showSigners() ->toArray(); - $this->initialState->provideInitialState('fileId', $file['nodeId']); + $this->initialState->provideInitialState('id', $file['id']); + $this->initialState->provideInitialState('nodeId', $file['nodeId']); $this->initialState->provideInitialState('status', $file['status']); $this->initialState->provideInitialState('statusText', $file['statusText']); $this->initialState->provideInitialState('visibleElements', []); @@ -409,10 +458,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 +517,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 +553,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(), @@ -582,7 +635,7 @@ public function resetPassword(): TemplateResponse { public function validationFilePublic(string $uuid): TemplateResponse { try { $this->signFileService->getFileByUuid($uuid); - $this->fileService->setFileByType('uuid', $uuid); + $this->fileService->setFileByUuid($uuid); } catch (DoesNotExistException) { try { $signRequest = $this->signFileService->getSignRequestByUuid($uuid); diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index 1557796bdb..0deafbfe15 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -13,6 +13,7 @@ use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Middleware\Attribute\RequireManager; use OCA\Libresign\ResponseDefinitions; +use OCA\Libresign\Service\File\FileListService; use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\RequestSignatureService; use OCP\AppFramework\Http; @@ -28,6 +29,7 @@ * @psalm-import-type LibresignNewFile from ResponseDefinitions * @psalm-import-type LibresignNewSigner from ResponseDefinitions * @psalm-import-type LibresignValidateFile from ResponseDefinitions + * @psalm-import-type LibresignFileDetail from ResponseDefinitions * @psalm-import-type LibresignSettings from ResponseDefinitions * @psalm-import-type LibresignSigner from ResponseDefinitions * @psalm-import-type LibresignVisibleElement from ResponseDefinitions @@ -38,6 +40,7 @@ public function __construct( protected IL10N $l10n, protected IUserSession $userSession, protected FileService $fileService, + protected FileListService $fileListService, protected ValidateHelper $validateHelper, protected RequestSignatureService $requestSignatureService, ) { @@ -50,6 +53,9 @@ public function __construct( * Request that a file be signed by a group of people. * Each user in the users array can optionally include a 'signing_order' field * to control the order of signatures when ordered signing flow is enabled. + * When the created entity is an envelope (`nodeType` = `envelope`), + * the returned `data` includes `filesCount` and `files` as a list of + * envelope child files. * * @param LibresignNewFile $file File object. * @param LibresignNewSigner[] $users Collection of users who must sign the document. Each user can have: identify, displayName, description, notify, signing_order @@ -57,7 +63,7 @@ public function __construct( * @param string|null $callback URL that will receive a POST after the document is signed * @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 - * @return DataResponse|DataResponse}, array{}> + * @return DataResponse|DataResponse}, array{}> * * 200: OK * 422: Unauthorized @@ -87,15 +93,7 @@ public function request( try { $this->requestSignatureService->validateNewRequestToFile($data); $file = $this->requestSignatureService->save($data); - $return = $this->fileService - ->setFile($file) - ->setHost($this->request->getServerHost()) - ->setMe($data['userManager']) - ->showVisibleElements() - ->showSigners() - ->showSettings() - ->showMessages() - ->toArray(); + $return = $this->fileListService->formatSingleFile($user, $file); return new DataResponse( [ 'message' => $this->l10n->t('Success'), @@ -137,7 +135,8 @@ 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 - * @return DataResponse|DataResponse}, array{}> + * @param string|null $name The name of file to sign + * @return DataResponse|DataResponse}, array{}> * * 200: OK * 422: Unauthorized @@ -153,6 +152,7 @@ public function updateSign( ?array $file = [], ?int $status = null, ?string $signatureFlow = null, + ?string $name = null, ): DataResponse { $user = $this->userSession->getUser(); $data = [ @@ -163,6 +163,7 @@ public function updateSign( 'status' => $status, 'visibleElements' => $visibleElements, 'signatureFlow' => $signatureFlow, + 'name' => $name, ]; try { $this->validateHelper->validateExistingFile($data); @@ -172,15 +173,7 @@ public function updateSign( $this->validateHelper->validateVisibleElements($data['visibleElements'], $this->validateHelper::TYPE_VISIBLE_ELEMENT_PDF); } $file = $this->requestSignatureService->save($data); - $return = $this->fileService - ->setFile($file) - ->setHost($this->request->getServerHost()) - ->setMe($data['userManager']) - ->showVisibleElements() - ->showSigners() - ->showSettings() - ->showMessages() - ->toArray(); + $return = $this->fileListService->formatSingleFile($user, $file); return new DataResponse( [ 'message' => $this->l10n->t('Success'), @@ -203,7 +196,7 @@ public function updateSign( * * You can only request exclusion as any sign * - * @param integer $fileId Node id of a Nextcloud file + * @param integer $fileId LibreSign file ID * @param integer $signRequestId The sign request id * @return DataResponse|DataResponse|DataResponse}, array{}> * diff --git a/lib/Dav/SignatureStatusPlugin.php b/lib/Dav/SignatureStatusPlugin.php index 696232c174..023ea84bbd 100644 --- a/lib/Dav/SignatureStatusPlugin.php +++ b/lib/Dav/SignatureStatusPlugin.php @@ -32,7 +32,7 @@ public function propFind(PropFind $propFind, INode $node): void { return; } - $fileService->setFileByType('FileId', $nodeId); + $fileService->setFileByNodeId($nodeId); $propFind->handle('{http://nextcloud.org/ns}libresign-signature-status', $fileService->getStatus()); $propFind->handle('{http://nextcloud.org/ns}libresign-signed-node-id', $fileService->getSignedNodeId()); diff --git a/lib/Db/File.php b/lib/Db/File.php index 04b93a1c26..7f0dcfddf4 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; @@ -30,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) @@ -43,6 +44,10 @@ * @method int getSignatureFlow() * @method void setDocmdpLevel(int $docmdpLevel) * @method int getDocmdpLevel() + * @method void setNodeType(string $nodeType) + * @method 'file'|'envelope' 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..e941ece8bb 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; @@ -141,7 +142,7 @@ public function getBySignerUuid(?string $uuid = null): File { /** * Return LibreSign file by nodeId */ - public function getByFileId(?int $nodeId = null): File { + public function getByNodeId(?int $nodeId = null): File { $exists = array_filter($this->file, fn ($f) => $f->getNodeId() === $nodeId || $f->getSignedNodeId() === $nodeId); if (!empty($exists)) { return current($exists); @@ -274,4 +275,68 @@ public function neutralizeDeletedUser(string $userId, string $displayName): void $update->executeStatement(); } } + + /** + * @return File[] + */ + public function getChildrenFiles(int $parentId): array { + $cached = array_filter($this->file, fn ($f) => $f->getParentFileId() === $parentId); + if (!empty($cached)) { + return array_values($cached); + } + + $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'); + + $children = $this->findEntities($qb); + + foreach ($children as $child) { + $this->file[] = $child; + } + + return $children; + } + + 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 { + $cached = array_filter($this->file, fn ($f) => $f->getParentFileId() === $envelopeId); + if (!empty($cached)) { + return count($cached); + } + + $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/lib/Db/IdentifyMethodMapper.php b/lib/Db/IdentifyMethodMapper.php index c7203c6471..408942c581 100644 --- a/lib/Db/IdentifyMethodMapper.php +++ b/lib/Db/IdentifyMethodMapper.php @@ -74,6 +74,16 @@ public function neutralizeDeletedUser(string $userId, string $displayName): void } } + public function deleteBySignRequestId(int $signRequestId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete('libresign_identify_method') + ->where( + $qb->expr()->eq('sign_request_id', $qb->createNamedParameter($signRequestId, IQueryBuilder::PARAM_INT)) + ) + ->executeStatement(); + unset($this->methodsBySignRequest[$signRequestId]); + } + /** * @return array[] */ diff --git a/lib/Db/SignRequestMapper.php b/lib/Db/SignRequestMapper.php index 1b7d5cc1c5..d0bda63fa3 100644 --- a/lib/Db/SignRequestMapper.php +++ b/lib/Db/SignRequestMapper.php @@ -8,8 +8,6 @@ namespace OCA\Libresign\Db; -use DateTimeInterface; -use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Enum\SignRequestStatus; use OCA\Libresign\Helper\Pagination; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; @@ -190,6 +188,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 */ @@ -257,16 +288,21 @@ public function findRemindersCandidates(): \Generator { /** * Get all signers by multiple fileId + * Includes signers from both the files themselves and their children files (for envelopes) * * @return SignRequest[] */ public function getByMultipleFileId(array $fileId) { $qb = $this->db->getQueryBuilder(); - $qb->select('*') + $qb->select('sr.*') ->from($this->getTableName(), 'sr') + ->join('sr', 'libresign_file', 'f', $qb->expr()->eq('sr.file_id', 'f.id')) ->where( - $qb->expr()->in('sr.file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT_ARRAY)) + $qb->expr()->orX( + $qb->expr()->in('f.id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT_ARRAY)), + $qb->expr()->in('f.parent_file_id', $qb->createNamedParameter($fileId, IQueryBuilder::PARAM_INT_ARRAY)) + ) ) ->orderBy('sr.id', 'ASC'); @@ -376,9 +412,8 @@ public function getByFileIdAndSignRequestId(int $fileId, int $signRequestId): Si $qb->select('sr.*') ->from($this->getTableName(), 'sr') - ->join('sr', 'libresign_file', 'f', 'sr.file_id = f.id') ->where( - $qb->expr()->eq('f.node_id', $qb->createNamedParameter($fileId)) + $qb->expr()->eq('sr.file_id', $qb->createNamedParameter($fileId)) ) ->andWhere( $qb->expr()->eq('sr.id', $qb->createNamedParameter($signRequestId)) @@ -392,7 +427,10 @@ public function getByFileIdAndSignRequestId(int $fileId, int $signRequestId): Si return end($this->signers); } - public function getFilesAssociatedFilesWithMeFormatted( + /** + * @return array{data: list, pagination: Pagination} + */ + public function getFilesAssociatedFilesWithMe( IUser $user, array $filter, ?int $page = null, @@ -409,11 +447,13 @@ public function getFilesAssociatedFilesWithMeFormatted( $data = []; foreach ($currentPageResults as $row) { - $data[] = $this->formatListRow($row); + $file = new File(); + $data[] = $file->fromRow($row); } - $return['data'] = $data; - $return['pagination'] = $pagination; - return $return; + return [ + 'data' => $data, + 'pagination' => $pagination, + ]; } /** @@ -426,18 +466,19 @@ public function getVisibleElementsFromSigners(array $signRequests): array { return []; } $qb = $this->db->getQueryBuilder(); + $qb->select('fe.*') ->from('libresign_file_element', 'fe') ->where( $qb->expr()->in('fe.sign_request_id', $qb->createParameter('signRequestIds')) ); + $return = []; foreach (array_chunk($signRequestIds, 1000) as $signRequestIdsChunk) { $qb->setParameter('signRequestIds', $signRequestIdsChunk, IQueryBuilder::PARAM_INT_ARRAY); $cursor = $qb->executeQuery(); while ($row = $cursor->fetch()) { - $fileElement = new FileElement(); - $return[$row['sign_request_id']][] = $fileElement->fromRow($row); + $return[$row['sign_request_id']][] = (new FileElement())->fromRow($row); } } return $return; @@ -483,6 +524,7 @@ public function getMyLibresignFile(string $userId, ?array $filter = []): File { if (!$row) { throw new DoesNotExistException('LibreSign file not found'); } + $file = new File(); return $file->fromRow($row); } @@ -510,6 +552,8 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array 'f.created_at', 'f.signature_flow', 'f.docmdp_level', + 'f.node_type', + 'f.parent_file_id' ) ->groupBy( 'f.id', @@ -522,6 +566,8 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array 'f.created_at', 'f.signature_flow', 'f.docmdp_level', + 'f.node_type', + 'f.parent_file_id' ); // 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 @@ -545,7 +591,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')); + if ($filter) { if (isset($filter['email']) && filter_var($filter['email'], FILTER_VALIDATE_EMAIL)) { $or[] = $qb->expr()->andX( @@ -553,7 +601,11 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array $qb->expr()->eq('im.identifier_value', $qb->createNamedParameter($filter['email'])) ); } - if (!empty($filter['signer_uuid'])) { + if (!empty($filter['signer_uuid']) && !empty($filter['parentFileId'])) { + $qb->leftJoin('f', 'libresign_file', 'parent', $qb->expr()->eq('f.parent_file_id', 'parent.id')); + $qb->innerJoin('parent', 'libresign_sign_request', 'psr', $qb->expr()->eq('psr.file_id', 'parent.id')) + ->andWhere($qb->expr()->eq('psr.uuid', $qb->createNamedParameter($filter['signer_uuid']))); + } elseif (!empty($filter['signer_uuid'])) { $qb->andWhere( $qb->expr()->eq('sr.uuid', $qb->createNamedParameter($filter['signer_uuid'])) ); @@ -580,6 +632,15 @@ private function getFilesAssociatedFilesWithMeQueryBuilder(string $userId, array $qb->expr()->lte('f.created_at', $qb->createNamedParameter($end, IQueryBuilder::PARAM_STR)) ); } + if (!empty($filter['parentFileId'])) { + $qb->andWhere( + $qb->expr()->eq('f.parent_file_id', $qb->createNamedParameter($filter['parentFileId'], IQueryBuilder::PARAM_INT)) + ); + } else { + $qb->andWhere($qb->expr()->isNull('f.parent_file_id')); + } + } else { + $qb->andWhere($qb->expr()->isNull('f.parent_file_id')); } return $qb; } @@ -617,49 +678,6 @@ private function getFilesAssociatedFilesWithMeStmt( return $pagination; } - private function formatListRow(array $row): array { - $row['id'] = (int)$row['id']; - $row['status'] = (int)$row['status']; - $row['statusText'] = $this->fileMapper->getTextOfStatus($row['status']); - $row['nodeId'] = (int)$row['node_id']; - $row['signedNodeId'] = (int)$row['signed_node_id']; - $row['requested_by'] = [ - 'userId' => $row['user_id'], - 'displayName' => $this->userManager->get($row['user_id'])?->getDisplayName(), - ]; - $row['created_at'] = (new \DateTime($row['created_at']))->setTimezone(new \DateTimeZone('UTC'))->format(DateTimeInterface::ATOM); - $row['file'] = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $row['uuid']]); - $row['nodeId'] = (int)$row['node_id']; - - $row['name'] = $this->removeExtensionFromName($row['name'], $row['metadata']); - $row['signatureFlow'] = SignatureFlow::fromNumeric((int)($row['signature_flow']))->value; - $row['docmdpLevel'] = (int)($row['docmdp_level'] ?? 0); - - unset( - $row['user_id'], - $row['node_id'], - $row['signed_node_id'], - $row['signature_flow'], - $row['docmdp_level'], - ); - return $row; - } - - private function removeExtensionFromName(string $name, ?string $metadataJson): string { - if (empty($name) || empty($metadataJson)) { - return $name; - } - - $metadata = json_decode($metadataJson, true); - if (!isset($metadata['extension'])) { - return $name; - } - - $extensionPattern = '/\.' . preg_quote($metadata['extension'], '/') . '$/i'; - $result = preg_replace($extensionPattern, '', $name); - return $result ?? $name; - } - public function getTextOfSignerStatus(int $status): string { return SignRequestStatus::from($status)->getLabel($this->l10n); } 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 @@ + $cert) { if ($cert['isLibreSignRootCA'] + && isset($cert['certificate_validation']) && $cert['certificate_validation']['id'] !== 1 ) { $signer['chain'][$key]['certificate_validation'] = [ diff --git a/lib/Helper/FileUploadHelper.php b/lib/Helper/FileUploadHelper.php new file mode 100644 index 0000000000..3a187b1602 --- /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/Pagination.php b/lib/Helper/Pagination.php index 146ea1fe4e..39379c638b 100644 --- a/lib/Helper/Pagination.php +++ b/lib/Helper/Pagination.php @@ -13,6 +13,9 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IURLGenerator; +/** + * @psalm-import-type LibresignPagination from \OCA\Libresign\ResponseDefinitions + */ class Pagination extends Pagerfanta { private string $routeName; public function __construct( @@ -32,6 +35,9 @@ public function setRouteName(string $routeName = ''): self { return $this; } + /** + * @return LibresignPagination + */ public function getPagination(int $page, int $length, array $filter = []): array { $this->setMaxPerPage($length); $total = $this->count(); diff --git a/lib/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index fecff24eeb..2ff3c34293 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'])) { @@ -227,12 +228,17 @@ public function validateElementSignRequestId(array $element, int $type): void { if ($type !== self::TYPE_VISIBLE_ELEMENT_PDF) { return; } - if (!array_key_exists('signRequestId', $element)) { + if (!array_key_exists('signRequestId', $element) && !array_key_exists('uuid', $element)) { // TRANSLATION The element can be an image or text. It has to be associated with an user. The element will be added to the document. throw new LibresignException($this->l10n->t('Element must be associated with a user')); } + + $getter = array_key_exists('signRequestId', $element) + ? fn () => $this->signRequestMapper->getById($element['signRequestId']) + : fn () => $this->signRequestMapper->getByUuid($element['uuid']); + try { - $this->signRequestMapper->getById($element['signRequestId']); + $getter(); } catch (\Throwable) { throw new LibresignException($this->l10n->t('User not found for element.')); } @@ -375,7 +381,7 @@ public function fileCanBeSigned(File $file): void { public function validateIfNodeIdExists(int $nodeId, string $userId = '', int $type = self::TYPE_TO_SIGN): void { if (!$userId) { - $libresignFile = $this->fileMapper->getByFileId($nodeId); + $libresignFile = $this->fileMapper->getByNodeId($nodeId); $userId = $libresignFile->getUserId(); } try { @@ -392,7 +398,7 @@ public function validateIfNodeIdExists(int $nodeId, string $userId = '', int $ty public function validateMimeTypeAcceptedByNodeId(int $nodeId, string $userId = '', int $type = self::TYPE_TO_SIGN): void { if (!$userId) { - $libresignFile = $this->fileMapper->getByFileId($nodeId); + $libresignFile = $this->fileMapper->getByNodeId($nodeId); $userId = $libresignFile->getUserId(); } $file = $this->root->getUserFolder($userId)->getFirstNodeById($nodeId); @@ -415,9 +421,9 @@ public function validateMimeTypeAcceptedByMime(string $mimetype, int $type = sel } } - public function validateLibreSignNodeId(int $nodeId): void { + public function validateLibreSignFileId(int $fileId): void { try { - $this->getLibreSignFileByNodeId($nodeId); + $this->fileMapper->getById($fileId); } catch (\Throwable) { throw new LibresignException($this->l10n->t('Invalid fileID')); } @@ -427,7 +433,7 @@ private function getLibreSignFileByNodeId(int $nodeId): ?\OCP\Files\File { if (isset($this->file[$nodeId])) { return $this->file[$nodeId]; } - $libresignFile = $this->fileMapper->getByFileId($nodeId); + $libresignFile = $this->fileMapper->getByNodeId($nodeId); $userFolder = $this->root->getUserFolder($libresignFile->getUserId()); $file = $userFolder->getFirstNodeById($nodeId); @@ -464,8 +470,8 @@ public function canRequestSign(IUser $user): void { } } - public function iRequestedSignThisFile(IUser $user, int $nodeId): void { - $libresignFile = $this->fileMapper->getByFileId($nodeId); + public function iRequestedSignThisFile(IUser $user, int $fileId): void { + $libresignFile = $this->fileMapper->getById($fileId); if ($libresignFile->getUserId() !== $user->getUID()) { throw new LibresignException($this->l10n->t('You do not have permission for this action.')); } @@ -485,7 +491,7 @@ public function validateFileStatus(array $data): void { $file = $this->fileMapper->getByUuid($data['uuid']); } elseif (!empty($data['file']['fileId'])) { try { - $file = $this->fileMapper->getByFileId($data['file']['fileId']); + $file = $this->fileMapper->getById($data['file']['fileId']); } catch (\Throwable) { } } @@ -504,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) { @@ -603,12 +613,12 @@ public function validateExistingFile(array $data): void { if (isset($data['uuid'])) { $this->validateFileUuid($data); $file = $this->fileMapper->getByUuid($data['uuid']); - $this->iRequestedSignThisFile($data['userManager'], $file->getNodeId()); + $this->iRequestedSignThisFile($data['userManager'], $file->getId()); } elseif (isset($data['file'])) { if (!isset($data['file']['fileId'])) { throw new LibresignException($this->l10n->t('Invalid fileID')); } - $this->validateLibreSignNodeId($data['file']['fileId']); + $this->validateLibreSignFileId($data['file']['fileId']); $this->iRequestedSignThisFile($data['userManager'], $data['file']['fileId']); } else { // TRANSLATORS This message is at API side. When an application or a @@ -647,7 +657,7 @@ public function haveValidMail(array $data, ?int $type = null): void { public function signerWasAssociated(array $signer): void { try { - $libresignFile = $this->fileMapper->getByFileId(); + $libresignFile = $this->fileMapper->getByNodeId(); } catch (\Throwable) { throw new LibresignException($this->l10n->t('File not loaded')); } @@ -671,7 +681,7 @@ public function signerWasAssociated(array $signer): void { public function notSigned(array $signer): void { try { - $libresignFile = $this->fileMapper->getByFileId(); + $libresignFile = $this->fileMapper->getByNodeId(); } catch (\Throwable) { throw new LibresignException($this->l10n->t('File not loaded')); } diff --git a/lib/Listener/BeforeNodeDeletedListener.php b/lib/Listener/BeforeNodeDeletedListener.php index 8b64ebb3e7..dc4c0f5c66 100644 --- a/lib/Listener/BeforeNodeDeletedListener.php +++ b/lib/Listener/BeforeNodeDeletedListener.php @@ -57,13 +57,12 @@ private function delete(int $nodeId): void { } switch ($type) { case 'signed_file': - $file = $this->fileMapper->getByFileId($nodeId); - $nodeId = $file->getNodeId(); - $this->requestSignatureService->deleteRequestSignature(['file' => ['fileId' => $nodeId]]); + $file = $this->fileMapper->getByNodeId($nodeId); + $this->requestSignatureService->deleteRequestSignature(['file' => ['fileId' => $file->getId()]]); break; case 'file': - $libresignFile = $this->fileMapper->getByFileId($nodeId); - $this->requestSignatureService->deleteRequestSignature(['file' => ['fileId' => $nodeId]]); + $libresignFile = $this->fileMapper->getByNodeId($nodeId); + $this->requestSignatureService->deleteRequestSignature(['file' => ['fileId' => $libresignFile->getId()]]); $this->fileMapper->delete($libresignFile); break; case 'user_element': diff --git a/lib/Listener/MailNotifyListener.php b/lib/Listener/MailNotifyListener.php index a9bfb7c812..50aa5753c1 100644 --- a/lib/Listener/MailNotifyListener.php +++ b/lib/Listener/MailNotifyListener.php @@ -106,6 +106,9 @@ protected function sendSignedMailNotification( IUser $user, ): void { try { + if ($libreSignFile->hasParent()) { + return; + } if ($identifyMethod->getEntity()->isDeletedAccount()) { return; } 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; + } +} diff --git a/lib/Migration/Version16001Date20251227000000.php b/lib/Migration/Version16001Date20251227000000.php new file mode 100644 index 0000000000..7a91ec29a9 --- /dev/null +++ b/lib/Migration/Version16001Date20251227000000.php @@ -0,0 +1,94 @@ +connection->getQueryBuilder(); + + $qb->select('id', 'name', 'metadata') + ->from('libresign_file') + ->where($qb->expr()->isNotNull('metadata')); + + $cursor = $qb->executeQuery(); + $filesToUpdate = []; + + while ($row = $cursor->fetch()) { + $metadata = json_decode($row['metadata'], true); + + // Only process files that have an extension in metadata + if (!isset($metadata['extension']) || empty($metadata['extension'])) { + continue; + } + + $name = $row['name']; + $extension = $metadata['extension']; + + // Remove the extension from the name + $extensionPattern = '/\.' . preg_quote($extension, '/') . '$/i'; + $newName = preg_replace($extensionPattern, '', $name); + + // Only update if the name actually changed + if ($newName !== $name) { + $filesToUpdate[] = [ + 'id' => (int)$row['id'], + 'newName' => $newName, + ]; + } + } + $cursor->closeCursor(); + + // Update all files with normalized names + if (!empty($filesToUpdate)) { + $updateQb = $this->connection->getQueryBuilder(); + + foreach ($filesToUpdate as $file) { + $updateQb->update('libresign_file') + ->set('name', $updateQb->createNamedParameter($file['newName'])) + ->where($updateQb->expr()->eq('id', $updateQb->createNamedParameter($file['id'], \OCP\DB\Types::INTEGER))) + ->executeStatement(); + } + + $output->info('Normalized ' . count($filesToUpdate) . ' file names by removing extensions from database'); + } else { + $output->info('No file names needed normalization'); + } + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index c2a365200d..e8728abd46 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -43,14 +43,6 @@ * name?: string, * type?: string, * } - * @psalm-type LibresignNextcloudFile = array{ - * message: string, - * name: non-falsy-string, - * id: int, - * status: int, - * statusText: string, - * created_at: string, - * } * @psalm-type LibresignIdentifyAccount = array{ * id: non-negative-int, * isNoUser: boolean, @@ -105,10 +97,10 @@ * canSign: bool, * canRequestSign: bool, * signerFileUuid: ?string, - * hasSignatureFile?: bool, * phoneNumber: string, - * needIdentificationDocuments?: bool, - * identificationDocumentsWaitingApproval?: bool, + * hasSignatureFile: bool, + * needIdentificationDocuments: bool, + * identificationDocumentsWaitingApproval: bool, * } * @psalm-type LibresignIdentifyMethod = array{ * method: "email"|"account", @@ -116,19 +108,20 @@ * 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, + * fileId: int, * type: string, * coordinates: LibresignCoordinate, * } @@ -183,15 +176,47 @@ * identifyMethods?: LibresignIdentifyMethod[], * visibleElements?: LibresignVisibleElement[], * signatureMethods?: LibresignSignatureMethods, + * uid?: string, + * } + * @psalm-type LibresignEnvelopeChildSignerSummary = array{ + * signRequestId: int, + * displayName: string, + * email: string, + * signed: ?string, + * status: int, + * statusText: string, + * } + * @psalm-type LibresignValidateMetadata = array{ + * extension: string, + * p: int, + * d?: list, + * pdfVersion?: string, + * } + * @psalm-type LibresignEnvelopeChildFile = array{ + * id: int, + * uuid: string, + * name: string, + * status: int, + * statusText: string, + * nodeId: int, + * totalPages?: non-negative-int, + * size?: non-negative-int, + * pdfVersion?: string, + * signers: list, + * metadata?: LibresignValidateMetadata, * } * @psalm-type LibresignValidateFile = array{ + * id: int, * uuid: string, * name: string, * status: 0|1|2|3|4, * statusText: string, * nodeId: non-negative-int, + * nodeType: 'file'|'envelope', * signatureFlow: int, * docmdpLevel: int, + * filesCount?: int<0, max>, + * files?: list, * totalPages: non-negative-int, * size: non-negative-int, * pdfVersion: string, @@ -202,11 +227,9 @@ * }, * file: string, * url?: string, - * metadata?: array{ - * extension: string, - * p: int, - * d?: list, - * }, + * mime?: string, + * pages?: list, + * metadata?: LibresignValidateMetadata, * signers?: LibresignSigner[], * settings?: LibresignSettings, * messages?: array{ @@ -215,6 +238,56 @@ * }[], * visibleElements?: LibresignVisibleElement[], * } + * @psalm-type LibresignNextcloudFile = array{ + * message: string, + * name: non-falsy-string, + * id: int, + * nodeId: int, + * uuid: string, + * status: int, + * statusText: string, + * nodeType: 'file'|'envelope', + * created_at: string, + * file: string, + * metadata: LibresignValidateMetadata, + * signatureFlow: 'none'|'parallel'|'ordered_numeric', + * visibleElements: LibresignVisibleElement[], + * signers: LibresignSigner[], + * requested_by: array{ + * userId: string, + * displayName: string, + * }, + * filesCount: int<0, max>, + * files: list, + * } + * @psalm-type LibresignFileDetail = array{ + * created_at: string, + * file: string, + * files: list, + * filesCount: int, + * id: int, + * metadata: array, + * name: string, + * nodeId: int, + * nodeType: string, + * requested_by: array{ + * userId: string, + * displayName: ?string, + * }, + * signatureFlow: int|string, + * signers: list, + * status: int, + * statusText: string, + * uuid: string, + * visibleElements: LibresignVisibleElement[], + * } * @psalm-type LibresignFile = array{ * account: array{ * userId: string, @@ -271,6 +344,9 @@ * signature-width: float, * signature-height: float, * }, + * envelope: array{ + * is-available: bool, + * }, * }, * version: string, * } 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/EnvelopeService.php b/lib/Service/EnvelopeService.php new file mode 100644 index 0000000000..2d8a599c9f --- /dev/null +++ b/lib/Service/EnvelopeService.php @@ -0,0 +1,112 @@ +appConfig->getValueBool(Application::APP_ID, 'envelope_enabled', true); + } + + /** + * @throws LibresignException + */ + public function validateEnvelopeConstraints(int $fileCount): void { + if (!$this->isEnabled()) { + throw new LibresignException($this->l10n->t('Envelope feature is disabled')); + } + + $maxFiles = $this->getMaxFilesPerEnvelope(); + if ($fileCount > $maxFiles) { + throw new LibresignException( + $this->l10n->t('Maximum number of files per envelope (%s) exceeded', [$maxFiles]) + ); + } + } + + public function createEnvelope(string $name, string $userId, int $filesCount = 0): FileEntity { + $this->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); + + $envelope->setMetadata(['filesCount' => $filesCount]); + + 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->getMaxFilesPerEnvelope(); + $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; + } + } + + private function getMaxFilesPerEnvelope(): int { + return $this->appConfig->getValueInt(Application::APP_ID, 'envelope_max_files', 50); + } +} diff --git a/lib/Service/File/AccountSettingsProvider.php b/lib/Service/File/AccountSettingsProvider.php new file mode 100644 index 0000000000..c183556084 --- /dev/null +++ b/lib/Service/File/AccountSettingsProvider.php @@ -0,0 +1,66 @@ +canRequestSign($user); + $return['hasSignatureFile'] = $this->hasSignatureFile($user); + return $return; + } + + public function getPhoneNumber(IUser $user): string { + $userAccount = $this->accountManager->getAccount($user); + return $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(); + } + + private function canRequestSign(?IUser $user = null): bool { + if (!$user) { + return false; + } + $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); + if (empty($authorized)) { + return false; + } + $userGroups = $this->groupManager->getUserGroupIds($user); + if (!array_intersect($userGroups, $authorized)) { + return false; + } + return true; + } + + private function hasSignatureFile(?IUser $user = null): bool { + if (!$user) { + return false; + } + try { + $this->pkcs12Handler->getPfxOfCurrentSigner($user->getUID()); + return true; + } catch (LibresignException) { + return false; + } + } +} diff --git a/lib/Service/File/CertificateChainService.php b/lib/Service/File/CertificateChainService.php new file mode 100644 index 0000000000..1fa248c4a9 --- /dev/null +++ b/lib/Service/File/CertificateChainService.php @@ -0,0 +1,52 @@ +isValidateFile() || !$libreSignFile->getSignedNodeId()) { + return []; + } + + try { + $resource = $fileNode->fopen('rb'); + $sha256 = $this->getSha256FromResource($resource); + rewind($resource); + if ($sha256 === $libreSignFile->getSignedHash()) { + $this->pkcs12Handler->setIsLibreSignFile(); + } + $certData = $this->pkcs12Handler->getCertificateChain($resource); + fclose($resource); + return $certData; + } catch (\Exception $e) { + $this->logger->warning('Failed to load certificate chain: ' . $e->getMessage()); + return []; + } + } + + private function getSha256FromResource($resource): string { + $hashContext = hash_init('sha256'); + while (!feof($resource)) { + $buffer = fread($resource, 8192); + hash_update($hashContext, $buffer); + } + return hash_final($hashContext); + } +} diff --git a/lib/Service/File/EnvelopeAssembler.php b/lib/Service/File/EnvelopeAssembler.php new file mode 100644 index 0000000000..0325749e66 --- /dev/null +++ b/lib/Service/File/EnvelopeAssembler.php @@ -0,0 +1,147 @@ +id = $childFile->getId(); + $fileData->uuid = $childFile->getUuid(); + $fileData->name = $childFile->getName(); + $fileData->status = $childFile->getStatus(); + $fileData->statusText = $this->fileMapper->getTextOfStatus($childFile->getStatus()); + $fileData->nodeId = $childFile->getNodeId(); + $fileData->metadata = $childFile->getMetadata(); + $childMetadata = $childFile->getMetadata() ?? []; + $fileData->totalPages = (int)($childMetadata['p'] ?? 0); + $fileData->pdfVersion = (string)($childMetadata['pdfVersion'] ?? ''); + + $nodeId = $childFile->getSignedNodeId() ?: $childFile->getNodeId(); + $fileNode = $this->root->getUserFolder($childFile->getUserId())->getFirstNodeById($nodeId); + if ($fileNode instanceof \OCP\Files\File) { + if (method_exists($fileNode, 'getSize')) { + $fileData->size = $fileNode->getSize(); + } + if (method_exists($fileNode, 'getMimeType')) { + $fileData->mime = $fileNode->getMimeType(); + } + } + + $fileData->signers = []; + $fileData->visibleElements = []; + + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + foreach ($signRequests as $signRequest) { + $identifyMethods = $this->identifyMethodService + ->setIsRequest(false) + ->getIdentifyMethodsFromSignRequestId($signRequest->getId()); + + $email = ''; + foreach ($identifyMethods[IdentifyMethodService::IDENTIFY_EMAIL] ?? [] as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + if ($entity->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { + $email = $entity->getIdentifierValue(); + break; + } + } + + $signed = null; + if ($signRequest->getSigned()) { + $signed = $signRequest->getSigned()->format(DateTimeInterface::ATOM); + } + + $displayName = $signRequest->getDisplayName(); + if ($displayName === '' && $email !== '') { + $displayName = $email; + } + + $signer = new \stdClass(); + $signer->signRequestId = $signRequest->getId(); + $signer->displayName = $displayName; + $signer->email = $email; + $signer->signed = $signed; + $signer->status = $signRequest->getStatus(); + $signer->statusText = $this->signRequestMapper->getTextOfSignerStatus($signRequest->getStatus()); + $fileData->signers[] = $signer; + } + + if ($options->isShowVisibleElements()) { + $childMetadata = $childFile->getMetadata(); + foreach ($this->signRequestMapper->getVisibleElementsFromSigners($signRequests) as $row) { + if (empty($row)) { + continue; + } + $fileData->visibleElements = array_merge( + $this->fileElementService->formatVisibleElements($row, $childMetadata), + $fileData->visibleElements + ); + } + } + + if ($options->isValidateFile() && $childFile->getSignedNodeId()) { + try { + $fileNode = $this->root->getUserFolder($childFile->getUserId())->getFirstNodeById($childFile->getSignedNodeId()); + if ($fileNode instanceof \OCP\Files\File) { + if ($this->certificateChainService !== null) { + $certData = $this->certificateChainService->getCertificateChain($fileNode, $childFile, $options); + } else { + $resource = $fileNode->fopen('rb'); + $sha256 = $this->getSha256FromResource($resource); + rewind($resource); + if ($sha256 === $childFile->getSignedHash()) { + $this->pkcs12Handler->setIsLibreSignFile(); + } + $certData = $this->pkcs12Handler->getCertificateChain($resource); + fclose($resource); + } + if (!empty($certData)) { + $this->signersLoader->loadSignersFromCertData($fileData, $certData, $options->getHost()); + } + } + } catch (\Exception $e) { + $this->logger->warning('Failed to load envelope child certificate chain: ' . $e->getMessage()); + } + } + + return $fileData; + } + + private function getSha256FromResource($resource): string { + $hashContext = hash_init('sha256'); + while (!feof($resource)) { + $buffer = fread($resource, 8192); + hash_update($hashContext, $buffer); + } + return hash_final($hashContext); + } + +} diff --git a/lib/Service/File/EnvelopeProgressService.php b/lib/Service/File/EnvelopeProgressService.php new file mode 100644 index 0000000000..dea555a0d4 --- /dev/null +++ b/lib/Service/File/EnvelopeProgressService.php @@ -0,0 +1,110 @@ + SignRequest[] + * $identifyMethodsBySignRequest is a map: signRequestId => array of identify-method wrappers + */ + public function computeProgress(stdClass $fileData, $envelope, array $childrenFiles, array $signRequestsByFileId, array $identifyMethodsBySignRequest): void { + if (!$envelope || $envelope->getParentFileId()) { + return; + } + + if (empty($fileData->signers)) { + return; + } + + if (empty($childrenFiles)) { + $this->resetSignerCounts($fileData); + return; + } + + $signerProgress = $this->aggregateSignerProgress($childrenFiles, $signRequestsByFileId, $identifyMethodsBySignRequest); + $this->applyProgressToFileData($fileData, $signerProgress); + } + + private function resetSignerCounts(stdClass $fileData): void { + foreach ($fileData->signers as $idx => $_) { + $fileData->signers[$idx]->totalDocuments = 0; + $fileData->signers[$idx]->documentsSignedCount = 0; + } + } + + /** + * Aggregate signer progress across all children files. + * Returns map signerKey => ['total' => int, 'signed' => int] + * + * @return array + */ + private function aggregateSignerProgress(array $childrenFiles, array $signRequestsByFileId, array $identifyMethodsBySignRequest): array { + $signerProgress = []; + foreach ($childrenFiles as $childFile) { + $signRequests = $signRequestsByFileId[$childFile->getId()] ?? []; + foreach ($signRequests as $signRequest) { + $signRequestId = $signRequest->getId(); + $identifyMethods = $identifyMethodsBySignRequest[$signRequestId] ?? []; + + $signerKey = $this->buildSignerKey($identifyMethods); + if (!isset($signerProgress[$signerKey])) { + $signerProgress[$signerKey] = ['total' => 0, 'signed' => 0]; + } + + $signerProgress[$signerKey]['total']++; + if ($signRequest->getSigned()) { + $signerProgress[$signerKey]['signed']++; + } + } + } + return $signerProgress; + } + + private function applyProgressToFileData(stdClass $fileData, array $signerProgress): void { + foreach ($fileData->signers as $index => $signer) { + $signerKey = $this->buildSignerKeyFromEnvelopeSigner($signer); + if (isset($signerProgress[$signerKey])) { + $fileData->signers[$index]->totalDocuments = $signerProgress[$signerKey]['total']; + $fileData->signers[$index]->documentsSignedCount = $signerProgress[$signerKey]['signed']; + } else { + $fileData->signers[$index]->totalDocuments = 0; + $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(stdClass $signer): string { + if (empty($signer->identifyMethods)) { + return ''; + } + $keys = []; + foreach ($signer->identifyMethods as $method) { + $keys[] = $method['method'] . ':' . $method['value']; + } + sort($keys); + return implode('|', $keys); + } +} diff --git a/lib/Service/File/FileContentProvider.php b/lib/Service/File/FileContentProvider.php new file mode 100644 index 0000000000..84ac697588 --- /dev/null +++ b/lib/Service/File/FileContentProvider.php @@ -0,0 +1,139 @@ +l10n->t('Invalid URL file')); + } + + try { + $response = $this->client->newClient()->get($url); + } catch (\Throwable) { + throw new \Exception($this->l10n->t('Invalid URL file')); + } + + $mimetypeFromHeader = $response->getHeader('Content-Type'); + $content = (string)$response->getBody(); + + if (!$content) { + throw new \Exception($this->l10n->t('Empty file')); + } + + $mimeTypeFromContent = $this->mimeService->getMimeType($content); + if ($mimetypeFromHeader !== $mimeTypeFromContent) { + throw new \Exception($this->l10n->t('Invalid URL file')); + } + + return $content; + } + + /** + * Decode base64 content and validate MIME type + * + * @throws \Exception if MIME types don't match + */ + public function getContentFromBase64(string $base64): string { + $withMime = explode(',', $base64); + + if (count($withMime) === 2) { + $withMime[0] = explode(';', $withMime[0]); + $withMime[0][0] = explode(':', $withMime[0][0]); + $mimeTypeFromType = $withMime[0][0][1]; + + $base64 = $withMime[1]; + + $content = base64_decode($base64); + $mimeTypeFromContent = $this->mimeService->getMimeType($content); + + if ($mimeTypeFromType !== $mimeTypeFromContent) { + throw new \Exception($this->l10n->t('Invalid URL file')); + } + + $this->mimeService->setMimeType($mimeTypeFromContent); + } else { + $content = base64_decode($base64); + $this->mimeService->getMimeType($content); + } + + return $content; + } + + /** + * Get raw file content from URL or base64 data array + * + * @param array $data Data array containing 'file' with 'url' or 'base64' + * @return string File content + * @throws \Exception if data is invalid + */ + public function getContentFromData(array $data): string { + if (!empty($data['file']['url'])) { + return $this->getContentFromUrl($data['file']['url']); + } + + if (!empty($data['file']['base64'])) { + return $this->getContentFromBase64($data['file']['base64']); + } + + throw new \Exception($this->l10n->t('No file source provided')); + } + + /** + * Get file content from a LibreSign File entity + * + * @param \OCA\Libresign\Db\File $file + * @throws LibresignException + */ + public function getContentFromLibresignFile(\OCA\Libresign\Db\File $file): string { + try { + $nodeId = $file->getSignedNodeId(); + if (!$nodeId) { + $nodeId = $file->getNodeId(); + } + + $fileNode = $this->root->getUserFolder($file->getUserId())->getFirstNodeById($nodeId); + + if (!$fileNode instanceof \OCP\Files\File) { + throw new LibresignException($this->l10n->t('File not found'), 404); + } + + return $fileNode->getContent(); + } catch (LibresignException $e) { + throw $e; + } catch (\Throwable $e) { + $this->logger->error('Failed to get file content: ' . $e->getMessage(), [ + 'fileId' => $file->getId(), + 'exception' => $e, + ]); + throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404, $e); + } + } +} diff --git a/lib/Service/File/FileDataAssembler.php b/lib/Service/File/FileDataAssembler.php new file mode 100644 index 0000000000..bd1667f7ac --- /dev/null +++ b/lib/Service/File/FileDataAssembler.php @@ -0,0 +1,110 @@ +id = $file->getId(); + $fileData->uuid = $file->getUuid(); + $fileData->name = $file->getName(); + $fileData->status = $file->getStatus(); + $fileData->created_at = $file->getCreatedAt()->format(DateTimeInterface::ATOM); + $fileData->statusText = $this->fileMapper->getTextOfStatus($file->getStatus()); + $fileData->nodeId = $file->getNodeId(); + $fileData->signatureFlow = $file->getSignatureFlow(); + $fileData->docmdpLevel = $file->getDocmdpLevel(); + $fileData->nodeType = $file->getNodeType(); + + if ($fileData->nodeType !== 'envelope' && !$file->getParentFileId()) { + $fileId = $file->getId(); + $childrenFiles = $this->fileMapper->getChildrenFiles($fileId); + if (!empty($childrenFiles)) { + $file->setNodeType('envelope'); + $this->fileMapper->update($file); + + $fileData->nodeType = 'envelope'; + $fileData->filesCount = count($childrenFiles); + $fileData->files = []; + } + } + + if ($fileData->nodeType === 'envelope') { + $metadata = $file->getMetadata(); + $fileData->filesCount = $metadata['filesCount'] ?? 0; + $fileData->files = []; + $childrenFiles = $this->fileMapper->getChildrenFiles($file->getId()); + foreach ($childrenFiles as $childFile) { + $fileData->files[] = $this->envelopeAssembler->buildEnvelopeChildData($childFile, $options); + } + if ($file->getStatus() === File::STATUS_SIGNED) { + $latestSignedDate = null; + foreach ($childrenFiles as $childFile) { + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + foreach ($signRequests as $signRequest) { + $signed = $signRequest->getSigned(); + if ($signed && (!$latestSignedDate || $signed > $latestSignedDate)) { + $latestSignedDate = $signed; + } + } + } + if ($latestSignedDate) { + $fileData->signedDate = $latestSignedDate->format(DateTimeInterface::ATOM); + } + } + } + + $fileData->requested_by = [ + 'userId' => $file->getUserId(), + 'displayName' => $this->userManager->get($file->getUserId())->getDisplayName(), + ]; + $fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $file->getUuid()]); + + if ($options->isShowVisibleElements()) { + // For envelopes, the visibleElements are in the child files (buildEnvelopeChildData) + if ($fileData->nodeType === 'envelope') { + // The visibleElements of each child file are already loaded in the EnvelopeAssembler + // No need to duplicate the logic here + return; + } + + // For individual files, fetch their visibleElements + $signers = $this->signRequestMapper->getByFileId($file->getId()); + $fileData->visibleElements = []; + foreach ($this->signRequestMapper->getVisibleElementsFromSigners($signers) as $row) { + if (empty($row)) { + continue; + } + $fileMetadata = $file->getMetadata(); + $fileData->visibleElements = array_merge( + $this->fileElementService->formatVisibleElements($row, $fileMetadata), + $fileData->visibleElements + ); + } + } + } +} diff --git a/lib/Service/File/FileListService.php b/lib/Service/File/FileListService.php new file mode 100644 index 0000000000..88782251c9 --- /dev/null +++ b/lib/Service/File/FileListService.php @@ -0,0 +1,267 @@ +, pagination: LibresignPagination} + */ + public function listAssociatedFilesOfSignFlow( + IUser $user, + $page = null, + $length = null, + array $filter = [], + array $sort = [], + ): array { + $page ??= 1; + $length ??= (int)$this->appConfig->getValueInt(Application::APP_ID, 'length_of_page', 100); + + $return = $this->signRequestMapper->getFilesAssociatedFilesWithMe( + $user, + $filter, + $page, + $length, + $sort, + ); + + $signers = $this->signRequestMapper->getByMultipleFileId(array_map(fn (File $file) => $file->getId(), $return['data'])); + $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers); + $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers); + $return['data'] = $this->associateAllAndFormat($user, $return['data'], $signers, $identifyMethods, $visibleElements); + + $return['pagination']->setRouteName('ocs.libresign.File.list'); + return [ + 'data' => $return['data'], + 'pagination' => $return['pagination']->getPagination($page, $length, $filter), + ]; + } + + public function formatSingleFile(IUser $user, File $file): array { + $signers = $this->signRequestMapper->getByMultipleFileId([$file->getId()]); + $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers); + $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers); + + return $this->formatSingleFileData($file, $signers, $identifyMethods, $visibleElements, $user); + } + + /** + * @return list + */ + private function associateAllAndFormat( + IUser $user, + array $files, + array $signers, + array $identifyMethods, + array $visibleElements, + ): array { + $formattedFiles = []; + foreach ($files as $file) { + $fileSigners = array_filter($signers, fn ($signer) => $signer->getFileId() === $file->getId()); + $formattedFiles[] = $this->formatSingleFileData($file, $fileSigners, $identifyMethods, $visibleElements, $user); + } + return $formattedFiles; + } + + /** + * Format a single file with its signers, identifyMethods and visibleElements. + * Core formatting used by list and single file operations. + * + * @return LibresignFileDetail + * @psalm-suppress MoreSpecificReturnType + */ + private function formatSingleFileData( + File $fileEntity, + array $signers, + array $identifyMethods, + array $visibleElements, + IUser $user, + ): array { + $file = [ + 'id' => $fileEntity->getId(), + 'nodeId' => $fileEntity->getNodeId(), + 'uuid' => $fileEntity->getUuid(), + 'name' => $fileEntity->getName(), + 'status' => $fileEntity->getStatus(), + 'metadata' => $fileEntity->getMetadata(), + 'createdAt' => $fileEntity->getCreatedAt(), + 'userId' => $fileEntity->getUserId(), + 'signatureFlow' => $fileEntity->getSignatureFlow(), + 'nodeType' => $fileEntity->getNodeType(), + ]; + $file['signatureFlow'] = SignatureFlow::fromNumeric($file['signatureFlow'])->value; + $file['statusText'] = $this->fileMapper->getTextOfStatus($file['status']); + $file['requested_by'] = [ + 'userId' => $file['userId'], + 'displayName' => $this->userManager->get($file['userId'])?->getDisplayName(), + ]; + $file['created_at'] = $file['createdAt']->setTimezone(new \DateTimeZone('UTC'))->format(DateTimeInterface::ATOM); + $file['file'] = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $file['uuid']]); + + if ($file['nodeType'] === 'envelope') { + $file['filesCount'] = $file['metadata']['filesCount'] ?? 0; + $file['files'] = []; + } else { + $file['filesCount'] = 1; + $file['files'] = [[ + 'fileId' => $file['id'], + 'nodeId' => $file['nodeId'], + 'uuid' => $file['uuid'], + 'name' => $file['name'], + 'status' => $file['status'], + 'statusText' => $file['statusText'], + ]]; + } + + // Remove raw fields not needed in response + unset($file['userId'], $file['createdAt']); + + $file['signers'] = []; + foreach ($signers as $signer) { + if ($signer->getFileId() !== $fileEntity->getId()) { + continue; + } + + $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? []; + $data = [ + 'email' => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { + if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { + return $identifyMethod->getIdentifierValue(); + } + if (filter_var($identifyMethod->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) { + return $identifyMethod->getIdentifierValue(); + } + return $carry; + }, ''), + 'description' => $signer->getDescription(), + 'displayName' => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { + if (!$carry && $identifyMethod->getMandatory()) { + return $identifyMethod->getIdentifierValue(); + } + return $carry; + }, $signer->getDisplayName()), + 'request_sign_date' => $signer->getCreatedAt()->format(DateTimeInterface::ATOM), + 'signed' => null, + 'signRequestId' => $signer->getId(), + 'signingOrder' => $signer->getSigningOrder(), + 'status' => $signer->getStatus(), + 'statusText' => $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()), + 'me' => array_reduce($identifyMethodsOfSigner, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool { + if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) { + return $user->getUID() === $identifyMethod->getIdentifierValue(); + } + if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL && $user->getEMailAddress()) { + return $user->getEMailAddress() === $identifyMethod->getIdentifierValue(); + } + return $carry; + }, false), + 'visibleElements' => isset($visibleElements[$signer->getId()]) + ? $this->fileElementService->formatVisibleElements( + $visibleElements[$signer->getId()], + $file['metadata'], + ) + : [], + 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ + 'method' => $identifyMethod->getIdentifierKey(), + 'value' => $identifyMethod->getIdentifierValue(), + 'mandatory' => $identifyMethod->getMandatory(), + ], array_values($identifyMethodsOfSigner)), + ]; + + if ($data['me']) { + $temp = array_map(function (IdentifyMethod $identifyMethodEntity) use ($signer): array { + $this->identifyMethodService->setCurrentIdentifyMethod($identifyMethodEntity); + $identifyMethod = $this->identifyMethodService + ->setIsRequest(false) + ->getInstanceOfIdentifyMethod( + $identifyMethodEntity->getIdentifierKey(), + $identifyMethodEntity->getIdentifierValue(), + ); + $signatureMethods = $identifyMethod->getSignatureMethods(); + $return = []; + foreach ($signatureMethods as $signatureMethod) { + if (!$signatureMethod->isEnabled()) { + continue; + } + $signatureMethod->setEntity($identifyMethod->getEntity()); + $return[$signatureMethod->getName()] = $signatureMethod->toArray(); + } + return $return; + }, array_values($identifyMethodsOfSigner)); + $data['signatureMethods'] = []; + foreach ($temp as $methods) { + $data['signatureMethods'] = array_merge($data['signatureMethods'], $methods); + } + $data['sign_uuid'] = $signer->getUuid(); + $file['url'] = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $signer->getuuid()]); + } + + if ($signer->getSigned()) { + $data['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM); + } + ksort($data); + $file['signers'][] = $data; + } + + if (empty($file['signers'])) { + $file['statusText'] = $this->l10n->t('no signers'); + $file['visibleElements'] = []; + } else { + usort($file['signers'], function ($a, $b) { + $orderA = $a['signingOrder'] ?? PHP_INT_MAX; + $orderB = $b['signingOrder'] ?? PHP_INT_MAX; + return $orderA <=> $orderB ?: (($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0)); + }); + + $file['statusText'] = $this->fileMapper->getTextOfStatus((int)$file['status']); + $file['visibleElements'] = []; + foreach ($file['signers'] as $signer) { + if (!empty($signer['visibleElements']) && is_array($signer['visibleElements'])) { + $file['visibleElements'] = array_merge($file['visibleElements'], $signer['visibleElements']); + } + } + } + + ksort($file); + /** @psalm-suppress LessSpecificReturnStatement,MoreSpecificReturnType */ + return $file; + } +} diff --git a/lib/Service/File/FileResponseOptions.php b/lib/Service/File/FileResponseOptions.php new file mode 100644 index 0000000000..7ab3ec8f9c --- /dev/null +++ b/lib/Service/File/FileResponseOptions.php @@ -0,0 +1,104 @@ +showSigners = $show; + return $this; + } + + public function isShowSigners(): bool { + return $this->showSigners; + } + + public function showSettings(bool $show = true): self { + $this->showSettings = $show; + return $this; + } + + public function isShowSettings(): bool { + return $this->showSettings; + } + + public function showVisibleElements(bool $show = true): self { + $this->showVisibleElements = $show; + return $this; + } + + public function isShowVisibleElements(): bool { + return $this->showVisibleElements; + } + + public function showMessages(bool $show = true): self { + $this->showMessages = $show; + return $this; + } + + public function isShowMessages(): bool { + return $this->showMessages; + } + + public function validateFile(bool $validate = true): self { + $this->validateFile = $validate; + return $this; + } + + public function isValidateFile(): bool { + return $this->validateFile; + } + + public function setSignerIdentified(bool $identified = true): self { + $this->signerIdentified = $identified; + return $this; + } + + public function isSignerIdentified(): bool { + return $this->signerIdentified; + } + + public function setMe(?IUser $user): self { + $this->me = $user; + return $this; + } + + public function getMe(): ?IUser { + return $this->me; + } + + public function setIdentifyMethodId(?int $id): self { + $this->identifyMethodId = $id; + return $this; + } + + public function getIdentifyMethodId(): ?int { + return $this->identifyMethodId; + } + + public function setHost(string $host): self { + $this->host = $host; + return $this; + } + + public function getHost(): string { + return $this->host; + } +} diff --git a/lib/Service/File/MessagesLoader.php b/lib/Service/File/MessagesLoader.php new file mode 100644 index 0000000000..987f2d570c --- /dev/null +++ b/lib/Service/File/MessagesLoader.php @@ -0,0 +1,57 @@ +isShowMessages()) { + return; + } + + $messages = []; + + if (isset($fileData->settings['canSign']) && $fileData->settings['canSign']) { + $messages[] = [ + 'type' => 'info', + 'message' => $this->l10n->t('You need to sign this document') + ]; + } + + if (isset($fileData->settings['canRequestSign']) && $fileData->settings['canRequestSign']) { + $this->signersLoader->loadLibreSignSigners($file, $fileData, $options, $certData); + + if (empty($fileData->signers)) { + $messages[] = [ + 'type' => 'info', + 'message' => $this->l10n->t('You cannot request signature for this document, please contact your administrator') + ]; + } + } + + if (!empty($messages)) { + $fileData->messages = $messages; + } + } +} diff --git a/lib/Service/File/MetadataLoader.php b/lib/Service/File/MetadataLoader.php new file mode 100644 index 0000000000..50cb244af7 --- /dev/null +++ b/lib/Service/File/MetadataLoader.php @@ -0,0 +1,107 @@ +getFileNode($file); + $metadata = $file->getMetadata() ?? []; + + $fileData->metadata = $metadata; + + if (method_exists($fileNode, 'getSize')) { + $fileData->size = $fileNode->getSize(); + } + + if (method_exists($fileNode, 'getMimeType')) { + $fileData->mime = $fileNode->getMimeType(); + } else { + $content = $this->contentProvider->getContentFromLibresignFile($file); + $fileData->mime = $this->mimeTypeDetector->detectString($content); + } + + $fileData->pages = $this->getPages($file); + + $fileData->totalPages = (int)($metadata['p'] ?? count($fileData->pages ?? [])); + $fileData->pdfVersion = (string)($metadata['pdfVersion'] ?? ''); + } catch (\Throwable $e) { + $this->logger->warning('Failed to load file metadata: ' . $e->getMessage()); + } + + $fileData->totalPages ??= 0; + $fileData->pdfVersion ??= ''; + } + + /** + * Get file node from File entity + * + * @throws \OCA\Libresign\Exception\LibresignException + */ + private function getFileNode(File $file): \OCP\Files\File { + $nodeId = $file->getSignedNodeId(); + if (!$nodeId) { + $nodeId = $file->getNodeId(); + } + + $fileNode = $this->root->getUserFolder($file->getUserId())->getFirstNodeById($nodeId); + + if (!$fileNode instanceof \OCP\Files\File) { + throw new \OCA\Libresign\Exception\LibresignException('File not found', 404); + } + + return $fileNode; + } + + /** + * Get pages array with URLs and resolutions + * + * @return array + */ + private function getPages(File $file): array { + $return = []; + + $metadata = $file->getMetadata(); + $pageCount = $metadata['p'] ?? 0; + + for ($page = 1; $page <= $pageCount; $page++) { + $return[] = [ + 'url' => $this->urlGenerator->linkToRoute('ocs.libresign.File.getPage', [ + 'apiVersion' => 'v1', + 'uuid' => $file->getUuid(), + 'page' => $page, + ]), + 'resolution' => $metadata['d'][$page - 1] ?? null, + ]; + } + + return $return; + } +} diff --git a/lib/Service/File/MimeService.php b/lib/Service/File/MimeService.php new file mode 100644 index 0000000000..2a01f24e0f --- /dev/null +++ b/lib/Service/File/MimeService.php @@ -0,0 +1,70 @@ +validateHelper->validateMimeTypeAcceptedByMime($mimetype); + $this->mimetype = $mimetype; + } + + public function getMimeType(string $content): string { + if ($this->mimetype === null) { + $detected = $this->mimeTypeDetector->detectString($content); + $this->setMimeType($detected); + } + // After setMimeType, mimetype is guaranteed to be non-null + // (or an exception was thrown) + assert($this->mimetype !== null); + return $this->mimetype; + } + + public function getExtension(string $content): string { + $mimetype = $this->getMimeType($content); + $mappings = $this->mimeTypeDetector->getAllMappings(); + + foreach ($mappings as $ext => $mimetypes) { + $ext = (string)$ext; + // Skip internal mappings starting with underscore + if ($ext[0] === '_') { + continue; + } + if (in_array($mimetype, $mimetypes)) { + return $ext; + } + } + + return ''; + } + + public function reset(): void { + $this->mimetype = null; + } + + public function getCurrentMimeType(): ?string { + return $this->mimetype; + } +} diff --git a/lib/Service/File/PdfValidator.php b/lib/Service/File/PdfValidator.php new file mode 100644 index 0000000000..5535e47b6a --- /dev/null +++ b/lib/Service/File/PdfValidator.php @@ -0,0 +1,56 @@ +parseContent($content); + } catch (\Throwable $th) { + $this->logger->error($th->getMessage()); + throw new \Exception($this->l10n->t('Invalid PDF')); + } + + $resource = fopen('php://memory', 'r+'); + if (!is_resource($resource)) { + return; + } + + try { + fwrite($resource, $content); + rewind($resource); + + if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) { + throw new LibresignException($this->l10n->t('This document has been certified with no changes allowed, so no additional signatures can be added.')); + } + } finally { + fclose($resource); + } + } +} diff --git a/lib/Service/File/SettingsLoader.php b/lib/Service/File/SettingsLoader.php new file mode 100644 index 0000000000..5a509fb4b9 --- /dev/null +++ b/lib/Service/File/SettingsLoader.php @@ -0,0 +1,120 @@ +isShowSettings()) { + return; + } + + $fileData->settings = [ + 'canSign' => false, + 'canRequestSign' => false, + 'signerFileUuid' => null, + 'phoneNumber' => '', + 'hasSignatureFile' => false, + 'needIdentificationDocuments' => false, + 'identificationDocumentsWaitingApproval' => false, + ]; + + if ($options->getMe()) { + $fileData->settings = array_merge( + $fileData->settings, + $this->accountSettingsProvider->getSettings($options->getMe()) + ); + $fileData->settings['phoneNumber'] = $this->accountSettingsProvider->getPhoneNumber($options->getMe()); + } + + if ($options->isSignerIdentified() || $options->getMe()) { + $status = $this->getIdentificationDocumentsStatus( + $options->getMe() ? $options->getMe()->getUID() : '' + ); + + if ($status === self::IDENTIFICATION_DOCUMENTS_NEED_SEND) { + $fileData->settings['needIdentificationDocuments'] = true; + $fileData->settings['identificationDocumentsWaitingApproval'] = false; + } elseif ($status === self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL) { + $fileData->settings['needIdentificationDocuments'] = true; + $fileData->settings['identificationDocumentsWaitingApproval'] = true; + } + } + } + + public function getIdentificationDocumentsStatus(string $userId = ''): int { + if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { + return self::IDENTIFICATION_DOCUMENTS_DISABLED; + } + + if (empty($userId)) { + return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; + } + + $files = $this->fileMapper->getFilesOfAccount($userId); + + if (empty($files) || !count($files)) { + return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; + } + + $deleted = array_filter($files, fn (File $file) => $file->getStatus() === File::STATUS_DELETED); + if (count($deleted) === count($files)) { + return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; + } + + $signed = array_filter($files, fn (File $file) => $file->getStatus() === File::STATUS_SIGNED); + if (count($signed) !== count($files)) { + return self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL; + } + + return self::IDENTIFICATION_DOCUMENTS_APPROVED; + } + + /** + * Get user identification documents settings + * These are user-specific settings, not file-specific + * Always returns complete LibresignSettings with defaults + * + * @return array{canSign: bool, canRequestSign: bool, signerFileUuid: ?string, phoneNumber: string, hasSignatureFile: bool, needIdentificationDocuments: bool, identificationDocumentsWaitingApproval: bool} + */ + public function getUserIdentificationSettings(string $userId): array { + $status = $this->getIdentificationDocumentsStatus($userId); + + return [ + 'canSign' => false, + 'canRequestSign' => false, + 'signerFileUuid' => null, + 'phoneNumber' => '', + 'hasSignatureFile' => false, + 'needIdentificationDocuments' => in_array($status, [ + self::IDENTIFICATION_DOCUMENTS_NEED_SEND, + self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL, + ], true), + 'identificationDocumentsWaitingApproval' => $status === self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL, + ]; + } +} diff --git a/lib/Service/File/SignersLoader.php b/lib/Service/File/SignersLoader.php new file mode 100644 index 0000000000..253ed02906 --- /dev/null +++ b/lib/Service/File/SignersLoader.php @@ -0,0 +1,259 @@ +signersLibreSignLoaded || !$file) { + return; + } + $signers = $this->signRequestMapper->getByFileId($file->getId()); + foreach ($signers as $signer) { + $identifyMethods = $this->identifyMethodService + ->setIsRequest(false) + ->getIdentifyMethodsFromSignRequestId($signer->getId()); + if (!empty($fileData->signers)) { + $found = array_filter($fileData->signers, function (stdClass $found) use ($identifyMethods) { + if (!isset($found->uid)) { + return false; + } + [$key, $value] = explode(':', (string)$found->uid); + foreach ($identifyMethods as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + if ($key === $entity->getIdentifierKey() && $value === $entity->getIdentifierValue()) { + return true; + } + } + } + return false; + }); + if (!empty($found)) { + $index = key($found); + } else { + $index = count($fileData->signers); + } + } else { + $index = 0; + } + if (!isset($fileData->signers[$index])) { + $fileData->signers[$index] = new stdClass(); + } + $fileData->signers[$index]->signRequestId = $signer->getId(); + $fileData->signers[$index]->signed = $signer->getSigned()?->format(DateTimeInterface::ATOM); + $fileData->signers[$index]->status = $signer->getStatus(); + $fileData->signers[$index]->statusText = $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()); + $fileData->signers[$index]->signingOrder = $signer->getSigningOrder(); + $fileData->signers[$index]->description = $signer->getDescription(); + $fileData->signers[$index]->displayName = $signer->getDisplayName(); + $fileData->signers[$index]->request_sign_date = $signer->getCreatedAt()->format(DateTimeInterface::ATOM); + $fileData->signers[$index]->identifyMethods = []; + $fileData->signers[$index]->visibleElements = []; + foreach ($identifyMethods as $type => $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + + $fileData->signers[$index]->identifyMethods[] = [ + 'method' => $entity->getIdentifierKey(), + 'value' => $entity->getIdentifierValue(), + 'mandatory' => $entity->getMandatory(), + ]; + + switch ($type) { + case 'account': + $fileData->signers[$index]->displayName = $entity->getIdentifierValue(); + $fileData->signers[$index]->uid = $entity->getIdentifierKey() . ':' . $entity->getIdentifierValue(); + if (!isset($fileData->signers[$index]->email)) { + $user = $this->userManager->get($entity->getIdentifierValue()); + if (!$user) { + break; + } + $account = $this->accountManager->getAccount($user); + $fileData->signers[$index]->email = $account->getProperty('email'); + } + break; + case 'email': + $fileData->signers[$index]->email = $entity->getIdentifierValue(); + $fileData->signers[$index]->uid = $entity->getIdentifierKey() . ':' . $entity->getIdentifierValue(); + if (!isset($fileData->signers[$index]->displayName)) { + $fileData->signers[$index]->displayName = $entity->getIdentifierValue(); + } + break; + case 'signatureinit': + $fileData->signers[$index]->signatureMethod = 'password'; + if (!isset($fileData->signers[$index]->email)) { + $fileData->signers[$index]->email = ''; + } + break; + case 'password': + $fileData->signers[$index]->signatureMethod = 'password'; + if (!isset($fileData->signers[$index]->email)) { + $fileData->signers[$index]->email = ''; + } + break; + } + } + } + if (isset($fileData->signers[$index]->uid)) { + $split = explode(':', $fileData->signers[$index]->uid); + $matches = [ + 'key' => $split[0], + 'value' => $split[1], + ]; + if (str_ends_with($matches['value'], $options->getHost())) { + $uid = str_replace('@' . $options->getHost(), '', $matches['value']); + $fileData->signers[$index]->displayName = $uid; + $fileData->signers[$index]->uid = 'account:' . $uid; + } + } + $fileData->signers[$index]->me = false; + if ($options->getMe() || $options->getIdentifyMethodId()) { + $currentUserData = new stdClass(); + $currentUserData->me = false; + foreach ($identifyMethods as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + if ($options->getIdentifyMethodId() === $entity->getId() + || $options->getMe()?->getUID() === $entity->getIdentifierValue() + || $options->getMe()?->getEMailAddress() === $entity->getIdentifierValue() + ) { + $currentUserData->me = true; + break 2; + } + } + } + $fileData->signers[$index]->me = $currentUserData->me; + } + + if ($fileData->signers[$index]->me) { + $fileData->signers[$index]->sign_uuid = $signer->getUuid(); + if (!$signer->getSigned() && isset($fileData->settings)) { + $fileData->settings['canSign'] = true; + $fileData->settings['signerFileUuid'] = $signer->getUuid(); + } + $fileData->signers[$index]->signatureMethods = []; + foreach ($identifyMethods as $methods) { + foreach ($methods as $identifyMethod) { + $entity = $identifyMethod->getEntity(); + $this->identifyMethodService->setCurrentIdentifyMethod($entity); + $identifyMethodInstance = $this->identifyMethodService + ->setIsRequest(false) + ->getInstanceOfIdentifyMethod( + $entity->getIdentifierKey(), + $entity->getIdentifierValue(), + ); + $signatureMethods = $identifyMethodInstance->getSignatureMethods(); + foreach ($signatureMethods as $signatureMethod) { + if (!$signatureMethod->isEnabled()) { + continue; + } + $signatureMethod->setEntity($identifyMethodInstance->getEntity()); + $fileData->signers[$index]->signatureMethods[$signatureMethod->getName()] = $signatureMethod->toArray(); + } + } + } + } + } + ksort($fileData->signers); + $this->signersLibreSignLoaded = true; + } + + public function loadSignersFromCertData(stdClass $fileData, array $certData, string $host): void { + foreach ($certData as $index => $signer) { + if (!isset($fileData->signers[$index])) { + $fileData->signers[$index] = new stdClass(); + } + $fileData->signers[$index]->status = 2; + $fileData->signers[$index]->statusText = $this->signRequestMapper->getTextOfSignerStatus(2); + + if (isset($signer['timestamp'])) { + $fileData->signers[$index]->timestamp = $signer['timestamp']; + if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) { + $fileData->signers[$index]->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); + } + } + if (isset($signer['signingTime']) && $signer['signingTime'] instanceof DateTimeInterface) { + $fileData->signers[$index]->signingTime = $signer['signingTime']; + $fileData->signers[$index]->signed = $signer['signingTime']->format(DateTimeInterface::ATOM); + } + if (isset($signer['docmdp'])) { + $fileData->signers[$index]->docmdp = $signer['docmdp']; + } + if (isset($signer['modifications'])) { + $fileData->signers[$index]->modifications = $signer['modifications']; + } + if (isset($signer['modification_validation'])) { + $fileData->signers[$index]->modification_validation = $signer['modification_validation']; + } + if (isset($signer['chain'])) { + $fileData->signers[$index]->chain = []; + foreach ($signer['chain'] as $chainIndex => $chainItem) { + $chainArr = $chainItem; + if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { + $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { + $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); + } + $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); + $fileData->signers[$index]->chain[$chainIndex] = $chainArr; + if ($chainIndex === 0) { + foreach ($chainArr as $key => $value) { + if (!isset($fileData->signers[$index]->$key)) { + $fileData->signers[$index]->$key = $value; + } + } + $fileData->signers[$index]->uid = $this->identifyMethodService->resolveUid($chainArr, $host); + } + } + } + if (isset($signer['uid'])) { + $fileData->signers[$index]->uid = $signer['uid']; + } + if (isset($signer['signDate'])) { + $fileData->signers[$index]->signDate = $signer['signDate']; + } + if (isset($signer['type'])) { + $fileData->signers[$index]->type = $signer['type']; + } + } + } + + public function reset(): void { + $this->signersLibreSignLoaded = false; + } + +} diff --git a/lib/Service/File/UploadProcessor.php b/lib/Service/File/UploadProcessor.php new file mode 100644 index 0000000000..dca0421cbd --- /dev/null +++ b/lib/Service/File/UploadProcessor.php @@ -0,0 +1,136 @@ +folderService->getUserId()) { + $this->folderService->setUserId($data['userManager']->getUID()); + } + + $uploadedFile = $data['uploadedFile']; + + $this->uploadHelper->validateUploadedFile($uploadedFile); + $content = $this->uploadHelper->readUploadedFile($uploadedFile); + + $extension = $this->mimeService->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); + } + + /** + * Process multiple 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 { + $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); + } + } + } + + /** + * Validate file content based on extension + * + * @throws \Exception + * @throws LibresignException + */ + private function validateFileContent(string $content, string $extension): void { + if ($extension === 'pdf') { + $this->pdfValidator->validate($content); + } + } + + /** + * Rollback created nodes on error + * + * @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(), + ]); + } + } + } +} diff --git a/lib/Service/FileElementService.php b/lib/Service/FileElementService.php index 9cc33d023f..dcc8a3e8d9 100644 --- a/lib/Service/FileElementService.php +++ b/lib/Service/FileElementService.php @@ -12,8 +12,12 @@ use OCA\Libresign\Db\FileElement; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; +use OCA\Libresign\ResponseDefinitions; use OCP\AppFramework\Utility\ITimeFactory; +/** + * @psalm-import-type LibresignVisibleElement from ResponseDefinitions + */ class FileElementService { public function __construct( private FileMapper $fileMapper, @@ -22,8 +26,8 @@ public function __construct( ) { } - public function saveVisibleElement(array $element, string $uuid = ''): FileElement { - $fileElement = $this->getVisibleElementFromProperties($element, $uuid); + public function saveVisibleElement(array $element): FileElement { + $fileElement = $this->getVisibleElementFromProperties($element); if ($fileElement->getId()) { $this->fileElementMapper->update($fileElement); } else { @@ -32,7 +36,7 @@ public function saveVisibleElement(array $element, string $uuid = ''): FileEleme return $fileElement; } - private function getVisibleElementFromProperties(array $properties, string $uuid = ''): FileElement { + private function getVisibleElementFromProperties(array $properties): FileElement { if (!empty($properties['elementId'])) { $fileElement = $this->fileElementMapper->getById($properties['elementId']); } else { @@ -40,13 +44,16 @@ private function getVisibleElementFromProperties(array $properties, string $uuid $fileElement->setCreatedAt($this->timeFactory->getDateTime()); } $file = null; - if ($uuid) { - $file = $this->fileMapper->getByUuid($uuid); + if (!empty($properties['uuid'])) { + $file = $this->fileMapper->getByUuid($properties['uuid']); $fileElement->setFileId($file->getId()); } elseif (!empty($properties['fileId'])) { $file = $this->fileMapper->getById($properties['fileId']); $fileElement->setFileId($properties['fileId']); } + if (!$file) { + throw new \InvalidArgumentException('File not found for visible element'); + } $coordinates = $this->translateCoordinatesToInternalNotation($properties, $file); $fileElement->setSignRequestId($properties['signRequestId']); $fileElement->setType($properties['type']); @@ -114,18 +121,6 @@ private function translateCoordinatesToInternalNotation(array $properties, File return $translated; } - public function translateCoordinatesFromInternalNotation(array $properties, File $file): array { - $metadata = $file->getMetadata(); - $dimension = $metadata['d'][$properties['coordinates']['page'] - 1]; - - $translated['left'] = $properties['coordinates']['llx']; - $translated['height'] = abs($properties['coordinates']['ury'] - $properties['coordinates']['lly']); - $translated['top'] = $dimension['h'] - $properties['coordinates']['ury']; - $translated['width'] = $properties['coordinates']['urx'] - $properties['coordinates']['llx']; - - return $translated; - } - public function deleteVisibleElement(int $elementId): void { $fileElement = new FileElement(); $fileElement = $fileElement->fromRow(['id' => $elementId]); @@ -138,4 +133,52 @@ public function deleteVisibleElements(int $fileId): void { $this->fileElementMapper->delete($visibleElement); } } + + /** + * Return visible elements formatted for API responses for given file and signRequestId + * + * @psalm-return list + */ + public function getVisibleElementsForSignRequest(File $file, int $signRequestId): array { + $rows = $this->fileElementMapper->getByFileIdAndSignRequestId($file->getId(), $signRequestId); + return $this->formatVisibleElements($rows, $file->getMetadata()); + } + + /** + * Format visible elements returned from DB rows for API responses. + * + * @param array $visibleElements Array of file elements as returned by mappers + * @param array $fileMetadata Metadata of the file (expects page dimensions under key 'd') + * @psalm-return list + */ + public function formatVisibleElements(array $visibleElements, array $fileMetadata = []): array { + $result = []; + foreach ($visibleElements as $fileElement) { + $elementMetadata = $fileElement->getMetadata(); + $metadata = $fileMetadata ?: (is_array($elementMetadata) ? $elementMetadata : []); + $dimension = $metadata['d'][$fileElement->getPage() - 1] ?? ['h' => 0]; + $height = (int)abs($fileElement->getUry() - $fileElement->getLly()); + $width = (int)abs($fileElement->getUrx() - $fileElement->getLlx()); + $top = (int)abs($dimension['h'] - $fileElement->getUry()); + $left = (int)$fileElement->getLlx(); + $result[] = [ + 'elementId' => $fileElement->getId(), + 'signRequestId' => $fileElement->getSignRequestId(), + 'fileId' => $fileElement->getFileId(), + 'type' => $fileElement->getType(), + 'coordinates' => [ + 'page' => $fileElement->getPage(), + 'urx' => $fileElement->getUrx(), + 'ury' => $fileElement->getUry(), + 'llx' => (int)$fileElement->getLlx(), + 'lly' => (int)$fileElement->getLly(), + 'left' => $left, + 'top' => $top, + 'width' => $width, + 'height' => $height, + ], + ]; + } + return $result; + } } diff --git a/lib/Service/FileService.php b/lib/Service/FileService.php index 4ce3899d32..e72fcc99a3 100644 --- a/lib/Service/FileService.php +++ b/lib/Service/FileService.php @@ -8,62 +8,56 @@ namespace OCA\Libresign\Service; -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; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\IdDocsMapper; -use OCA\Libresign\Db\IdentifyMethod; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Handler\SignEngine\Pkcs12Handler; -use OCA\Libresign\Helper\ValidateHelper; +use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\ResponseDefinitions; -use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; -use OCP\Accounts\IAccountManager; +use OCA\Libresign\Service\File\CertificateChainService; +use OCA\Libresign\Service\File\EnvelopeAssembler; +use OCA\Libresign\Service\File\EnvelopeProgressService; +use OCA\Libresign\Service\File\FileContentProvider; +use OCA\Libresign\Service\File\FileResponseOptions; +use OCA\Libresign\Service\File\MessagesLoader; +use OCA\Libresign\Service\File\MetadataLoader; +use OCA\Libresign\Service\File\MimeService; +use OCA\Libresign\Service\File\PdfValidator; +use OCA\Libresign\Service\File\SettingsLoader; +use OCA\Libresign\Service\File\SignersLoader; +use OCA\Libresign\Service\File\UploadProcessor; 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; -use OCP\IDateTimeFormatter; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; -use OCP\IUserSession; use Psr\Log\LoggerInterface; use stdClass; /** + * @psalm-import-type LibresignEnvelopeChildFile from ResponseDefinitions * @psalm-import-type LibresignValidateFile from ResponseDefinitions + * @psalm-import-type LibresignVisibleElement from ResponseDefinitions */ class FileService { - use TFile; - - private bool $showSigners = false; - private bool $showSettings = false; - private bool $showVisibleElements = false; - private bool $showMessages = false; - private bool $validateFile = false; - private bool $signersLibreSignLoaded = false; - private bool $signerIdentified = false; + private string $fileContent = ''; - private string $host = ''; private ?File $file = null; private ?SignRequest $signRequest = null; - private ?IUser $me = null; - private ?int $identifyMethodId = null; private array $certData = []; private stdClass $fileData; + private FileResponseOptions $options; public const IDENTIFICATION_DOCUMENTS_DISABLED = 0; public const IDENTIFICATION_DOCUMENTS_NEED_SEND = 1; public const IDENTIFICATION_DOCUMENTS_NEED_APPROVAL = 2; @@ -74,34 +68,115 @@ public function __construct( protected FileElementMapper $fileElementMapper, protected FileElementService $fileElementService, protected FolderService $folderService, - protected ValidateHelper $validateHelper, - protected PdfParserService $pdfParserService, private IdDocsMapper $idDocsMapper, - private AccountService $accountService, private IdentifyMethodService $identifyMethodService, - private IUserSession $userSession, private IUserManager $userManager, - private IAccountManager $accountManager, - protected IClientService $client, - private IDateTimeFormatter $dateTimeFormatter, - private IAppConfig $appConfig, private IURLGenerator $urlGenerator, protected IMimeTypeDetector $mimeTypeDetector, protected Pkcs12Handler $pkcs12Handler, - DocMdpHandler $docMdpHandler, + protected DocMdpHandler $docMdpHandler, + protected PdfValidator $pdfValidator, private IRootFolder $root, protected LoggerInterface $logger, protected IL10N $l10n, + private EnvelopeService $envelopeService, + private SignersLoader $signersLoader, + protected FileUploadHelper $uploadHelper, + private EnvelopeAssembler $envelopeAssembler, + private EnvelopeProgressService $envelopeProgressService, + private CertificateChainService $certificateChainService, + private MimeService $mimeService, + private FileContentProvider $contentProvider, + private UploadProcessor $uploadProcessor, + private MetadataLoader $metadataLoader, + private SettingsLoader $settingsLoader, + private MessagesLoader $messagesLoader, ) { - $this->docMdpHandler = $docMdpHandler; $this->fileData = new stdClass(); + $this->options = new FileResponseOptions(); + } + + 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']; + } + if (isset($data['file']['fileId'])) { + return $this->folderService->getFileById($data['file']['fileId']); + } + if (isset($data['file']['path'])) { + return $this->folderService->getFileByPath($data['file']['path']); + } + + $content = $this->getFileRaw($data); + $extension = $this->getExtension($content); + + $this->validateFileContent($content, $extension); + + $userFolder = $this->folderService->getFolder(); + $folderName = $this->folderService->getFolderName($data, $data['userManager']); + $folderToFile = $userFolder->newFolder($folderName); + $filename = $this->resolveFileName($data, $extension); + return $folderToFile->newFile($filename, $content); + } + + public function getNodeFromUploadedFile(array $data): Node { + return $this->uploadProcessor->getNodeFromUploadedFile($data); + } + + public function validateFileContent(string $content, string $extension): void { + if ($extension === 'pdf') { + $this->pdfValidator->validate($content); + } + } + + private function getExtension(string $content): string { + return $this->mimeService->getExtension($content); + } + + private function getFileRaw(array $data): string { + return $this->contentProvider->getContentFromData($data); + } + + private function resolveFileName(array $data, string $extension): string { + $name = ''; + if (isset($data['name'])) { + $name = trim((string)$data['name']); + } + + if ($name === '') { + $basename = ''; + if (!empty($data['file']['url'])) { + $path = (string)parse_url((string)$data['file']['url'], PHP_URL_PATH); + if ($path !== '') { + $basename = basename($path); + } + } + if ($basename !== '') { + $filenameNoExt = pathinfo($basename, PATHINFO_FILENAME); + $name = $filenameNoExt !== '' ? $filenameNoExt : $basename; + } else { + $name = 'document'; + } + } + + $name = preg_replace('/\s+/', '_', $name); + $name = $name !== '' ? $name : 'document'; + return $name . '.' . $extension; } /** * @return static */ public function showSigners(bool $show = true): self { - $this->showSigners = $show; + $this->options->showSigners($show); return $this; } @@ -109,7 +184,7 @@ public function showSigners(bool $show = true): self { * @return static */ public function showSettings(bool $show = true): self { - $this->showSettings = $show; + $this->options->showSettings($show); if ($show) { $this->fileData->settings = [ 'canSign' => false, @@ -127,7 +202,7 @@ public function showSettings(bool $show = true): self { * @return static */ public function showVisibleElements(bool $show = true): self { - $this->showVisibleElements = $show; + $this->options->showVisibleElements($show); return $this; } @@ -135,7 +210,7 @@ public function showVisibleElements(bool $show = true): self { * @return static */ public function showMessages(bool $show = true): self { - $this->showMessages = $show; + $this->options->showMessages($show); return $this; } @@ -143,22 +218,22 @@ public function showMessages(bool $show = true): self { * @return static */ public function setMe(?IUser $user): self { - $this->me = $user; + $this->options->setMe($user); return $this; } public function setSignerIdentified(bool $identified = true): self { - $this->signerIdentified = $identified; + $this->options->setSignerIdentified($identified); return $this; } public function setIdentifyMethodId(?int $id): self { - $this->identifyMethodId = $id; + $this->options->setIdentifyMethodId($id); return $this; } public function setHost(string $host): self { - $this->host = $host; + $this->options->setHost($host); return $this; } @@ -177,49 +252,52 @@ public function setSignRequest(SignRequest $signRequest): self { } public function showValidateFile(bool $validateFile = true): self { - $this->validateFile = $validateFile; + $this->options->validateFile($validateFile); return $this; } - /** - * @return static - */ - public function setFileByType(string $type, $identifier): self { + private function setFileOrFail(callable $resolver): self { try { - /** @var File */ - $file = call_user_func( - [$this->fileMapper, 'getBy' . $type], - $identifier - ); + $file = $resolver(); } catch (\Throwable) { throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404); } - if (!$file) { + + if (!$file instanceof File) { throw new LibresignException($this->l10n->t('Invalid file identifier'), 404); } - $this->setFile($file); - return $this; + + return $this->setFile($file); + } + + public function setFileById(int $fileId): self { + return $this->setFileOrFail(fn () => $this->fileMapper->getById($fileId)); + } + + public function setFileByUuid(string $uuid): self { + return $this->setFileOrFail(fn () => $this->fileMapper->getByUuid($uuid)); + } + + public function setFileBySignerUuid(string $uuid): self { + return $this->setFileOrFail(fn () => $this->fileMapper->getBySignerUuid($uuid)); + } + + public function setFileByNodeId(int $nodeId): self { + return $this->setFileOrFail(fn () => $this->fileMapper->getByNodeId($nodeId)); + } + + 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')); } - 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); + $mimeType = $this->mimeService->getMimeType($this->fileContent); if ($mimeType !== 'application/pdf') { $this->fileContent = ''; unlink($file['tmp_name']); @@ -264,6 +342,10 @@ public function getStatus(): int { return $this->file->getStatus(); } + public function isLibresignFile(int $nodeId): bool { + return $this->fileMapper->fileIdExists($nodeId); + } + public function getSignedNodeId(): ?int { $status = $this->file->getStatus(); @@ -273,495 +355,174 @@ public function getSignedNodeId(): ?int { return $this->file->getSignedNodeId(); } - private function getFileContent(): string { - if ($this->fileContent) { - return $this->fileContent; - } elseif ($this->file) { - try { - return $this->fileContent = $this->getFile()->getContent(); - } catch (LibresignException $e) { - throw $e; - } catch (\Throwable $e) { - $this->logger->error('Failed to get file content: ' . $e->getMessage(), [ - 'fileId' => $this->file->getId(), - 'exception' => $e, - ]); - throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404, $e); - } - } - return ''; - } - - public function isLibresignFile(int $nodeId): bool { - try { - return $this->fileMapper->fileIdExists($nodeId); - } catch (\Throwable) { - throw new LibresignException($this->l10n->t('Invalid data to validate file'), 404); + private function loadSigners(): void { + if (!$this->options->isShowSigners()) { + return; } - } - private function loadFileMetadata(): void { - if (!$content = $this->getFileContent()) { + if (!$this->file instanceof File) { return; } - $pdfParserService = $this->pdfParserService->setFile($content); - if ($this->file) { - $metadata = $this->file->getMetadata(); - $this->fileData->metadata = $metadata; - } - if (isset($metadata) && isset($metadata['p'])) { - $dimensions = $metadata; - } else { - $dimensions = $pdfParserService->getPageDimensions(); + + if ($this->file->getSignedNodeId()) { + $fileNode = $this->getFile(); + $certData = $this->certificateChainService->getCertificateChain($fileNode, $this->file, $this->options); + if ($certData) { + $this->signersLoader->loadSignersFromCertData($this->fileData, $certData, $this->options->getHost()); + } } - $this->fileData->totalPages = $dimensions['p']; - $this->fileData->size = strlen($content); - $this->fileData->pdfVersion = $pdfParserService->getPdfVersion(); + $this->signersLoader->loadLibreSignSigners($this->file, $this->fileData, $this->options, $this->certData); } - private function loadCertDataFromLibreSignFile(): void { - if (!empty($this->certData) || !$this->validateFile || !$this->file || !$this->file->getSignedNodeId()) { - return; - } - $file = $this->getFile(); + private function loadFileMetadata(): void { + $this->metadataLoader->loadMetadata($this->file, $this->fileData); + } - $resource = $file->fopen('rb'); - $sha256 = $this->getSha256FromResource($resource); - if ($sha256 === $this->file->getSignedHash()) { - $this->pkcs12Handler->setIsLibreSignFile(); - } - $this->certData = $this->pkcs12Handler->getCertificateChain($resource); - fclose($resource); + private function loadSettings(): void { + $this->settingsLoader->loadSettings($this->fileData, $this->options); } - private function getSha256FromResource($resource): string { - $hashContext = hash_init('sha256'); - while (!feof($resource)) { - $buffer = fread($resource, 8192); // 8192 bytes = 8 KB - hash_update($hashContext, $buffer); - } - return hash_final($hashContext); + public function getIdentificationDocumentsStatus(string $userId = ''): int { + return $this->settingsLoader->getIdentificationDocumentsStatus($userId); } - private function loadLibreSignSigners(): void { - if ($this->signersLibreSignLoaded || !$this->file) { + private function loadLibreSignData(): void { + if (!$this->file) { return; } - $signers = $this->signRequestMapper->getByFileId($this->file->getId()); - foreach ($signers as $signer) { - $identifyMethods = $this->identifyMethodService - ->setIsRequest(false) - ->getIdentifyMethodsFromSignRequestId($signer->getId()); - if (!empty($this->fileData->signers)) { - $found = array_filter($this->fileData->signers, function ($found) use ($identifyMethods) { - if (!isset($found['uid'])) { - return false; - } - [$key, $value] = explode(':', (string)$found['uid']); - foreach ($identifyMethods as $methods) { - foreach ($methods as $identifyMethod) { - $entity = $identifyMethod->getEntity(); - if ($key === $entity->getIdentifierKey() && $value === $entity->getIdentifierValue()) { - return true; - } - } - } - return false; - }); - if (!empty($found)) { - $index = key($found); - } else { - $totalSigners = count($signers); - $totalCert = count($this->certData); - // When only have a signature, consider that who signed is who need to sign - if ($totalCert === 1 && $totalSigners === $totalCert) { - $index = 0; - } else { - $index = count($this->fileData->signers); - } - } - } else { - $index = 0; - } - $this->fileData->signers[$index]['identifyMethods'] = $identifyMethods; - $this->fileData->signers[$index]['displayName'] = $signer->getDisplayName(); - $this->fileData->signers[$index]['me'] = false; - $this->fileData->signers[$index]['signRequestId'] = $signer->getId(); - $this->fileData->signers[$index]['description'] = $signer->getDescription(); - $this->fileData->signers[$index]['status'] = $signer->getStatus(); - $this->fileData->signers[$index]['statusText'] = $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()); - $this->fileData->signers[$index]['signingOrder'] = $signer->getSigningOrder(); - $this->fileData->signers[$index]['visibleElements'] = $this->getVisibleElements($signer->getId()); - $this->fileData->signers[$index]['request_sign_date'] = $signer->getCreatedAt()->format(DateTimeInterface::ATOM); - if (empty($this->fileData->signers[$index]['signed'])) { - if ($signer->getSigned()) { - $this->fileData->signers[$index]['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM); - } else { - $this->fileData->signers[$index]['signed'] = null; - } - } - $metadata = $signer->getMetadata(); - if (!empty($metadata['remote-address'])) { - $this->fileData->signers[$index]['remote_address'] = $metadata['remote-address']; - } - if (!empty($metadata['user-agent'])) { - $this->fileData->signers[$index]['user_agent'] = $metadata['user-agent']; - } - if (!empty($metadata['notify'])) { - foreach ($metadata['notify'] as $notify) { - $this->fileData->signers[$index]['notify'][] = [ - 'method' => $notify['method'], - 'date' => (new \DateTime('@' . $notify['date'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM), - ]; - } - } - if ($signer->getSigned() && empty($this->fileData->signers[$index]['signed'])) { - if ($signer->getSigned()) { - $this->fileData->signers[$index]['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM); - } else { - $this->fileData->signers[$index]['signed'] = null; - } - } - // @todo refactor this code - if ($this->me || $this->identifyMethodId) { - $this->fileData->signers[$index]['sign_uuid'] = $signer->getUuid(); - // Identifi if I'm file owner - if ($this->me?->getUID() === $this->file->getUserId()) { - $email = array_reduce($identifyMethods[IdentifyMethodService::IDENTIFY_EMAIL] ?? [], function (?string $carry, IIdentifyMethod $identifyMethod): ?string { - if ($identifyMethod->getEntity()->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { - $carry = $identifyMethod->getEntity()->getIdentifierValue(); - } - return $carry; - }, ''); - $this->fileData->signers[$index]['email'] = $email; - $user = $this->userManager->getByEmail($email); - if ($user && count($user) === 1) { - $this->fileData->signers[$index]['userId'] = $user[0]->getUID(); - } - } - // Identify if I'm signer - foreach ($identifyMethods as $methods) { - foreach ($methods as $identifyMethod) { - $entity = $identifyMethod->getEntity(); - if ($this->identifyMethodId === $entity->getId() - || $this->me?->getUID() === $entity->getIdentifierValue() - || $this->me?->getEMailAddress() === $entity->getIdentifierValue() - ) { - $this->fileData->signers[$index]['me'] = true; - if (!$signer->getSigned()) { - $this->fileData->settings['canSign'] = true; - $this->fileData->settings['signerFileUuid'] = $signer->getUuid(); - } - } - } - } - } - if ($this->fileData->signers[$index]['me']) { - $this->fileData->url = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $this->fileData->signers[$index]['sign_uuid']]); - $this->fileData->signers[$index]['signatureMethods'] = $this->identifyMethodService->getSignMethodsOfIdentifiedFactors($signer->getId()); - } - $this->fileData->signers[$index]['identifyMethods'] = array_reduce($this->fileData->signers[$index]['identifyMethods'], function ($carry, $list) { - foreach ($list as $identifyMethod) { - $carry[] = [ - 'method' => $identifyMethod->getEntity()->getIdentifierKey(), - 'value' => $identifyMethod->getEntity()->getIdentifierValue(), - 'mandatory' => $identifyMethod->getEntity()->getMandatory(), - ]; - } - return $carry; - }, []); - ksort($this->fileData->signers[$index]); - } - - usort($this->fileData->signers, function ($a, $b) { - $orderA = $a['signingOrder'] ?? PHP_INT_MAX; - $orderB = $b['signingOrder'] ?? PHP_INT_MAX; - - if ($orderA !== $orderB) { - return $orderA <=> $orderB; - } + $this->fileData->id = $this->file->getId(); + $this->fileData->uuid = $this->file->getUuid(); + $this->fileData->name = $this->file->getName(); + $this->fileData->status = $this->file->getStatus(); + $this->fileData->created_at = $this->file->getCreatedAt()->format(DateTimeInterface::ATOM); + $this->fileData->statusText = $this->fileMapper->getTextOfStatus($this->file->getStatus()); + $this->fileData->nodeId = $this->file->getNodeId(); + $this->fileData->signatureFlow = $this->file->getSignatureFlow(); + $this->fileData->docmdpLevel = $this->file->getDocmdpLevel(); + $this->fileData->nodeType = $this->file->getNodeType(); - return ($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0); - }); + if ($this->fileData->nodeType !== 'envelope' && !$this->file->getParentFileId()) { + $fileId = $this->file->getId(); - $this->signersLibreSignLoaded = true; - } + $childrenFiles = $this->fileMapper->getChildrenFiles($fileId); - private function loadSignersFromCertData(): void { - $this->loadCertDataFromLibreSignFile(); - foreach ($this->certData as $index => $signer) { - $this->fileData->signers[$index]['status'] = 2; - $this->fileData->signers[$index]['statusText'] = $this->signRequestMapper->getTextOfSignerStatus(2); + if (!empty($childrenFiles)) { + $this->file->setNodeType('envelope'); + $this->fileMapper->update($this->file); - if (isset($signer['timestamp'])) { - $this->fileData->signers[$index]['timestamp'] = $signer['timestamp']; - if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) { - $this->fileData->signers[$index]['timestamp']['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM); - } - } - if (isset($signer['signingTime']) && $signer['signingTime'] instanceof DateTimeInterface) { - $this->fileData->signers[$index]['signingTime'] = $signer['signingTime']; - $this->fileData->signers[$index]['signed'] = $signer['signingTime']->format(DateTimeInterface::ATOM); - } - if (isset($signer['docmdp'])) { - $this->fileData->signers[$index]['docmdp'] = $signer['docmdp']; - } - if (isset($signer['modifications'])) { - $this->fileData->signers[$index]['modifications'] = $signer['modifications']; - } - if (isset($signer['modification_validation'])) { - $this->fileData->signers[$index]['modification_validation'] = $signer['modification_validation']; - } - if (isset($signer['chain'])) { - foreach ($signer['chain'] as $chainIndex => $chainItem) { - $chainArr = $chainItem; - if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) { - $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) { - $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM); - } - $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? ''); - $this->fileData->signers[$index]['chain'][$chainIndex] = $chainArr; - if ($chainIndex === 0) { - $this->fileData->signers[$index] = array_merge($chainArr, $this->fileData->signers[$index] ?? []); - $this->fileData->signers[$index]['uid'] = $this->resolveUid($chainArr); - } - } + $this->fileData->nodeType = 'envelope'; + $this->fileData->filesCount = count($childrenFiles); + $this->fileData->files = []; } } - } - private function resolveUid(array $chainArr): ?string { - if (!empty($chainArr['subject']['UID'])) { - return $chainArr['subject']['UID']; - } - if (!empty($chainArr['subject']['CN'])) { - $cn = $chainArr['subject']['CN']; - if (is_array($cn)) { - $cn = $cn[0]; - } - if (preg_match('/^(?.*):(?.*), /', (string)$cn, $matches)) { - return $matches['key'] . ':' . $matches['value']; - } - } - if (!empty($chainArr['extensions']['subjectAltName'])) { - $subjectAltName = $chainArr['extensions']['subjectAltName']; - if (is_array($subjectAltName)) { - $subjectAltName = $subjectAltName[0]; - } - preg_match('/^(?(email|account)):(?.*)$/', (string)$subjectAltName, $matches); - if ($matches) { - if (str_ends_with($matches['value'], $this->host)) { - $uid = str_replace('@' . $this->host, '', $matches['value']); - $userFound = $this->userManager->get($uid); - if ($userFound) { - return 'account:' . $uid; - } else { - $userFound = $this->userManager->getByEmail($matches['value']); - if ($userFound) { - $userFound = current($userFound); - return 'account:' . $userFound->getUID(); - } else { - return 'email:' . $matches['value']; - } - } - } else { - $userFound = $this->userManager->getByEmail($matches['value']); - if ($userFound) { - $userFound = current($userFound); - return 'account:' . $userFound->getUID(); - } else { - $userFound = $this->userManager->get($matches['value']); - if ($userFound) { - return 'account:' . $userFound->getUID(); - } else { - return $matches['key'] . ':' . $matches['value']; - } - } + 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); } } } - return null; - } - private function loadSigners(): void { - if (!$this->showSigners) { - return; - } - $this->loadSignersFromCertData(); - $this->loadLibreSignSigners(); - } + $this->fileData->requested_by = [ + 'userId' => $this->file->getUserId(), + 'displayName' => $this->userManager->get($this->file->getUserId())->getDisplayName(), + ]; + $this->fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->file->getUuid()]); - /** - * @return (mixed|string)[][] - * - * @psalm-return list - */ - private function getPages(): array { - $return = []; - - $metadata = $this->file->getMetadata(); - for ($page = 1; $page <= $metadata['p']; $page++) { - $return[] = [ - 'url' => $this->urlGenerator->linkToRoute('ocs.libresign.File.getPage', [ - 'apiVersion' => 'v1', - 'uuid' => $this->file->getUuid(), - 'page' => $page, - ]), - 'resolution' => $metadata['d'][$page - 1] - ]; - } - return $return; - } + $this->loadEnvelopeData(); - private function getVisibleElements(int $signRequestId): array { - $return = []; - if (!$this->showVisibleElements) { - return $return; - } - try { - $visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($this->file->getId(), $signRequestId); - foreach ($visibleElements as $visibleElement) { - $element = [ - 'elementId' => $visibleElement->getId(), - 'signRequestId' => $visibleElement->getSignRequestId(), - 'type' => $visibleElement->getType(), - 'coordinates' => [ - 'page' => $visibleElement->getPage(), - 'urx' => $visibleElement->getUrx(), - 'ury' => $visibleElement->getUry(), - 'llx' => $visibleElement->getLlx(), - 'lly' => $visibleElement->getLly() - ] - ]; - $element['coordinates'] = array_merge( - $element['coordinates'], - $this->fileElementService->translateCoordinatesFromInternalNotation($element, $this->file) + if ($this->options->isShowVisibleElements()) { + $signers = $this->signRequestMapper->getByMultipleFileId([$this->file->getId()]); + $this->fileData->visibleElements = []; + foreach ($this->signRequestMapper->getVisibleElementsFromSigners($signers) as $visibleElements) { + if (empty($visibleElements)) { + continue; + } + $file = array_filter($this->fileData->files, fn (stdClass $file) => $file->id === $visibleElements[0]->getFileId()); + if (empty($file)) { + continue; + } + $file = current($file); + $fileMetadata = $this->file->getMetadata(); + $this->fileData->visibleElements = array_merge( + $this->fileElementService->formatVisibleElements($visibleElements, $fileMetadata), + $this->fileData->visibleElements ); - $return[] = $element; } - } catch (\Throwable) { } - return $return; } - private function getPhoneNumber(): string { - if (!$this->me) { - return ''; + private function getLatestSignedDateFromEnvelope(): ?\DateTime { + if (!$this->file || $this->file->getNodeType() !== 'envelope') { + return null; } - $userAccount = $this->accountManager->getAccount($this->me); - return $userAccount->getProperty(IAccountManager::PROPERTY_PHONE)->getValue(); - } - private function loadSettings(): void { - if (!$this->showSettings) { - return; - } - if ($this->me) { - $this->fileData->settings = array_merge($this->fileData->settings, $this->accountService->getSettings($this->me)); - $this->fileData->settings['phoneNumber'] = $this->getPhoneNumber(); - } - if ($this->signerIdentified || $this->me) { - $status = $this->getIdentificationDocumentsStatus(); - if ($status === self::IDENTIFICATION_DOCUMENTS_NEED_SEND) { - $this->fileData->settings['needIdentificationDocuments'] = true; - $this->fileData->settings['identificationDocumentsWaitingApproval'] = false; - } elseif ($status === self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL) { - $this->fileData->settings['needIdentificationDocuments'] = true; - $this->fileData->settings['identificationDocumentsWaitingApproval'] = true; + $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; } - public function getIdentificationDocumentsStatus(string $userId = ''): int { - if (!$this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)) { - return self::IDENTIFICATION_DOCUMENTS_DISABLED; + private function loadEnvelopeFiles(): void { + if (!$this->file || $this->file->getNodeType() !== 'envelope') { + return; } - if (!$userId && $this->me instanceof IUser) { - $userId = $this->me->getUID(); - } - if (!empty($userId)) { - $files = $this->fileMapper->getFilesOfAccount($userId); + $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId()); + foreach ($childrenFiles as $childFile) { + $this->fileData->files[] = $this->buildEnvelopeChildData($childFile); } + } - if (empty($files) || !count($files)) { - return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; - } - $deleted = array_filter($files, fn (File $file) => $file->getStatus() === File::STATUS_DELETED); - if (count($deleted) === count($files)) { - return self::IDENTIFICATION_DOCUMENTS_NEED_SEND; - } + private function buildEnvelopeChildData(File $childFile): stdClass { + return $this->envelopeAssembler->buildEnvelopeChildData($childFile, $this->options); + } - $signed = array_filter($files, fn (File $file) => $file->getStatus() === File::STATUS_SIGNED); - if (count($signed) !== count($files)) { - return self::IDENTIFICATION_DOCUMENTS_NEED_APPROVAL; + private function loadEnvelopeData(): void { + if (!$this->file->hasParent()) { + return; } - return self::IDENTIFICATION_DOCUMENTS_APPROVED; - } - - private function loadLibreSignData(): void { - if (!$this->file) { + $envelope = $this->envelopeService->getEnvelopeByFileId($this->file->getId()); + if (!$envelope) { return; } - $this->fileData->uuid = $this->file->getUuid(); - $this->fileData->name = $this->file->getName(); - $this->fileData->status = $this->file->getStatus(); - $this->fileData->created_at = $this->file->getCreatedAt()->format(DateTimeInterface::ATOM); - $this->fileData->statusText = $this->fileMapper->getTextOfStatus($this->file->getStatus()); - $this->fileData->nodeId = $this->file->getNodeId(); - $this->fileData->signatureFlow = $this->file->getSignatureFlow(); - $this->fileData->docmdpLevel = $this->file->getDocmdpLevel(); - $this->fileData->requested_by = [ - 'userId' => $this->file->getUserId(), - 'displayName' => $this->userManager->get($this->file->getUserId())->getDisplayName(), + $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' => $envelopeMetadata['filesCount'] ?? 0, + 'files' => [], ]; - $this->fileData->file = $this->urlGenerator->linkToRoute('libresign.page.getPdf', ['uuid' => $this->file->getUuid()]); - if ($this->showVisibleElements) { - $signers = $this->signRequestMapper->getByMultipleFileId([$this->file->getId()]); - $this->fileData->visibleElements = []; - foreach ($this->signRequestMapper->getVisibleElementsFromSigners($signers) as $visibleElements) { - $this->fileData->visibleElements = array_merge( - $this->formatVisibleElementsToArray( - $visibleElements, - $this->file->getMetadata() - ), - $this->fileData->visibleElements - ); - } - } } private function loadMessages(): void { - if (!$this->showMessages) { - return; - } - $messages = []; - if ($this->fileData->settings['canSign']) { - $messages[] = [ - 'type' => 'info', - 'message' => $this->l10n->t('You need to sign this document') - ]; - } - if ($this->fileData->settings['canRequestSign']) { - $this->loadLibreSignSigners(); - if (empty($this->fileData->signers)) { - $messages[] = [ - 'type' => 'info', - 'message' => $this->l10n->t('You cannot request signature for this document, please contact your administrator') - ]; - } - } - if ($messages) { - $this->fileData->messages = $messages; - } + $this->messagesLoader->loadMessages($this->file, $this->fileData, $this->options, $this->certData); } /** * @return LibresignValidateFile + * @psalm-return LibresignValidateFile */ public function toArray(): array { $this->loadLibreSignData(); @@ -769,213 +530,60 @@ public function toArray(): array { $this->loadSettings(); $this->loadSigners(); $this->loadMessages(); + $this->computeEnvelopeSignersProgress(); + $return = json_decode(json_encode($this->fileData), true); ksort($return); return $return; } - public function setFileByPath(string $path): self { - $node = $this->folderService->getFileByPath($path); - $this->setFileByType('FileId', $node->getId()); - return $this; - } + private function computeEnvelopeSignersProgress(): void { + if (!$this->file || $this->file->getParentFileId()) { + return; + } + if (empty($this->fileData->signers)) { + return; + } - /** - * @return array[] - * - * @psalm-return array{data: array, pagination: array} - */ - public function listAssociatedFilesOfSignFlow( - $page = null, - $length = null, - array $filter = [], - array $sort = [], - ): array { - $page ??= 1; - $length ??= (int)$this->appConfig->getValueInt(Application::APP_ID, 'length_of_page', 100); - - $return = $this->signRequestMapper->getFilesAssociatedFilesWithMeFormatted( - $this->me, - $filter, - $page, - $length, - $sort, - ); + $childrenFiles = $this->fileMapper->getChildrenFiles($this->file->getId()); + if (empty($childrenFiles)) { + return; + } - $signers = $this->signRequestMapper->getByMultipleFileId(array_column($return['data'], 'id')); - $identifyMethods = $this->signRequestMapper->getIdentifyMethodsFromSigners($signers); - $visibleElements = $this->signRequestMapper->getVisibleElementsFromSigners($signers); - $return['data'] = $this->associateAllAndFormat($this->me, $return['data'], $signers, $identifyMethods, $visibleElements); + $signRequestsByFileId = []; + $identifyMethodsBySignRequest = []; + foreach ($childrenFiles as $child) { + $signRequestsByFileId[$child->getId()] = $this->signRequestMapper->getByFileId($child->getId()); + foreach ($signRequestsByFileId[$child->getId()] as $sr) { + $identifyMethodsBySignRequest[$sr->getId()] = $this->identifyMethodService->setIsRequest(false)->getIdentifyMethodsFromSignRequestId($sr->getId()); + } + } - $return['pagination']->setRouteName('ocs.libresign.File.list'); - return [ - 'data' => $return['data'], - 'pagination' => $return['pagination']->getPagination($page, $length, $filter) - ]; + $this->envelopeProgressService->computeProgress( + $this->fileData, + $this->file, + $childrenFiles, + $signRequestsByFileId, + $identifyMethodsBySignRequest + ); } - private function associateAllAndFormat(IUser $user, array $files, array $signers, array $identifyMethods, array $visibleElements): array { - foreach ($files as $key => $file) { - $totalSigned = 0; - foreach ($signers as $signerKey => $signer) { - if ($signer->getFileId() === $file['id']) { - /** @var array */ - $identifyMethodsOfSigner = $identifyMethods[$signer->getId()] ?? []; - $data = [ - 'email' => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { - if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { - return $identifyMethod->getIdentifierValue(); - } - if (filter_var($identifyMethod->getIdentifierValue(), FILTER_VALIDATE_EMAIL)) { - return $identifyMethod->getIdentifierValue(); - } - return $carry; - }, ''), - 'description' => $signer->getDescription(), - 'displayName' - => array_reduce($identifyMethodsOfSigner, function (string $carry, IdentifyMethod $identifyMethod): string { - if (!$carry && $identifyMethod->getMandatory()) { - return $identifyMethod->getIdentifierValue(); - } - return $carry; - }, $signer->getDisplayName()), - 'request_sign_date' => $signer->getCreatedAt()->format(DateTimeInterface::ATOM), - 'signed' => null, - 'signRequestId' => $signer->getId(), - 'signingOrder' => $signer->getSigningOrder(), - 'status' => $signer->getStatus(), - 'statusText' => $this->signRequestMapper->getTextOfSignerStatus($signer->getStatus()), - 'me' => array_reduce($identifyMethodsOfSigner, function (bool $carry, IdentifyMethod $identifyMethod) use ($user): bool { - if ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_ACCOUNT) { - if ($user->getUID() === $identifyMethod->getIdentifierValue()) { - return true; - } - } elseif ($identifyMethod->getIdentifierKey() === IdentifyMethodService::IDENTIFY_EMAIL) { - if (!$user->getEMailAddress()) { - return false; - } - if ($user->getEMailAddress() === $identifyMethod->getIdentifierValue()) { - return true; - } - } - return $carry; - }, false), - 'visibleElements' => $this->formatVisibleElementsToArray( - $visibleElements[$signer->getId()] ?? [], - !empty($file['metadata'])?json_decode((string)$file['metadata'], true):[] - ), - 'identifyMethods' => array_map(fn (IdentifyMethod $identifyMethod): array => [ - 'method' => $identifyMethod->getIdentifierKey(), - 'value' => $identifyMethod->getIdentifierValue(), - 'mandatory' => $identifyMethod->getMandatory(), - ], array_values($identifyMethodsOfSigner)), - ]; - - if ($data['me']) { - $temp = array_map(function (IdentifyMethod $identifyMethodEntity) use ($signer): array { - $this->identifyMethodService->setCurrentIdentifyMethod($identifyMethodEntity); - $identifyMethod = $this->identifyMethodService - ->setIsRequest(false) - ->getInstanceOfIdentifyMethod( - $identifyMethodEntity->getIdentifierKey(), - $identifyMethodEntity->getIdentifierValue(), - ); - $signatureMethods = $identifyMethod->getSignatureMethods(); - $return = []; - foreach ($signatureMethods as $signatureMethod) { - if (!$signatureMethod->isEnabled()) { - continue; - } - $signatureMethod->setEntity($identifyMethod->getEntity()); - $return[$signatureMethod->getName()] = $signatureMethod->toArray(); - } - return $return; - }, array_values($identifyMethodsOfSigner)); - $data['signatureMethods'] = []; - foreach ($temp as $methods) { - $data['signatureMethods'] = array_merge($data['signatureMethods'], $methods); - } - $data['sign_uuid'] = $signer->getUuid(); - $files[$key]['url'] = $this->urlGenerator->linkToRoute('libresign.page.getPdfFile', ['uuid' => $signer->getuuid()]); - } - - if ($signer->getSigned()) { - $data['signed'] = $signer->getSigned()->format(DateTimeInterface::ATOM); - $totalSigned++; - } - ksort($data); - $files[$key]['signers'][] = $data; - unset($signers[$signerKey]); - } - } - if (empty($files[$key]['signers'])) { - $files[$key]['signers'] = []; - $files[$key]['statusText'] = $this->l10n->t('no signers'); - } else { - usort($files[$key]['signers'], function ($a, $b) { - $orderA = $a['signingOrder'] ?? PHP_INT_MAX; - $orderB = $b['signingOrder'] ?? PHP_INT_MAX; - - if ($orderA !== $orderB) { - return $orderA <=> $orderB; - } + public function delete(int $fileId): void { + $file = $this->fileMapper->getById($fileId); - return ($a['signRequestId'] ?? 0) <=> ($b['signRequestId'] ?? 0); - }); + $this->decrementEnvelopeFilesCountIfNeeded($file); - $files[$key]['statusText'] = $this->fileMapper->getTextOfStatus((int)$files[$key]['status']); + if ($file->getNodeType() === 'envelope') { + $childrenFiles = $this->fileMapper->getChildrenFiles($file->getId()); + foreach ($childrenFiles as $childFile) { + $this->delete($childFile->getId()); } - unset($files[$key]['id']); - ksort($files[$key]); } - return $files; - } - - /** - * @param FileElement[] $visibleElements - * @param array - * @return array - */ - private function formatVisibleElementsToArray(array $visibleElements, array $metadata): array { - return array_map(function (FileElement $visibleElement) use ($metadata) { - $element = [ - 'elementId' => $visibleElement->getId(), - 'signRequestId' => $visibleElement->getSignRequestId(), - 'type' => $visibleElement->getType(), - 'coordinates' => [ - 'page' => $visibleElement->getPage(), - 'urx' => $visibleElement->getUrx(), - 'ury' => $visibleElement->getUry(), - 'llx' => $visibleElement->getLlx(), - 'lly' => $visibleElement->getLly() - ] - ]; - $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); - } - - public function getMyLibresignFile(int $nodeId): File { - return $this->signRequestMapper->getMyLibresignFile( - userId: $this->me->getUID(), - filter: [ - 'email' => $this->me->getEMailAddress(), - 'nodeId' => $nodeId, - ], - ); - } - public function delete(int $fileId): void { - $file = $this->fileMapper->getByFileId($fileId); $this->fileElementService->deleteVisibleElements($file->getId()); $list = $this->signRequestMapper->getByFileId($file->getId()); foreach ($list as $signRequest) { + $this->identifyMethodService->deleteBySignRequestId($signRequest->getId()); $this->signRequestMapper->delete($signRequest); } $this->idDocsMapper->deleteByFileId($file->getId()); @@ -990,4 +598,27 @@ public function delete(int $fileId): void { } catch (NotFoundException) { } } + + public function processUploadedFilesWithRollback(array $filesArray, IUser $user, array $settings): array { + return $this->uploadProcessor->processUploadedFilesWithRollback($filesArray, $user, $settings); + } + + public function updateEnvelopeFilesCount(File $envelope, int $delta = 0): void { + $metadata = $envelope->getMetadata(); + $currentCount = $metadata['filesCount'] ?? 0; + $metadata['filesCount'] = max(0, $currentCount + $delta); + $envelope->setMetadata($metadata); + $this->fileMapper->update($envelope); + } + + private function decrementEnvelopeFilesCountIfNeeded(File $file): void { + if ($file->getParentFileId() === null) { + return; + } + + $parentEnvelope = $this->fileMapper->getById($file->getParentFileId()); + if ($parentEnvelope->getNodeType() === 'envelope') { + $this->updateEnvelopeFilesCount($parentEnvelope, -1); + } + } } diff --git a/lib/Service/FileStatusService.php b/lib/Service/FileStatusService.php index b211bfe56a..5654d7325c 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,82 @@ 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; + } + + public function propagateStatusToChildren(int $envelopeId, int $newStatus): void { + try { + $envelope = $this->fileMapper->getById($envelopeId); + } catch (DoesNotExistException) { + return; + } + + if (!$envelope->isEnvelope()) { + return; + } + + $children = $this->fileMapper->getChildrenFiles($envelopeId); + + foreach ($children as $child) { + if ($child->getStatus() !== $newStatus) { + $child->setStatus($newStatus); + $this->fileMapper->update($child); + } + } + } } diff --git a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php index 3cfc3582c9..e40a613ba6 100644 --- a/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php +++ b/lib/Service/IdentifyMethod/AbstractIdentifyMethod.php @@ -171,14 +171,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')]], + ])); + } } } 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); diff --git a/lib/Service/IdentifyMethodService.php b/lib/Service/IdentifyMethodService.php index bea6240cfc..c8c0a8027a 100644 --- a/lib/Service/IdentifyMethodService.php +++ b/lib/Service/IdentifyMethodService.php @@ -22,6 +22,7 @@ use OCA\Libresign\Service\IdentifyMethod\Xmpp; use OCP\IConfig; use OCP\IL10N; +use OCP\IUserManager; class IdentifyMethodService { public const IDENTIFY_ACCOUNT = 'account'; @@ -56,6 +57,7 @@ public function __construct( private IConfig $config, private IdentifyMethodMapper $identifyMethodMapper, private IL10N $l10n, + private IUserManager $userManager, private Account $account, private Email $email, private Signal $signal, @@ -66,6 +68,11 @@ public function __construct( ) { } + public function clearCache(): void { + $this->identifyMethods = []; + $this->currentIdentifyMethod = null; + } + public function setIsRequest(bool $isRequest): self { $this->isRequest = $isRequest; return $this; @@ -213,6 +220,11 @@ public function getIdentifiedMethod(int $signRequestId): IIdentifyMethod { throw new LibresignException($this->l10n->t('Invalid identification method'), 1); } + public function deleteBySignRequestId(int $signRequestId): void { + $this->identifyMethodMapper->deleteBySignRequestId($signRequestId); + $this->clearCache(); + } + public function getSignMethodsOfIdentifiedFactors(int $signRequestId): array { $matrix = $this->getIdentifyMethodsFromSignRequestId($signRequestId); $return = []; @@ -278,4 +290,68 @@ public function getIdentifyMethodsSettings(): array { } return $this->identifyMethodsSettings; } + + /** + * Resolve UID from certificate chain data + * + * Extracts and resolves the identifier from certificate subject or extensions. + * Supports fallbacks for older LibreSign versions and converts to standard + * identifier format (account:uid or email:value). + * + * @param array $chainArr Certificate chain array with subject and extensions + * @param string $host Host domain for email matching + * @return string|null Resolved identifier in format "type:value" or null + */ + public function resolveUid(array $chainArr, string $host): ?string { + if (!empty($chainArr['subject']['UID'])) { + return $chainArr['subject']['UID']; + } + if (!empty($chainArr['subject']['CN'])) { + $cn = $chainArr['subject']['CN']; + if (is_array($cn)) { + $cn = $cn[0]; + } + if (preg_match('/^(?.*):(?.*), /', (string)$cn, $matches)) { + return $matches['key'] . ':' . $matches['value']; + } + } + if (!empty($chainArr['extensions']['subjectAltName'])) { + $subjectAltName = $chainArr['extensions']['subjectAltName']; + if (is_array($subjectAltName)) { + $subjectAltName = $subjectAltName[0]; + } + preg_match('/^(?(email|account)):(?.*)$/', (string)$subjectAltName, $matches); + if ($matches) { + if (str_ends_with($matches['value'], $host)) { + $uid = str_replace('@' . $host, '', $matches['value']); + $userFound = $this->userManager->get($uid); + if ($userFound) { + return 'account:' . $uid; + } else { + $userFound = $this->userManager->getByEmail($matches['value']); + if ($userFound) { + $userFound = current($userFound); + return 'account:' . $userFound->getUID(); + } else { + return 'email:' . $matches['value']; + } + } + } else { + $userFound = $this->userManager->getByEmail($matches['value']); + if ($userFound) { + $userFound = current($userFound); + return 'account:' . $userFound->getUID(); + } else { + $userFound = $this->userManager->get($matches['value']); + if ($userFound) { + return 'account:' . $userFound->getUID(); + } else { + return $matches['key'] . ':' . $matches['value']; + } + } + } + } + } + return null; + } } diff --git a/lib/Service/NotifyService.php b/lib/Service/NotifyService.php index 73fbbc688f..19def2d244 100644 --- a/lib/Service/NotifyService.php +++ b/lib/Service/NotifyService.php @@ -29,25 +29,27 @@ public function __construct( ) { } - public function signer(int $nodeId, int $signRequestId): void { + public function signer(int $fileId, int $signRequestId): void { $this->validateHelper->canRequestSign($this->userSession->getUser()); - $this->validateHelper->validateLibreSignNodeId($nodeId); - $this->validateHelper->iRequestedSignThisFile($this->userSession->getUser(), $nodeId); - $signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($nodeId, $signRequestId); + $this->validateHelper->validateLibreSignFileId($fileId); + $signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($fileId, $signRequestId); + $this->validateHelper->iRequestedSignThisFile($this->userSession->getUser(), $fileId); $this->notify($signRequest); } - public function signers(int $nodeId, array $signers): void { + public function signers(int $fileId, array $signers): void { $this->validateHelper->canRequestSign($this->userSession->getUser()); - $this->validateHelper->validateLibreSignNodeId($nodeId); - $this->validateHelper->iRequestedSignThisFile($this->userSession->getUser(), $nodeId); + $this->validateHelper->validateLibreSignFileId($fileId); + $signRequests = $this->signRequestMapper->getByFileId($fileId); + if (!empty($signRequests)) { + $this->validateHelper->iRequestedSignThisFile($this->userSession->getUser(), $fileId); + } foreach ($signers as $signer) { $this->validateHelper->haveValidMail($signer); $this->validateHelper->signerWasAssociated($signer); $this->validateHelper->notSigned($signer); } // @todo refactor this code - $signRequests = $this->signRequestMapper->getByNodeId($nodeId); foreach ($signRequests as $signRequest) { $this->notify($signRequest, $signers); } diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index aee7d3bb10..f6c8aa256b 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -17,10 +17,11 @@ use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; +use OCA\Libresign\Exception\LibresignException; 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; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; use OCP\Files\Node; @@ -33,9 +34,9 @@ use Sabre\DAV\UUIDUtil; class RequestSignatureService { - use TFile; public function __construct( + protected FileService $fileService, protected IL10N $l10n, protected IdentifyMethodService $identifyMethod, protected SignRequestMapper $signRequestMapper, @@ -55,22 +56,221 @@ public function __construct( protected IAppConfig $appConfig, protected IEventDispatcher $eventDispatcher, protected FileStatusService $fileStatusService, - protected SignRequestStatusService $signRequestStatusService, protected DocMdpConfigService $docMdpConfigService, + protected EnvelopeService $envelopeService, + protected FileUploadHelper $uploadHelper, + protected SignRequestService $signRequestService, ) { } + /** + * Save files - creates single file or envelope based on files count + * + * @param array{files: array, name: string, settings: array, userManager: IUser} $data + * @return array{file: FileEntity, children: FileEntity[]} + */ + public function saveFiles(array $data): array { + if (empty($data['files'])) { + throw new LibresignException('Files parameter is required'); + } + + if (count($data['files']) === 1) { + $fileData = $data['files'][0]; + + $saveData = [ + 'name' => $data['name'] ?? $fileData['name'] ?? '', + 'userManager' => $data['userManager'], + 'status' => FileEntity::STATUS_DRAFT, + 'settings' => $data['settings'], + ]; + + if (isset($fileData['uploadedFile'])) { + $saveData['uploadedFile'] = $fileData['uploadedFile']; + } elseif (isset($fileData['fileNode'])) { + $saveData['file'] = ['fileNode' => $fileData['fileNode']]; + } else { + $saveData['file'] = $fileData; + } + + $savedFile = $this->save($saveData); + + return [ + 'file' => $savedFile, + 'children' => [$savedFile], + ]; + } + + $result = $this->saveEnvelope([ + 'files' => $data['files'], + 'name' => $data['name'], + 'userManager' => $data['userManager'], + 'settings' => $data['settings'], + ]); + + return [ + 'file' => $result['envelope'], + 'children' => $result['files'], + ]; + } + public function save(array $data): FileEntity { $file = $this->saveFile($data); - $this->saveVisibleElements($data, $file); if (!isset($data['status'])) { $data['status'] = $file->getStatus(); } $this->sequentialSigningService->setFile($file); - $this->associateToSigners($data, $file->getId()); + $this->associateToSigners($data, $file); + $this->propagateSignersToChildren($file, $data); + $this->saveVisibleElements($data, $file); + return $file; } + private function propagateSignersToChildren(FileEntity $envelope, array $data): void { + if ($envelope->getNodeType() !== 'envelope' || empty($data['users'])) { + return; + } + + $children = $this->fileMapper->getChildrenFiles($envelope->getId()); + + $dataWithoutNotification = $data; + foreach ($dataWithoutNotification['users'] as &$user) { + $user['notify'] = 0; + } + + foreach ($children as $child) { + $this->identifyMethod->clearCache(); + $this->sequentialSigningService->setFile($child); + $this->associateToSigners($dataWithoutNotification, $child); + } + + if ($envelope->getStatus() > FileEntity::STATUS_DRAFT) { + $this->fileStatusService->propagateStatusToChildren($envelope->getId(), $envelope->getStatus()); + } + } + + public function saveEnvelope(array $data): array { + $this->envelopeService->validateEnvelopeConstraints(count($data['files'])); + + $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; + $filesCount = count($data['files']); + + $envelope = null; + $files = []; + $createdNodes = []; + + try { + $envelope = $this->envelopeService->createEnvelope($envelopeName, $userId, $filesCount); + + $envelopeFolderName = 'envelope-' . $envelope->getUuid(); + $envelopeSettings = array_merge($data['settings'] ?? [], [ + 'folderName' => $envelopeFolderName, + ]); + + foreach ($data['files'] as $fileData) { + $node = $this->processFileData($fileData, $userManager, $envelopeSettings); + $createdNodes[] = $node; + + $fileData['node'] = $node; + $fileEntity = $this->createFileForEnvelope($fileData, $userManager, $envelopeSettings); + $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); + $files[] = $fileEntity; + } + + return [ + 'envelope' => $envelope, + 'files' => $files, + ]; + } catch (\Throwable $e) { + $this->rollbackEnvelopeCreation($envelope, $files, $createdNodes); + throw $e; + } + } + + private function processFileData(array $fileData, ?IUser $userManager, array $settings): Node { + if (isset($fileData['uploadedFile'])) { + return $this->fileService->getNodeFromData([ + 'userManager' => $userManager, + 'name' => $fileData['name'] ?? '', + 'uploadedFile' => $fileData['uploadedFile'], + 'settings' => $settings, + ]); + } + + return $this->fileService->getNodeFromData([ + 'userManager' => $userManager, + 'name' => $fileData['name'] ?? '', + 'file' => $fileData, + 'settings' => $settings, + ]); + } + + 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 { + 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, + 'settings' => $settings, + ]); + } + /** * Save file data * @@ -80,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; @@ -90,14 +294,14 @@ public function saveFile(array $data): FileEntity { } if (!is_null($fileId)) { try { - $file = $this->fileMapper->getByFileId($fileId); + $file = $this->fileMapper->getByNodeId($fileId); $this->updateSignatureFlowIfAllowed($file, $data); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } catch (\Throwable) { } } - $node = $this->getNodeFromData($data); + $node = $this->fileService->getNodeFromData($data); $file = new FileEntity(); $file->setNodeId($node->getId()); @@ -109,7 +313,11 @@ public function saveFile(array $data): FileEntity { $file->setUuid(UUIDUtil::getUUID()); $file->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); $metadata = $this->getFileMetadata($node); - $file->setName($this->removeExtensionFromName($data['name'], $metadata)); + $name = trim((string)($data['name'] ?? '')); + if ($name === '') { + $name = $node->getName(); + } + $file->setName($this->removeExtensionFromName($name, $metadata)); $file->setMetadata($metadata); if (!empty($data['callback'])) { $file->setCallback($data['callback']); @@ -120,6 +328,10 @@ public function saveFile(array $data): FileEntity { $file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN); } + if (isset($data['parentFileId'])) { + $file->setParentFileId($data['parentFileId']); + } + $this->setSignatureFlow($file, $data); $this->setDocMdpLevelFromGlobalConfig($file); @@ -175,12 +387,12 @@ private function getFileMetadata(\OCP\Files\Node $node): array { 'extension' => $extension, ]; if ($metadata['extension'] === 'pdf') { + $pdfParser = $this->pdfParserService->setFile($node); $metadata = array_merge( $metadata, - $this->pdfParserService - ->setFile($node) - ->getPageDimensions() + $pdfParser->getPageDimensions() ); + $metadata['pdfVersion'] = $pdfParser->getPdfVersion(); } } return $metadata; @@ -195,20 +407,19 @@ private function removeExtensionFromName(string $name, array $metadata): string return $result ?? $name; } - private function deleteIdentifyMethodIfNotExits(array $users, int $fileId): void { - $file = $this->fileMapper->getById($fileId); - $signRequests = $this->signRequestMapper->getByFileId($fileId); + private function deleteIdentifyMethodIfNotExits(array $users, FileEntity $file): void { + $signRequests = $this->signRequestMapper->getByFileId($file->getId()); foreach ($signRequests as $key => $signRequest) { $identifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequest->getId()); if (empty($identifyMethods)) { - $this->unassociateToUser($file->getNodeId(), $signRequest->getId()); + $this->unassociateToUser($file->getId(), $signRequest->getId()); continue; } foreach ($identifyMethods as $methodName => $list) { foreach ($list as $method) { $exists[$key]['identify'][$methodName] = $method->getEntity()->getIdentifierValue(); if (!$this->identifyMethodExists($users, $method)) { - $this->unassociateToUser($file->getNodeId(), $signRequest->getId()); + $this->unassociateToUser($file->getId(), $signRequest->getId()); continue 3; } } @@ -246,10 +457,10 @@ private function identifyMethodExists(array $users, IIdentifyMethod $identifyMet * * @psalm-return list */ - private function associateToSigners(array $data, int $fileId): array { + private function associateToSigners(array $data, FileEntity $file): array { $return = []; if (!empty($data['users'])) { - $this->deleteIdentifyMethodIfNotExits($data['users'], $fileId); + $this->deleteIdentifyMethodIfNotExits($data['users'], $file); $this->sequentialSigningService->resetOrderCounter(); $fileStatus = $data['status'] ?? null; @@ -258,29 +469,30 @@ private function associateToSigners(array $data, int $fileId): array { $userProvidedOrder = isset($user['signingOrder']) ? (int)$user['signingOrder'] : null; $signingOrder = $this->sequentialSigningService->determineSigningOrder($userProvidedOrder); $signerStatus = $user['status'] ?? null; + $shouldNotify = !isset($user['notify']) || $user['notify'] !== 0; if (isset($user['identifyMethods'])) { foreach ($user['identifyMethods'] as $identifyMethod) { - $return[] = $this->associateToSigner( + $return[] = $this->signRequestService->createOrUpdateSignRequest( identifyMethods: [ $identifyMethod['method'] => $identifyMethod['value'], ], displayName: $user['displayName'] ?? '', description: $user['description'] ?? '', - notify: empty($user['notify']), - fileId: $fileId, + notify: $shouldNotify, + fileId: $file->getId(), signingOrder: $signingOrder, fileStatus: $fileStatus, signerStatus: $signerStatus, ); } } else { - $return[] = $this->associateToSigner( + $return[] = $this->signRequestService->createOrUpdateSignRequest( identifyMethods: $user['identify'], displayName: $user['displayName'] ?? '', description: $user['description'] ?? '', - notify: empty($user['notify']), - fileId: $fileId, + notify: $shouldNotify, + fileId: $file->getId(), signingOrder: $signingOrder, fileStatus: $fileStatus, signerStatus: $signerStatus, @@ -291,86 +503,34 @@ private function associateToSigners(array $data, int $fileId): array { return $return; } - private function associateToSigner( - array $identifyMethods, - string $displayName, - string $description, - bool $notify, - int $fileId, - int $signingOrder = 0, - ?int $fileStatus = null, - ?int $signerStatus = null, - ): SignRequestEntity { - $identifyMethodsIncances = $this->identifyMethod->getByUserData($identifyMethods); - if (empty($identifyMethodsIncances)) { - throw new \Exception($this->l10n->t('Invalid identification method')); - } - $signRequest = $this->getSignRequestByIdentifyMethod( - current($identifyMethodsIncances), - $fileId - ); - $displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsIncances, $displayName); - $this->setDataToUser($signRequest, $displayName, $description, $fileId); - - $signRequest->setSigningOrder($signingOrder); - - $isNewSignRequest = !$signRequest->getId(); - $currentStatus = $signRequest->getStatusEnum(); - - if ($isNewSignRequest || $currentStatus === \OCA\Libresign\Enum\SignRequestStatus::DRAFT) { - $desiredStatus = $this->signRequestStatusService->determineInitialStatus($signingOrder, $fileId, $fileStatus, $signerStatus, $currentStatus); - $this->signRequestStatusService->updateStatusIfAllowed($signRequest, $currentStatus, $desiredStatus, $isNewSignRequest); - } - - $this->saveSignRequest($signRequest); - - $shouldNotify = $notify && $this->signRequestStatusService->shouldNotifySignRequest( - $signRequest->getStatusEnum(), - $fileStatus - ); - - foreach ($identifyMethodsIncances as $identifyMethod) { - $identifyMethod->getEntity()->setSignRequestId($signRequest->getId()); - $identifyMethod->willNotifyUser($shouldNotify); - $identifyMethod->save(); - } - return $signRequest; - } - /** - * @param IIdentifyMethod[] $identifyMethodsIncances - * @param string $displayName - * @return string - */ - private function getDisplayNameFromIdentifyMethodIfEmpty(array $identifyMethodsIncances, string $displayName): string { - if (!empty($displayName)) { - return $displayName; - } - foreach ($identifyMethodsIncances as $identifyMethod) { - if ($identifyMethod->getName() === 'account') { - return $this->userManager->get($identifyMethod->getEntity()->getIdentifierValue())->getDisplayName(); - } - } - foreach ($identifyMethodsIncances as $identifyMethod) { - if ($identifyMethod->getName() !== 'account') { - return $identifyMethod->getEntity()->getIdentifierValue(); - } - } - return ''; - } private function saveVisibleElements(array $data, FileEntity $file): array { if (empty($data['visibleElements'])) { return []; } - $elements = $data['visibleElements']; - foreach ($elements as $key => $element) { - $element['fileId'] = $file->getId(); - $elements[$key] = $this->fileElementService->saveVisibleElement($element); + $persisted = []; + foreach ($data['visibleElements'] as $element) { + if ($file->isEnvelope() && !empty($element['signRequestId'])) { + $envelopeSignRequest = $this->signRequestMapper->getById((int)$element['signRequestId']); + // Only translate if the provided SR belongs to the envelope itself + if ($envelopeSignRequest && $envelopeSignRequest->getFileId() === $file->getId()) { + $childrenSrs = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod($file->getId(), (int)$element['signRequestId']); + foreach ($childrenSrs as $childSr) { + if ($childSr->getFileId() === (int)$element['fileId']) { + $element['signRequestId'] = $childSr->getId(); + break; + } + } + } + } + + $persisted[] = $this->fileElementService->saveVisibleElement($element); } - return $elements; + return $persisted; } + public function validateNewRequestToFile(array $data): void { $this->validateNewFile($data); $this->validateUsers($data); @@ -400,69 +560,65 @@ public function validateUsers(array $data): void { } } - public function saveSignRequest(SignRequestEntity $signRequest): void { - if ($signRequest->getId()) { - $this->signRequestMapper->update($signRequest); - } else { - $this->signRequestMapper->insert($signRequest); - } - } - - /** - * @psalm-suppress MixedMethodCall - */ - private function setDataToUser(SignRequestEntity $signRequest, string $displayName, string $description, int $fileId): void { - $signRequest->setFileId($fileId); - if (!$signRequest->getUuid()) { - $signRequest->setUuid(UUIDUtil::getUUID()); - } - if (!empty($displayName)) { - $signRequest->setDisplayName($displayName); - } - if (!empty($description)) { - $signRequest->setDescription($description); - } - if (!$signRequest->getId()) { - $signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); - } - } - private function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, int $fileId): SignRequestEntity { - try { - $signRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId($identifyMethod, $fileId); - } catch (DoesNotExistException) { - $signRequest = new SignRequestEntity(); - } - return $signRequest; - } public function unassociateToUser(int $fileId, int $signRequestId): void { + $file = $this->fileMapper->getById($fileId); $signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($fileId, $signRequestId); $deletedOrder = $signRequest->getSigningOrder(); $groupedIdentifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequestId); - $this->dispatchCancellationEventIfNeeded($signRequest, $fileId, $groupedIdentifyMethods); + $this->dispatchCancellationEventIfNeeded($signRequest, $file, $groupedIdentifyMethods); try { $this->signRequestMapper->delete($signRequest); - foreach ($groupedIdentifyMethods as $identifyMethods) { - foreach ($identifyMethods as $identifyMethod) { - $identifyMethod->delete(); - } - } + $this->identifyMethod->deleteBySignRequestId($signRequestId); $visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId); foreach ($visibleElements as $visibleElement) { $this->fileElementMapper->delete($visibleElement); } - $this->sequentialSigningService->reorderAfterDeletion($fileId, $deletedOrder); + $this->sequentialSigningService + ->setFile($file) + ->reorderAfterDeletion($file->getId(), $deletedOrder); + + $this->propagateSignerDeletionToChildren($file, $signRequest); } catch (\Throwable) { } } + private function propagateSignerDeletionToChildren(FileEntity $envelope, SignRequestEntity $deletedSignRequest): void { + if ($envelope->getNodeType() !== 'envelope') { + return; + } + + $children = $this->fileMapper->getChildrenFiles($envelope->getId()); + + $identifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($deletedSignRequest->getId()); + if (empty($identifyMethods)) { + return; + } + + foreach ($children as $child) { + try { + $this->identifyMethod->clearCache(); + $childSignRequest = $this->signRequestService->getSignRequestByIdentifyMethod( + current(reset($identifyMethods)), + $child->getId() + ); + + if ($childSignRequest->getId()) { + $this->unassociateToUser($child->getId(), $childSignRequest->getId()); + } + } catch (\Throwable $e) { + continue; + } + } + } + private function dispatchCancellationEventIfNeeded( SignRequestEntity $signRequest, - int $fileId, + FileEntity $file, array $groupedIdentifyMethods, ): void { if ($signRequest->getStatus() !== \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN->value) { @@ -470,12 +626,11 @@ private function dispatchCancellationEventIfNeeded( } try { - $libreSignFile = $this->fileMapper->getByFileId($fileId); foreach ($groupedIdentifyMethods as $identifyMethods) { foreach ($identifyMethods as $identifyMethod) { $event = new SignRequestCanceledEvent( $signRequest, - $libreSignFile, + $file, $identifyMethod, ); $this->eventDispatcher->dispatchTyped($event); @@ -491,12 +646,13 @@ public function deleteRequestSignature(array $data): void { $signatures = $this->signRequestMapper->getByFileUuid($data['uuid']); $fileData = $this->fileMapper->getByUuid($data['uuid']); } elseif (!empty($data['file']['fileId'])) { - $signatures = $this->signRequestMapper->getByNodeId($data['file']['fileId']); - $fileData = $this->fileMapper->getByFileId($data['file']['fileId']); + $fileData = $this->fileMapper->getById($data['file']['fileId']); + $signatures = $this->signRequestMapper->getByFileId($fileData->getId()); } else { throw new \Exception($this->l10n->t('Please provide either UUID or File object')); } foreach ($signatures as $signRequest) { + $this->identifyMethod->deleteBySignRequestId($signRequest->getId()); $this->signRequestMapper->delete($signRequest); } $this->fileMapper->delete($fileData); diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 0d517059b3..d2342d17fc 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -62,7 +62,7 @@ use Sabre\DAV\UUIDUtil; class SignFileService { - private SignRequestEntity $signRequest; + private ?SignRequestEntity $signRequest = null; private string $password = ''; private ?FileEntity $libreSignFile = null; /** @var VisibleElementAssoc[] */ @@ -106,6 +106,7 @@ public function __construct( private DocMdpHandler $docMdpHandler, private PdfSignatureDetectionService $pdfSignatureDetectionService, private SequentialSigningService $sequentialSigningService, + private FileStatusService $fileStatusService, ) { } @@ -214,6 +215,9 @@ public function setCurrentUser(?IUser $user): self { } public function setVisibleElements(array $list): self { + if (!$this->signRequest instanceof SignRequestEntity) { + return $this; + } $fileElements = $this->fileElementMapper->getByFileIdAndSignRequestId($this->signRequest->getFileId(), $this->signRequest->getId()); $canCreateSignature = $this->signerElementsService->canCreateSignature(); @@ -320,18 +324,138 @@ 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; + $envelopeLastSignedDate = null; + $lastSignedFile = null; + + $signRequests = $this->getSignRequestsToSign(); + + 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->fileToSign = null; + + $this->validateDocMdpAllowsSignatures(); + $signedFile = $this->getEngine()->sign(); + $lastSignedFile = $signedFile; + + $hash = $this->computeHash($signedFile); + $envelopeLastSignedDate = $this->getEngine()->getLastSignedDate(); + + $this->updateSignRequest($hash); + $this->updateLibreSignFile($signedFile->getId(), $hash); + + $this->dispatchSignedEvent(); + } + + $this->libreSignFile = $originalLibreSignFile; + $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); + } + } + } - $hash = $this->computeHash($signedFile); + /** + * @return array Array of ['file' => FileEntity, 'signRequest' => SignRequestEntity] + */ + private function getSignRequestsToSign(): array { + if (!$this->libreSignFile->isEnvelope()) { + return [[ + 'file' => $this->libreSignFile, + 'signRequest' => $this->signRequest, + ]]; + } - $this->updateSignRequest($hash); - $this->updateLibreSignFile($hash); + $childFiles = $this->fileMapper->getChildrenFiles($this->libreSignFile->getId()); - $this->dispatchSignedEvent(); + if (empty($childFiles)) { + throw new LibresignException('No files found in envelope'); + } - return $signedFile; + $childSignRequests = $this->signRequestMapper->getByEnvelopeChildrenAndIdentifyMethod( + $this->libreSignFile->getId(), + $this->signRequest->getId() + ); + + 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()); + + $totalSignRequests = 0; + $signedSignRequests = 0; + + foreach ($childFiles as $childFile) { + $signRequests = $this->signRequestMapper->getByFileId($childFile->getId()); + $totalSignRequests += count($signRequests); + + foreach ($signRequests as $signRequest) { + if ($signRequest->getSigned()) { + $signedSignRequests++; + } + } + } + + 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); + } else { + $this->libreSignFile->setStatus(FileEntity::STATUS_PARTIAL_SIGNED); + } + + $this->fileMapper->update($this->libreSignFile); } /** @@ -368,7 +492,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) { @@ -399,12 +527,15 @@ 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(); $this->fileMapper->update($this->libreSignFile); + + if ($this->libreSignFile->hasParent()) { + $this->fileStatusService->propagateStatusToParent($this->libreSignFile->getParentFileId()); + } } protected function dispatchSignedEvent(): void { @@ -451,7 +582,7 @@ private function addEmailToSignatureParams(array $signatureParams, array $certif if (empty($signatureParams['SignerEmail']) && $this->user instanceof IUser) { $signatureParams['SignerEmail'] = $this->user->getEMailAddress(); } - if (empty($signatureParams['SignerEmail'])) { + if (empty($signatureParams['SignerEmail']) && $this->signRequest instanceof SignRequestEntity) { $identifyMethod = $this->identifyMethodService->getIdentifiedMethod($this->signRequest->getId()); if ($identifyMethod->getName() === IdentifyMethodService::IDENTIFY_EMAIL) { $signatureParams['SignerEmail'] = $identifyMethod->getEntity()->getIdentifierValue(); @@ -621,7 +752,7 @@ private function configureEngine(): void { public function getLibresignFile(?int $nodeId, ?string $signRequestUuid = null): FileEntity { try { if ($nodeId) { - return $this->fileMapper->getByFileId($nodeId); + return $this->fileMapper->getByNodeId($nodeId); } if ($signRequestUuid) { @@ -681,7 +812,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); @@ -834,7 +975,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 +998,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 +1056,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; } } diff --git a/lib/Service/SignRequestService.php b/lib/Service/SignRequestService.php new file mode 100644 index 0000000000..7e701694b6 --- /dev/null +++ b/lib/Service/SignRequestService.php @@ -0,0 +1,156 @@ +identifyMethodService->getByUserData($identifyMethods); + if (empty($identifyMethodsInstances)) { + throw new \Exception($this->l10n->t('Invalid identification method')); + } + + $signRequest = $this->getSignRequestByIdentifyMethod( + current($identifyMethodsInstances), + $fileId + ); + + $displayName = $this->getDisplayNameFromIdentifyMethodIfEmpty($identifyMethodsInstances, $displayName); + $this->populateSignRequest($signRequest, $displayName, $signingOrder, $description, $fileId); + + $isNewSignRequest = !$signRequest->getId(); + $currentStatus = $signRequest->getStatusEnum(); + + if ($isNewSignRequest || $currentStatus === \OCA\Libresign\Enum\SignRequestStatus::DRAFT) { + $desiredStatus = $this->signRequestStatusService->determineInitialStatus( + $signingOrder, + $fileId, + $fileStatus, + $signerStatus, + $currentStatus + ); + $this->signRequestStatusService->updateStatusIfAllowed($signRequest, $currentStatus, $desiredStatus, $isNewSignRequest); + } + + $this->insertOrUpdateSignRequest($signRequest); + + $shouldNotify = $notify && $this->signRequestStatusService->shouldNotifySignRequest( + $signRequest->getStatusEnum(), + $fileStatus + ); + + foreach ($identifyMethodsInstances as $identifyMethod) { + $identifyMethod->getEntity()->setSignRequestId($signRequest->getId()); + $identifyMethod->willNotifyUser($shouldNotify); + $identifyMethod->save(); + } + + return $signRequest; + } + + public function getSignRequestByIdentifyMethod(IIdentifyMethod $identifyMethod, int $fileId): SignRequestEntity { + try { + $signRequest = $this->signRequestMapper->getByIdentifyMethodAndFileId($identifyMethod, $fileId); + } catch (DoesNotExistException) { + $signRequest = new SignRequestEntity(); + } + return $signRequest; + } + + private function populateSignRequest( + SignRequestEntity $signRequest, + string $displayName, + int $signingOrder, + string $description, + int $fileId, + ): void { + $signRequest->setFileId($fileId); + $signRequest->setSigningOrder($signingOrder); + if (!$signRequest->getUuid()) { + $signRequest->setUuid(UUIDUtil::getUUID()); + } + if (!empty($displayName)) { + $signRequest->setDisplayName($displayName); + } + if (!empty($description)) { + $signRequest->setDescription($description); + } + if (!$signRequest->getId()) { + $signRequest->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + } + } + + /** + * @param IIdentifyMethod[] $identifyMethodsInstances + * @param string $displayName + * @return string + */ + private function getDisplayNameFromIdentifyMethodIfEmpty(array $identifyMethodsInstances, string $displayName): string { + if (!empty($displayName)) { + return $displayName; + } + foreach ($identifyMethodsInstances as $identifyMethod) { + if ($identifyMethod->getName() === 'account') { + return $this->userManager->get($identifyMethod->getEntity()->getIdentifierValue())->getDisplayName(); + } + } + foreach ($identifyMethodsInstances as $identifyMethod) { + if ($identifyMethod->getName() !== 'account') { + return $identifyMethod->getEntity()->getIdentifierValue(); + } + } + return ''; + } + + public function insertOrUpdateSignRequest(SignRequestEntity $signRequest): void { + if ($signRequest->getId()) { + $this->signRequestMapper->update($signRequest); + } else { + $this->signRequestMapper->insert($signRequest); + } + } +} diff --git a/lib/Service/TFile.php b/lib/Service/TFile.php deleted file mode 100644 index 77a10f144f..0000000000 --- a/lib/Service/TFile.php +++ /dev/null @@ -1,179 +0,0 @@ -folderService->getUserId()) { - $this->folderService->setUserId($data['userManager']->getUID()); - } - if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) { - return $data['file']['fileNode']; - } - if (isset($data['file']['fileId'])) { - return $this->folderService->getFileById($data['file']['fileId']); - } - if (isset($data['file']['path'])) { - return $this->folderService->getFileByPath($data['file']['path']); - } - - $content = $this->getFileRaw($data); - $extension = $this->getExtension($content); - - $this->validateFileContent($content, $extension); - - $userFolder = $this->folderService->getFolder(); - $folderName = $this->folderService->getFolderName($data, $data['userManager']); - $folderToFile = $userFolder->newFolder($folderName); - return $folderToFile->newFile($data['name'] . '.' . $extension, $content); - } - - /** - * @throws \Exception - * @throws LibresignException - */ - public function validateFileContent(string $content, string $extension): void { - if ($extension === 'pdf') { - $this->validatePdfStringWithFpdi($content); - $this->validateDocMdpAllowsSignatures($content); - } - } - - private function setMimeType(string $mimetype): void { - $this->validateHelper->validateMimeTypeAcceptedByMime($mimetype); - $this->mimetype = $mimetype; - } - - private function getMimeType(string $content): ?string { - if (!$this->mimetype) { - $this->setMimeType($this->mimeTypeDetector->detectString($content)); - } - return $this->mimetype; - } - - private function getExtension(string $content): string { - $mimetype = $this->getMimeType($content); - $mappings = $this->mimeTypeDetector->getAllMappings(); - foreach ($mappings as $ext => $mimetypes) { - // Single digit extensions will be treated as integers - // Let's make sure they are strings - // https://github.com/nextcloud/server/issues/42902 - $ext = (string)$ext; - if ($ext[0] === '_') { - // comment - continue; - } - if (in_array($mimetype, $mimetypes)) { - return $ext; - } - } - return ''; - } - - /** - * @return resource|string - */ - private function getFileRaw(array $data) { - if (!empty($data['file']['url'])) { - if (!filter_var($data['file']['url'], FILTER_VALIDATE_URL)) { - throw new \Exception($this->l10n->t('Invalid URL file')); - } - try { - $response = $this->client->newClient()->get($data['file']['url']); - } catch (\Throwable) { - throw new \Exception($this->l10n->t('Invalid URL file')); - } - $mimetypeFromHeader = $response->getHeader('Content-Type'); - $content = (string)$response->getBody(); - if (!$content) { - throw new \Exception($this->l10n->t('Empty file')); - } - $mimeTypeFromContent = $this->getMimeType($content); - if ($mimetypeFromHeader !== $mimeTypeFromContent) { - throw new \Exception($this->l10n->t('Invalid URL file')); - } - } else { - $content = $this->getFileFromBase64($data['file']['base64']); - } - return $content; - } - - private function getFileFromBase64(string $base64): string { - $withMime = explode(',', $base64); - if (count($withMime) === 2) { - $withMime[0] = explode(';', $withMime[0]); - $withMime[0][0] = explode(':', $withMime[0][0]); - $mimeTypeFromType = $withMime[0][0][1]; - - $base64 = $withMime[1]; - - $content = base64_decode($base64); - $mimeTypeFromContent = $this->getMimeType($content); - if ($mimeTypeFromType !== $mimeTypeFromContent) { - throw new \Exception($this->l10n->t('Invalid URL file')); - } - $this->setMimeType($mimeTypeFromContent); - } else { - $content = base64_decode($base64); - $this->getMimeType($content); - } - return $content; - } - - /** - * Validates a PDF. Triggers error if invalid. - * - * @param string $string - * - * @throws PdfTypeException - */ - private function validatePdfStringWithFpdi($string): void { - try { - $parser = new \OCA\Libresign\Vendor\Smalot\PdfParser\Parser(); - $parser->parseContent($string); - } catch (\Throwable $th) { - $this->logger->error($th->getMessage()); - throw new \Exception($this->l10n->t('Invalid PDF')); - } - } - - /** - * @throws LibresignException - */ - private function validateDocMdpAllowsSignatures(string $pdfContent): void { - $resource = fopen('php://memory', 'r+'); - if (!is_resource($resource)) { - return; - } - - try { - fwrite($resource, $pdfContent); - rewind($resource); - - if (!$this->docMdpHandler->allowsAdditionalSignatures($resource)) { - throw new LibresignException( - $this->l10n->t('This document has been certified with no changes allowed, so no additional signatures can be added.') - ); - } - } finally { - fclose($resource); - } - } -} diff --git a/openapi-administration.json b/openapi-administration.json index 7e503d90a6..fd4f267037 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -37,7 +37,8 @@ "config": { "type": "object", "required": [ - "sign-elements" + "sign-elements", + "envelope" ], "properties": { "sign-elements": { @@ -74,6 +75,17 @@ "format": "double" } } + }, + "envelope": { + "type": "object", + "required": [ + "is-available" + ], + "properties": { + "is-available": { + "type": "boolean" + } + } } } }, diff --git a/openapi-full.json b/openapi-full.json index 06e6e1fc22..3950dd9f6c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -37,7 +37,8 @@ "config": { "type": "object", "required": [ - "sign-elements" + "sign-elements", + "envelope" ], "properties": { "sign-elements": { @@ -74,6 +75,17 @@ "format": "double" } } + }, + "envelope": { + "type": "object", + "required": [ + "is-available" + ], + "properties": { + "is-available": { + "type": "boolean" + } + } } } }, @@ -179,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" } } }, @@ -249,6 +252,97 @@ } } }, + "EnvelopeChildFile": { + "type": "object", + "required": [ + "id", + "uuid", + "name", + "status", + "statusText", + "nodeId", + "signers" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "nodeId": { + "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": { + "$ref": "#/components/schemas/EnvelopeChildSignerSummary" + } + }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + } + } + }, + "EnvelopeChildSignerSummary": { + "type": "object", + "required": [ + "signRequestId", + "displayName", + "email", + "signed", + "status", + "statusText" + ], + "properties": { + "signRequestId": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "signed": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + } + } + }, "File": { "type": "object", "required": [ @@ -373,6 +467,144 @@ } } }, + "FileDetail": { + "type": "object", + "required": [ + "created_at", + "file", + "files", + "filesCount", + "id", + "metadata", + "name", + "nodeId", + "nodeType", + "requested_by", + "signatureFlow", + "signers", + "status", + "statusText", + "uuid", + "visibleElements" + ], + "properties": { + "created_at": { + "type": "string" + }, + "file": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "nodeId", + "uuid", + "name", + "status", + "statusText" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + } + } + } + }, + "filesCount": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "name": { + "type": "string" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "nodeType": { + "type": "string" + }, + "requested_by": { + "type": "object", + "required": [ + "userId", + "displayName" + ], + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "signatureFlow": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + }, + "signers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signer" + } + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "visibleElements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + } + } + }, "FolderSettings": { "type": "object", "properties": { @@ -541,9 +773,20 @@ "message", "name", "id", + "nodeId", + "uuid", "status", "statusText", - "created_at" + "nodeType", + "created_at", + "file", + "metadata", + "signatureFlow", + "visibleElements", + "signers", + "requested_by", + "filesCount", + "files" ], "properties": { "message": { @@ -556,6 +799,13 @@ "type": "integer", "format": "int64" }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, "status": { "type": "integer", "format": "int64" @@ -563,8 +813,93 @@ "statusText": { "type": "string" }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, "created_at": { "type": "string" + }, + "file": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + }, + "signatureFlow": { + "type": "string", + "enum": [ + "none", + "parallel", + "ordered_numeric" + ] + }, + "visibleElements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + }, + "signers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signer" + } + }, + "requested_by": { + "type": "object", + "required": [ + "userId", + "displayName" + ], + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, + "filesCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "nodeId", + "uuid", + "name", + "status", + "statusText" + ], + "properties": { + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + } + } + } } } }, @@ -744,7 +1079,10 @@ "canSign", "canRequestSign", "signerFileUuid", - "phoneNumber" + "phoneNumber", + "hasSignatureFile", + "needIdentificationDocuments", + "identificationDocumentsWaitingApproval" ], "properties": { "canSign": { @@ -757,12 +1095,12 @@ "type": "string", "nullable": true }, - "hasSignatureFile": { - "type": "boolean" - }, "phoneNumber": { "type": "string" }, + "hasSignatureFile": { + "type": "boolean" + }, "needIdentificationDocuments": { "type": "boolean" }, @@ -965,6 +1303,9 @@ }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" + }, + "uid": { + "type": "string" } } }, @@ -1021,11 +1362,13 @@ "ValidateFile": { "type": "object", "required": [ + "id", "uuid", "name", "status", "statusText", "nodeId", + "nodeType", "signatureFlow", "docmdpLevel", "totalPages", @@ -1036,6 +1379,10 @@ "file" ], "properties": { + "id": { + "type": "integer", + "format": "int64" + }, "uuid": { "type": "string" }, @@ -1061,6 +1408,13 @@ "format": "int64", "minimum": 0 }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, "signatureFlow": { "type": "integer", "format": "int64" @@ -1069,6 +1423,17 @@ "type": "integer", "format": "int64" }, + "filesCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnvelopeChildFile" + } + }, "totalPages": { "type": "integer", "format": "int64", @@ -1106,23 +1471,22 @@ "url": { "type": "string" }, - "metadata": { - "type": "object", - "required": [ - "extension", - "p" - ], - "properties": { - "extension": { - "type": "string" - }, - "p": { - "type": "integer", - "format": "int64" - }, - "d": { - "type": "array", - "items": { + "mime": { + "type": "string" + }, + "pages": { + "type": "array", + "items": { + "type": "object", + "required": [ + "url", + "resolution" + ], + "properties": { + "url": { + "type": "string" + }, + "resolution": { "type": "object", "required": [ "w", @@ -1142,6 +1506,9 @@ } } }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + }, "signers": { "type": "array", "items": { @@ -1180,24 +1547,66 @@ } } }, + "ValidateMetadata": { + "type": "object", + "required": [ + "extension", + "p" + ], + "properties": { + "extension": { + "type": "string" + }, + "p": { + "type": "integer", + "format": "int64" + }, + "d": { + "type": "array", + "items": { + "type": "object", + "required": [ + "w", + "h" + ], + "properties": { + "w": { + "type": "number", + "format": "double" + }, + "h": { + "type": "number", + "format": "double" + } + } + } + }, + "pdfVersion": { + "type": "string" + } + } + }, "VisibleElement": { "type": "object", "required": [ "elementId", "signRequestId", + "fileId", "type", "coordinates" ], "properties": { "elementId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "signRequestId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" + }, + "fileId": { + "type": "integer", + "format": "int64" }, "type": { "type": "string" @@ -1749,57 +2158,15 @@ "schema": { "type": "string" } - }, - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/index.php/apps/libresign/p/sign/{uuid}": { - "get": { - "operationId": "page-sign", - "summary": "Sign page to unauthenticated signer", - "description": "The path is used only by frontend", - "tags": [ - "page" - ], - "security": [ - {}, - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "Sign request uuid", - "required": true, - "schema": { - "type": "string" - } + }, + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } } ], "responses": { @@ -1816,14 +2183,16 @@ } } }, - "/index.php/apps/libresign/p/id-docs/approve/{uuid}": { + "/index.php/apps/libresign/p/sign/{uuid}": { "get": { - "operationId": "page-sign-id-doc", - "summary": "Show signature page", + "operationId": "page-sign", + "summary": "Sign page to unauthenticated signer", + "description": "The path is used only by frontend", "tags": [ "page" ], "security": [ + {}, { "bearer_auth": [] }, @@ -1856,10 +2225,10 @@ } } }, - "/index.php/apps/libresign/p/id-docs/approve/{uuid}/{path}": { + "/index.php/apps/libresign/p/id-docs/approve/{uuid}": { "get": { - "operationId": "page-sign-id-doc-private", - "summary": "Show signature page", + "operationId": "page-sign-id-doc", + "summary": "Show signature page for identification document approval", "tags": [ "page" ], @@ -1875,20 +2244,11 @@ { "name": "uuid", "in": "path", - "description": "Sign request uuid", + "description": "File UUID for the identification document approval", "required": true, "schema": { "type": "string" } - }, - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" - } } ], "responses": { @@ -3485,7 +3845,7 @@ "get": { "operationId": "file-validate-uuid", "summary": "Validate a file using Uuid", - "description": "Validate a file returning file data.", + "description": "Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -3644,7 +4004,7 @@ "get": { "operationId": "file-validate-file-id", "summary": "Validate a file using FileId", - "description": "Validate a file returning file data.", + "description": "Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -3804,7 +4164,7 @@ "post": { "operationId": "file-validate-binary", "summary": "Validate a binary file", - "description": "Validate a binary file returning file data. Use field 'file' for the file upload", + "description": "Validate a binary file returning file data. Use field 'file' for the file upload. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -4151,6 +4511,16 @@ "nullable": true } }, + { + "name": "parentFileId", + "in": "query", + "description": "Filter files by parent envelope file ID", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -4195,10 +4565,12 @@ }, "data": { "type": "array", - "nullable": true, "items": { - "$ref": "#/components/schemas/File" + "$ref": "#/components/schemas/FileDetail" } + }, + "settings": { + "$ref": "#/components/schemas/Settings" } } } @@ -4443,7 +4815,7 @@ "post": { "operationId": "file-save", "summary": "Send a file", - "description": "Send a new file to Nextcloud and return the fileId to request signature", + "description": "Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.", "tags": [ "file" ], @@ -4456,17 +4828,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 +4848,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" + } } } } @@ -4580,6 +4958,203 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/{uuid}/add-file": { + "post": { + "operationId": "file-add-file-to-envelope", + "summary": "Add file to envelope", + "description": "Add one or more files to an existing envelope that is in DRAFT status. Files must be uploaded as multipart/form-data with field name 'files[]'.", + "tags": [ + "file" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "uuid", + "in": "path", + "description": "The UUID of the envelope", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Files added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NextcloudFile" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Envelope not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "422": { + "description": "Cannot add files (envelope not in DRAFT status or validation failed)", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/file_id/{fileId}": { "delete": { "operationId": "file-delete-all-request-signature-using-file-id", @@ -4612,7 +5187,7 @@ { "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", @@ -4804,6 +5379,12 @@ "nullable": true, "description": "ID of visible element. Each element has an ID that is returned on validation endpoints." }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "File ID when using node identifier instead of UUID" + }, "type": { "type": "string", "default": "", @@ -4983,6 +5564,12 @@ "format": "int64", "description": "Id of sign request" }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "File ID when using node identifier instead of UUID" + }, "type": { "type": "string", "default": "", @@ -6466,7 +7053,7 @@ "post": { "operationId": "request_signature-request", "summary": "Request signature", - "description": "Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled.", + "description": "Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. When the created entity is an envelope (`nodeType` = `envelope`), the returned `data` includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "request_signature" ], @@ -6580,7 +7167,7 @@ ], "properties": { "data": { - "$ref": "#/components/schemas/ValidateFile" + "$ref": "#/components/schemas/FileDetail" }, "message": { "type": "string" @@ -6720,6 +7307,11 @@ "type": "string", "nullable": true, "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + }, + "name": { + "type": "string", + "nullable": true, + "description": "The name of file to sign" } } } @@ -6782,7 +7374,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/ValidateFile" + "$ref": "#/components/schemas/FileDetail" } } } @@ -6884,7 +7476,7 @@ { "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", diff --git a/openapi.json b/openapi.json index 1a54d7bca9..e29adc7356 100644 --- a/openapi.json +++ b/openapi.json @@ -37,7 +37,8 @@ "config": { "type": "object", "required": [ - "sign-elements" + "sign-elements", + "envelope" ], "properties": { "sign-elements": { @@ -74,6 +75,17 @@ "format": "double" } } + }, + "envelope": { + "type": "object", + "required": [ + "is-available" + ], + "properties": { + "is-available": { + "type": "boolean" + } + } } } }, @@ -134,48 +146,130 @@ "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" + }, + "height": { + "type": "integer", + "format": "int64" + } + } + }, + "EnvelopeChildFile": { + "type": "object", + "required": [ + "id", + "uuid", + "name", + "status", + "statusText", + "nodeId", + "signers" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "totalPages": { "type": "integer", "format": "int64", "minimum": 0 }, - "height": { + "size": { "type": "integer", "format": "int64", "minimum": 0 + }, + "pdfVersion": { + "type": "string" + }, + "signers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnvelopeChildSignerSummary" + } + }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + } + } + }, + "EnvelopeChildSignerSummary": { + "type": "object", + "required": [ + "signRequestId", + "displayName", + "email", + "signed", + "status", + "statusText" + ], + "properties": { + "signRequestId": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "signed": { + "type": "string", + "nullable": true + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" } } }, @@ -303,6 +397,144 @@ } } }, + "FileDetail": { + "type": "object", + "required": [ + "created_at", + "file", + "files", + "filesCount", + "id", + "metadata", + "name", + "nodeId", + "nodeType", + "requested_by", + "signatureFlow", + "signers", + "status", + "statusText", + "uuid", + "visibleElements" + ], + "properties": { + "created_at": { + "type": "string" + }, + "file": { + "type": "string" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "nodeId", + "uuid", + "name", + "status", + "statusText" + ], + "properties": { + "fileId": { + "type": "integer", + "format": "int64" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + } + } + } + }, + "filesCount": { + "type": "integer", + "format": "int64" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "name": { + "type": "string" + }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "nodeType": { + "type": "string" + }, + "requested_by": { + "type": "object", + "required": [ + "userId", + "displayName" + ], + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string", + "nullable": true + } + } + }, + "signatureFlow": { + "oneOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ] + }, + "signers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signer" + } + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + }, + "uuid": { + "type": "string" + }, + "visibleElements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + } + } + }, "FolderSettings": { "type": "object", "properties": { @@ -471,9 +703,20 @@ "message", "name", "id", + "nodeId", + "uuid", "status", "statusText", - "created_at" + "nodeType", + "created_at", + "file", + "metadata", + "signatureFlow", + "visibleElements", + "signers", + "requested_by", + "filesCount", + "files" ], "properties": { "message": { @@ -486,6 +729,13 @@ "type": "integer", "format": "int64" }, + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, "status": { "type": "integer", "format": "int64" @@ -493,8 +743,93 @@ "statusText": { "type": "string" }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, "created_at": { "type": "string" + }, + "file": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + }, + "signatureFlow": { + "type": "string", + "enum": [ + "none", + "parallel", + "ordered_numeric" + ] + }, + "visibleElements": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + }, + "signers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Signer" + } + }, + "requested_by": { + "type": "object", + "required": [ + "userId", + "displayName" + ], + "properties": { + "userId": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, + "filesCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "type": "object", + "required": [ + "nodeId", + "uuid", + "name", + "status", + "statusText" + ], + "properties": { + "nodeId": { + "type": "integer", + "format": "int64" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "integer", + "format": "int64" + }, + "statusText": { + "type": "string" + } + } + } } } }, @@ -594,7 +929,10 @@ "canSign", "canRequestSign", "signerFileUuid", - "phoneNumber" + "phoneNumber", + "hasSignatureFile", + "needIdentificationDocuments", + "identificationDocumentsWaitingApproval" ], "properties": { "canSign": { @@ -607,12 +945,12 @@ "type": "string", "nullable": true }, - "hasSignatureFile": { - "type": "boolean" - }, "phoneNumber": { "type": "string" }, + "hasSignatureFile": { + "type": "boolean" + }, "needIdentificationDocuments": { "type": "boolean" }, @@ -815,6 +1153,9 @@ }, "signatureMethods": { "$ref": "#/components/schemas/SignatureMethods" + }, + "uid": { + "type": "string" } } }, @@ -871,11 +1212,13 @@ "ValidateFile": { "type": "object", "required": [ + "id", "uuid", "name", "status", "statusText", "nodeId", + "nodeType", "signatureFlow", "docmdpLevel", "totalPages", @@ -886,6 +1229,10 @@ "file" ], "properties": { + "id": { + "type": "integer", + "format": "int64" + }, "uuid": { "type": "string" }, @@ -911,6 +1258,13 @@ "format": "int64", "minimum": 0 }, + "nodeType": { + "type": "string", + "enum": [ + "file", + "envelope" + ] + }, "signatureFlow": { "type": "integer", "format": "int64" @@ -919,6 +1273,17 @@ "type": "integer", "format": "int64" }, + "filesCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "files": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EnvelopeChildFile" + } + }, "totalPages": { "type": "integer", "format": "int64", @@ -956,23 +1321,22 @@ "url": { "type": "string" }, - "metadata": { - "type": "object", - "required": [ - "extension", - "p" - ], - "properties": { - "extension": { - "type": "string" - }, - "p": { - "type": "integer", - "format": "int64" - }, - "d": { - "type": "array", - "items": { + "mime": { + "type": "string" + }, + "pages": { + "type": "array", + "items": { + "type": "object", + "required": [ + "url", + "resolution" + ], + "properties": { + "url": { + "type": "string" + }, + "resolution": { "type": "object", "required": [ "w", @@ -992,6 +1356,9 @@ } } }, + "metadata": { + "$ref": "#/components/schemas/ValidateMetadata" + }, "signers": { "type": "array", "items": { @@ -1030,24 +1397,66 @@ } } }, + "ValidateMetadata": { + "type": "object", + "required": [ + "extension", + "p" + ], + "properties": { + "extension": { + "type": "string" + }, + "p": { + "type": "integer", + "format": "int64" + }, + "d": { + "type": "array", + "items": { + "type": "object", + "required": [ + "w", + "h" + ], + "properties": { + "w": { + "type": "number", + "format": "double" + }, + "h": { + "type": "number", + "format": "double" + } + } + } + }, + "pdfVersion": { + "type": "string" + } + } + }, "VisibleElement": { "type": "object", "required": [ "elementId", "signRequestId", + "fileId", "type", "coordinates" ], "properties": { "elementId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, "signRequestId": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" + }, + "fileId": { + "type": "integer", + "format": "int64" }, "type": { "type": "string" @@ -1599,57 +2008,15 @@ "schema": { "type": "string" } - }, - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "text/html": { - "schema": { - "type": "string" - } - } - } - } - } - } - }, - "/index.php/apps/libresign/p/sign/{uuid}": { - "get": { - "operationId": "page-sign", - "summary": "Sign page to unauthenticated signer", - "description": "The path is used only by frontend", - "tags": [ - "page" - ], - "security": [ - {}, - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "uuid", - "in": "path", - "description": "Sign request uuid", - "required": true, - "schema": { - "type": "string" - } + }, + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^.+$" + } } ], "responses": { @@ -1666,14 +2033,16 @@ } } }, - "/index.php/apps/libresign/p/id-docs/approve/{uuid}": { + "/index.php/apps/libresign/p/sign/{uuid}": { "get": { - "operationId": "page-sign-id-doc", - "summary": "Show signature page", + "operationId": "page-sign", + "summary": "Sign page to unauthenticated signer", + "description": "The path is used only by frontend", "tags": [ "page" ], "security": [ + {}, { "bearer_auth": [] }, @@ -1706,10 +2075,10 @@ } } }, - "/index.php/apps/libresign/p/id-docs/approve/{uuid}/{path}": { + "/index.php/apps/libresign/p/id-docs/approve/{uuid}": { "get": { - "operationId": "page-sign-id-doc-private", - "summary": "Show signature page", + "operationId": "page-sign-id-doc", + "summary": "Show signature page for identification document approval", "tags": [ "page" ], @@ -1725,20 +2094,11 @@ { "name": "uuid", "in": "path", - "description": "Sign request uuid", + "description": "File UUID for the identification document approval", "required": true, "schema": { "type": "string" } - }, - { - "name": "path", - "in": "path", - "required": true, - "schema": { - "type": "string", - "pattern": "^.+$" - } } ], "responses": { @@ -3335,7 +3695,7 @@ "get": { "operationId": "file-validate-uuid", "summary": "Validate a file using Uuid", - "description": "Validate a file returning file data.", + "description": "Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -3494,7 +3854,7 @@ "get": { "operationId": "file-validate-file-id", "summary": "Validate a file using FileId", - "description": "Validate a file returning file data.", + "description": "Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -3654,7 +4014,7 @@ "post": { "operationId": "file-validate-binary", "summary": "Validate a binary file", - "description": "Validate a binary file returning file data. Use field 'file' for the file upload", + "description": "Validate a binary file returning file data. Use field 'file' for the file upload. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "file" ], @@ -4001,6 +4361,16 @@ "nullable": true } }, + { + "name": "parentFileId", + "in": "query", + "description": "Filter files by parent envelope file ID", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -4045,10 +4415,12 @@ }, "data": { "type": "array", - "nullable": true, "items": { - "$ref": "#/components/schemas/File" + "$ref": "#/components/schemas/FileDetail" } + }, + "settings": { + "$ref": "#/components/schemas/Settings" } } } @@ -4293,7 +4665,7 @@ "post": { "operationId": "file-save", "summary": "Send a file", - "description": "Send a new file to Nextcloud and return the fileId to request signature", + "description": "Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'.", "tags": [ "file" ], @@ -4306,17 +4678,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 +4698,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" + } } } } @@ -4430,6 +4808,203 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/{uuid}/add-file": { + "post": { + "operationId": "file-add-file-to-envelope", + "summary": "Add file to envelope", + "description": "Add one or more files to an existing envelope that is in DRAFT status. Files must be uploaded as multipart/form-data with field name 'files[]'.", + "tags": [ + "file" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "uuid", + "in": "path", + "description": "The UUID of the envelope", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Files added successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NextcloudFile" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Envelope not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "422": { + "description": "Cannot add files (envelope not in DRAFT status or validation failed)", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/file_id/{fileId}": { "delete": { "operationId": "file-delete-all-request-signature-using-file-id", @@ -4462,7 +5037,7 @@ { "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", @@ -4654,6 +5229,12 @@ "nullable": true, "description": "ID of visible element. Each element has an ID that is returned on validation endpoints." }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "File ID when using node identifier instead of UUID" + }, "type": { "type": "string", "default": "", @@ -4833,6 +5414,12 @@ "format": "int64", "description": "Id of sign request" }, + "fileId": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "File ID when using node identifier instead of UUID" + }, "type": { "type": "string", "default": "", @@ -6316,7 +6903,7 @@ "post": { "operationId": "request_signature-request", "summary": "Request signature", - "description": "Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled.", + "description": "Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. When the created entity is an envelope (`nodeType` = `envelope`), the returned `data` includes `filesCount` and `files` as a list of envelope child files.", "tags": [ "request_signature" ], @@ -6430,7 +7017,7 @@ ], "properties": { "data": { - "$ref": "#/components/schemas/ValidateFile" + "$ref": "#/components/schemas/FileDetail" }, "message": { "type": "string" @@ -6570,6 +7157,11 @@ "type": "string", "nullable": true, "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + }, + "name": { + "type": "string", + "nullable": true, + "description": "The name of file to sign" } } } @@ -6632,7 +7224,7 @@ "type": "string" }, "data": { - "$ref": "#/components/schemas/ValidateFile" + "$ref": "#/components/schemas/FileDetail" } } } @@ -6734,7 +7326,7 @@ { "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", diff --git a/psalm.xml b/psalm.xml index 89b74c044f..5fe94c6b98 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,6 +31,7 @@ + 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 @@ + + + + + + diff --git a/src/Components/File/File.vue b/src/Components/File/File.vue index 54911f712e..ecb01e4ef1 100644 --- a/src/Components/File/File.vue +++ b/src/Components/File/File.vue @@ -3,7 +3,7 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> @@ -35,7 +35,7 @@ export default { FileIcon, }, props: { - nodeId: { + fileId: { type: Number, default: 0, required: false, @@ -53,24 +53,30 @@ export default { } }, computed: { - currentNodeId() { - if (this.nodeId) { - return this.nodeId + currentFileId() { + if (this.fileId) { + return this.fileId } - return this.filesStore.selectedNodeId + return this.filesStore.selectedId }, previewUrl() { if (this.backgroundFailed === true) { return null } + + const file = this.filesStore.files[this.currentFileId] + if (!file) { + return null + } + let previewUrl = '' - if (this.filesStore.files[this.currentNodeId]?.uuid?.length > 0) { + if (file.nodeId) { previewUrl = generateOcsUrl('/apps/libresign/api/v1/file/thumbnail/{nodeId}', { - nodeId: this.currentNodeId, + nodeId: file.nodeId, }) } else { previewUrl = window.location.origin + generateUrl('/core/preview?fileId={fileid}', { - fileid: this.currentNodeId, + fileid: this.currentFileId, }) } @@ -88,7 +94,7 @@ export default { }, methods: { openSidebar() { - this.filesStore.selectFile(this.currentNodeId) + this.filesStore.selectFile(this.currentFileId) }, statusToClass(status) { switch (Number(status)) { diff --git a/src/Components/PdfEditor/PdfEditor.vue b/src/Components/PdfEditor/PdfEditor.vue index 39e5ff65c4..8185e482f2 100644 --- a/src/Components/PdfEditor/PdfEditor.vue +++ b/src/Components/PdfEditor/PdfEditor.vue @@ -80,12 +80,18 @@ export default { height: signer.element.coordinates.height, originWidth: signer.element.coordinates.width, originHeight: signer.element.coordinates.height, - x: signer.element.coordinates.llx, - y: signer.element.coordinates.ury, + x: signer.element.coordinates.left, + y: signer.element.coordinates.top, } + + 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/RequestPicker.vue b/src/Components/Request/RequestPicker.vue index 309e014661..706011fbac 100644 --- a/src/Components/Request/RequestPicker.vue +++ b/src/Components/Request/RequestPicker.vue @@ -24,7 +24,7 @@ - {{ t('libresign', 'Choose from Files') }} + {{ envelopeEnabled ? t('libresign', 'Choose from Files (multiple)') : t('libresign', 'Choose from Files') }} @@ -34,9 +34,15 @@ {{ t('libresign', 'Upload') }} + @@ -68,6 +74,15 @@ + diff --git a/src/Components/Request/VisibleElements.vue b/src/Components/Request/VisibleElements.vue index e222f659ce..98f4d9052c 100644 --- a/src/Components/Request/VisibleElements.vue +++ b/src/Components/Request/VisibleElements.vue @@ -54,11 +54,11 @@
-
@@ -108,6 +108,9 @@ export default { signerSelected: null, width: getCapabilities().libresign.config['sign-elements']['full-signature-width'], height: getCapabilities().libresign.config['sign-elements']['full-signature-height'], + filePagesMap: {}, + elementsLoaded: false, + loadedPdfsCount: 0, } }, computed: { @@ -126,8 +129,21 @@ export default { document() { return this.filesStore.getFile() }, + pdfFiles() { + return this.document.files.map(f => f.file) + }, + pdfFileNames() { + return this.document.files.map(f => { + const metadata = f.metadata + const ext = metadata?.extension || 'pdf' + return `${f.name}.${ext}` + }) + }, documentNameWithExtension() { const doc = this.document + if (!doc.metadata?.extension) { + return doc.name + } return `${doc.name}.${doc.metadata.extension}` }, canSign() { @@ -164,11 +180,18 @@ export default { subscribe('libresign:visible-elements-select-signer', this.onSelectSigner) }, beforeUnmount() { - unsubscribe('libresign:show-visible-elements') - unsubscribe('libresign:visible-elements-select-signer') + unsubscribe('libresign:show-visible-elements', this.showModal) + unsubscribe('libresign:visible-elements-select-signer', this.onSelectSigner) }, methods: { - showModal() { + getVuePdfEditor() { + return this.$refs.pdfEditor?.$refs?.vuePdfEditor + }, + getCanvasList() { + const editor = this.getVuePdfEditor() + return editor?.$refs?.pdfBody?.querySelectorAll('canvas') || [] + }, + async showModal() { if (!this.canRequestSign) { return } @@ -177,25 +200,99 @@ export default { } this.modal = true this.filesStore.loading = true + this.buildFilePagesMap() + this.filesStore.loading = false + }, + buildFilePagesMap() { + this.filePagesMap = {} + + let currentPage = 1 + this.document.files.forEach((file, index) => { + const metadata = file.metadata + const pageCount = metadata?.p || 0 + for (let i = 0; i < pageCount; i++) { + this.filePagesMap[currentPage + i] = { + id: file.id, + fileIndex: index, + startPage: currentPage, + fileName: file.name, + } + } + currentPage += pageCount + }) }, closeModal() { this.modal = false this.filesStore.loading = false + this.elementsLoaded = false + this.loadedPdfsCount = 0 + }, + getPageHeightForFile(fileId, page) { + const fileInfo = this.document.files.find(f => f.id === fileId) + const metadata = fileInfo?.metadata + return metadata?.d?.[page - 1]?.h }, updateSigners(data) { - 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) - element.coordinates.ury = Math.round(data.measurement[element.coordinates.page].height) - - element.coordinates.ury - object.element = element - this.$refs.pdfEditor.addSigner(object) - } + this.loadedPdfsCount++ + + const expectedPdfsCount = this.document.files.length + if (this.elementsLoaded || this.loadedPdfsCount < expectedPdfsCount) { + return + } + + // Coletar visibleElements de múltiplas fontes no array unificado `files` + let visibleElementsToAdd = [] + this.document.files.forEach((f, fileIndex) => { + const elements = Array.isArray(f.visibleElements) ? f.visibleElements : [] + elements.forEach(element => { + visibleElementsToAdd.push({ + ...element, + documentIndex: fileIndex, + fileId: f.id, }) + }) + }) + + // Adicionar signers com seus elementos usando correspondência por identifyMethods + visibleElementsToAdd.forEach(element => { + let envelopeSignerMatch = null + let childSigner = null + if (element.fileId) { + const fileInfo = this.document.files.find(f => f.id === element.fileId) + if (fileInfo) { + childSigner = (fileInfo.signers || []).find(s => s.signRequestId === element.signRequestId) + } + } + + if (childSigner) { + const childIdMethods = (childSigner.identifyMethods || []).map(m => `${m.method}:${m.value}`).sort().join('|') + envelopeSignerMatch = this.document.signers.find(s => { + const envIdMethods = (s.identifyMethods || []).map(m => `${m.method}:${m.value}`).sort().join('|') + return envIdMethods === childIdMethods + }) + } + + const baseSigner = envelopeSignerMatch || this.document.signers.find(s => s.signRequestId === element.signRequestId) || null + if (!baseSigner) { + return + } + + const object = structuredClone(baseSigner) + const fileInfo = this.document.files.find(f => f.id === element.fileId) + if (fileInfo) { + const fileIndex = this.document.files.indexOf(fileInfo) + object.element = { ...element, documentIndex: fileIndex } + this.$refs.pdfEditor.addSigner(object) + return } + + // fallback: add without documentIndex + object.element = element + this.$refs.pdfEditor.addSigner(object) }) + + this.elementsLoaded = true + this.filesStore.loading = false }, onSelectSigner(signer) { @@ -203,18 +300,29 @@ export default { return } this.signerSelected = signer - const canvasList = this.$refs.pdfEditor.$refs.vuePdfEditor.$refs.pdfBody.querySelectorAll('canvas') + const canvasList = this.getCanvasList() canvasList.forEach((canvas) => { canvas.addEventListener('click', this.doSelectSigner) }) }, 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 canvasList = this.getCanvasList() + const canvasIndex = Array.from(canvasList).indexOf(event.target) + const globalPageNumber = canvasIndex + 1 // 1-based + + let documentIndex = 0 + let pageInDocument = globalPageNumber + + if (this.filePagesMap[globalPageNumber]) { + const pageInfo = this.filePagesMap[globalPageNumber] + documentIndex = pageInfo.fileIndex + pageInDocument = globalPageNumber - pageInfo.startPage + 1 + } + + 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 @@ -230,19 +338,36 @@ export default { const normalizedX = clickX / scale const normalizedY = clickY / scale + const targetFileId = this.document.files[documentIndex]?.id || this.document?.id + const pageHeight = this.getPageHeightForFile(targetFileId, pageInDocument) + if (!pageHeight) { + return + } + const left = normalizedX - this.width / 2 + const top = normalizedY - this.height / 2 + const llx = left + const ury = pageHeight - top + + const coordinates = { + page: pageInDocument, + width: this.width, + height: this.height, + left, + top, + llx, + ury, + } + this.signerSelected.element = { - coordinates: { - page: page + 1, - llx: normalizedX - this.width / 2, - ury: normalizedY - this.height / 2, - width: this.width, - height: this.height, - }, + coordinates: coordinates, } + + this.signerSelected.element.documentIndex = documentIndex + this.$refs.pdfEditor.addSigner(this.signerSelected) }, stopAddSigner() { - const canvasList = this.$refs.pdfEditor.$refs.vuePdfEditor.$refs.pdfBody.querySelectorAll('canvas') + const canvasList = this.getCanvasList() canvasList.forEach((canvas) => { canvas.removeEventListener('click', this.doSelectSigner) }) @@ -282,26 +407,70 @@ 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.document.files.length + for (let docIndex = 0; docIndex < numDocuments; docIndex++) { + const objects = this.$refs.pdfEditor.$refs.vuePdfEditor.getAllObjects(docIndex) + objects.forEach(object => { + if (!object.signer) return + + // Map per-file page index to global using filePagesMap + let globalPageNumber = object.pageNumber + for (const [page, info] of Object.entries(this.filePagesMap)) { + if (info.fileIndex === docIndex) { + globalPageNumber = info.startPage + object.pageNumber - 1 + break + } + } + + // Coordinates normalized for PDF editor + const pageInfo = this.filePagesMap[globalPageNumber] + const pageHeight = this.getPageHeightForFile(pageInfo.id, object.pageNumber) + if (!pageHeight) { + return + } + + const left = Math.floor(object.normalizedCoordinates.llx) + const top = Math.floor(pageHeight - object.normalizedCoordinates.lly) + const width = Math.floor(object.normalizedCoordinates.width) + const height = Math.floor(object.normalizedCoordinates.height) + + const coordinates = { + page: globalPageNumber, + width, + height, + left, + top, + } + + const element = { + type: 'signature', + elementId: object.signer.element.elementId, + coordinates, + } + + // Target file and per-file page number + const targetFileId = pageInfo.id + element.fileId = targetFileId + element.coordinates.page = globalPageNumber - pageInfo.startPage + 1 + + // Resolve child signer SR for the specific file via identifyMethods + const fileInfo = this.document.files.find(f => f.id === targetFileId) + if (!fileInfo || !Array.isArray(fileInfo.signers)) { + return + } + const envIdMethods = (object.signer.identifyMethods || []).map(m => `${m.method}:${m.value}`).sort().join('|') + const candidate = fileInfo.signers.find(s => { + const childIdMethods = (s.identifyMethods || []).map(m => `${m.method}:${m.value}`).sort().join('|') + return childIdMethods === envIdMethods + }) + if (!candidate || !candidate.signRequestId) { + return + } + element.signRequestId = candidate.signRequestId + + visibleElements.push(element) }) - }) + } return visibleElements }, }, diff --git a/src/Components/RightSidebar/EnvelopeFilesList.vue b/src/Components/RightSidebar/EnvelopeFilesList.vue new file mode 100644 index 0000000000..b2e3e017a6 --- /dev/null +++ b/src/Components/RightSidebar/EnvelopeFilesList.vue @@ -0,0 +1,655 @@ + + + + + + diff --git a/src/Components/RightSidebar/RequestSignatureTab.vue b/src/Components/RightSidebar/RequestSignatureTab.vue index 0b1fcfc339..b0cf288865 100644 --- a/src/Components/RightSidebar/RequestSignatureTab.vue +++ b/src/Components/RightSidebar/RequestSignatureTab.vue @@ -13,6 +13,9 @@ + {{ t('libresign', 'Add signer') }} -
-
- - - {{ 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') }} - -
-
+ + + + {{ 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/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/DocumentValidationDetails.vue b/src/components/validation/DocumentValidationDetails.vue new file mode 100644 index 0000000000..31c3c5d67a --- /dev/null +++ b/src/components/validation/DocumentValidationDetails.vue @@ -0,0 +1,165 @@ + + + + + + diff --git a/src/components/validation/EnvelopeValidation.vue b/src/components/validation/EnvelopeValidation.vue new file mode 100644 index 0000000000..fdeff07789 --- /dev/null +++ b/src/components/validation/EnvelopeValidation.vue @@ -0,0 +1,395 @@ + + + + + + diff --git a/src/components/validation/FileValidation.vue b/src/components/validation/FileValidation.vue new file mode 100644 index 0000000000..39515785c0 --- /dev/null +++ b/src/components/validation/FileValidation.vue @@ -0,0 +1,90 @@ + + + + + + diff --git a/src/components/validation/SignerDetails.vue b/src/components/validation/SignerDetails.vue new file mode 100644 index 0000000000..07a29d3a99 --- /dev/null +++ b/src/components/validation/SignerDetails.vue @@ -0,0 +1,448 @@ + + + + + + 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 @@ + + + + + + diff --git a/src/store/files.js b/src/store/files.js index 913d121424..66ff08bb8d 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -37,7 +37,7 @@ export const useFilesStore = function(...args) { state: () => { return { files: {}, - selectedNodeId: 0, + selectedId: 0, identifyingSigner: false, loading: false, canRequestSign: loadState('libresign', 'can_request_sign', false), @@ -49,15 +49,15 @@ export const useFilesStore = function(...args) { actions: { addFile(file) { - set(this.files, file.nodeId, file) - this.hydrateFile(file.nodeId) - if (!this.ordered.includes(file.nodeId)) { - this.ordered.push(file.nodeId) + set(this.files, file.id, file) + this.hydrateFile(file.id) + if (!this.ordered.includes(file.id)) { + this.ordered.push(file.id) } }, - selectFile(nodeId) { - this.selectedNodeId = nodeId ?? 0 - if (this.selectedNodeId === 0) { + selectFile(fileId) { + this.selectedId = fileId ?? 0 + if (this.selectedId === 0) { const signStore = useSignStore() signStore.reset() return @@ -66,16 +66,93 @@ export const useFilesStore = function(...args) { sidebarStore.activeRequestSignatureTab() }, getFile(file) { - if (typeof file === 'object') { + if (typeof file === 'object' && file !== null) { return file } - return this.files[this.selectedNodeId] || emptyFile + return this.files[this.selectedId] || emptyFile }, async flushSelectedFile() { const files = await this.getAllFiles({ - 'nodeIds[]': [this.selectedNodeId], + 'nodeIds[]': [this.selectedId], }) - this.addFile(files[this.selectedNodeId]) + this.addFile(files[this.selectedId]) + }, + async addFilesToEnvelope(envelopeUuid, formData, options = {}) { + return await axios.post( + generateOcsUrl('/apps/libresign/api/v1/file/{uuid}/add-file', { uuid: envelopeUuid }), + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + signal: options.signal, + onUploadProgress: options.onUploadProgress, + }, + ) + .then(({ data }) => { + const addedFiles = data.ocs.data.files || [] + const newFilesCount = data.ocs.data.filesCount || 0 + const fileId = data.ocs.data.id + + if (this.files[fileId]) { + set(this.files[fileId], 'filesCount', newFilesCount) + } + + return { + success: true, + message: data.ocs.data.message, + files: addedFiles, + filesCount: newFilesCount, + } + }) + .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, + message, + error, + } + }) + }, + async removeFilesFromEnvelope(envelopeId, fileIds) { + const ids = Array.isArray(fileIds) ? fileIds : [fileIds] + + const deletePromises = ids.map(id => + axios.delete( + generateOcsUrl('/apps/libresign/api/v1/file/file_id/{fileId}', { fileId: id }), + ), + ) + + return await Promise.all(deletePromises) + .then(() => { + if (this.files[envelopeId] && this.files[envelopeId].filesCount) { + const newCount = Math.max(0, this.files[envelopeId].filesCount - ids.length) + set(this.files[envelopeId], 'filesCount', newCount) + } + + const isSingle = ids.length === 1 + return { + success: true, + message: isSingle ? 'File removed from envelope' : 'Files removed from envelope', + removedCount: ids.length, + removedIds: ids, + } + }) + .catch((error) => { + const message = error.response?.data?.ocs?.data?.message || 'Failed to remove file(s) from envelope' + return { + success: false, + message, + error, + } + }) }, enableIdentifySigner() { this.identifyingSigner = true @@ -85,7 +162,7 @@ export const useFilesStore = function(...args) { }, hasSigners(file) { file = this.getFile(file) - if (this.selectedNodeId === 0) { + if (this.selectedId === 0) { return false } if (!Object.hasOwn(file, 'signers')) { @@ -162,7 +239,7 @@ export const useFilesStore = function(...args) { && file?.signers?.length > 0 }, getSubtitle() { - if (this.selectedNodeId === 0) { + if (this.selectedId === 0) { return '' } const file = this.getFile() @@ -174,20 +251,20 @@ export const useFilesStore = function(...args) { date: Moment(Date.parse(file.created_at)).format('LL LTS'), }) }, - async hydrateFile(nodeId) { - this.addUniqueIdentifierToAllSigners(this.files[nodeId].signers) - if (Object.hasOwn(this.files[nodeId], 'uuid')) { + async hydrateFile(fileId) { + this.addUniqueIdentifierToAllSigners(this.files[fileId].signers) + if (Object.hasOwn(this.files[fileId], 'uuid')) { return } await axios.get(generateOcsUrl('/apps/libresign/api/v1/file/validate/file_id/{fileId}', { - fileId: nodeId, + fileId: fileId, })) .then((response) => { - set(this.files, nodeId, response.data.ocs.data) - this.addUniqueIdentifierToAllSigners(this.files[nodeId].signers) + set(this.files, fileId, response.data.ocs.data) + this.addUniqueIdentifierToAllSigners(this.files[fileId].signers) }) .catch(() => { - set(this.files[nodeId], 'signers', []) + set(this.files[fileId], 'signers', []) }) }, addUniqueIdentifierToAllSigners(signers) { @@ -229,7 +306,7 @@ export const useFilesStore = function(...args) { signer.signingOrder = maxOrder + 1 } this.getFile().signers.push(signer) - const selected = this.selectedNodeId + const selected = this.selectedId this.selectFile(-1) // to force reactivity this.selectFile(selected) // to force reactivity }, @@ -237,19 +314,19 @@ export const useFilesStore = function(...args) { if (!isNaN(signer.signRequestId)) { await axios.delete(generateOcsUrl('/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}', { apiVersion: 'v1', - fileId: this.selectedNodeId, + fileId: this.selectedId, signRequestId: signer.signRequestId, })) } set( - this.files[this.selectedNodeId], + this.files[this.selectedId], 'signers', - this.files[this.selectedNodeId].signers.filter((i) => i.identify !== signer.identify), + this.files[this.selectedId].signers.filter((i) => i.identify !== signer.identify), ) if (this.getFile().signatureFlow === 'ordered_numeric' && signer.signingOrder) { - this.files[this.selectedNodeId].signers.forEach((s) => { + this.files[this.selectedId].signers.forEach((s) => { if (s.signingOrder && s.signingOrder > signer.signingOrder) { s.signingOrder -= 1 } @@ -258,21 +335,21 @@ export const useFilesStore = function(...args) { }, async delete(file, deleteFile) { file = this.getFile(file) - if (file?.nodeId) { + if (file?.id) { const url = deleteFile ? '/apps/libresign/api/v1/file/file_id/{fileId}' : '/apps/libresign/api/v1/sign/file_id/{fileId}' await axios.delete(generateOcsUrl(url, { - fileId: file.nodeId, + fileId: file.id, })) .then(() => { - if (this.selectedNodeId === file.nodeId) { + if (this.selectedId === file.id) { const sidebarStore = useSidebarStore() sidebarStore.hideSidebar() - this.selectedNodeId = 0 + this.selectedId = 0 } - del(this.files, file.nodeId) - const index = this.ordered.indexOf(file.nodeId) + del(this.files, file.id) + const index = this.ordered.indexOf(file.id) if (index > -1) { this.ordered.splice(index, 1) } @@ -280,22 +357,63 @@ export const useFilesStore = function(...args) { } }, - async deleteMultiple(nodeIds, deleteFile) { + async deleteMultiple(fileIds, deleteFile) { this.loading = true - for (const nodeId of nodeIds) { - await this.delete(this.files[nodeId], deleteFile) + for (const fileId of fileIds) { + await this.delete(this.files[fileId], deleteFile) } 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 rename(uuid, newName) { + const url = generateOcsUrl('/apps/libresign/api/v1/request-signature') + return axios.patch(url, { + uuid, + name: newName, }) - return { ...data.ocs.data } + .then((response) => { + if (response.data?.ocs?.meta?.status === 'ok') { + const fileId = Object.keys(this.files).find(id => this.files[id].uuid === uuid) + if (fileId && this.files[fileId]) { + this.files[fileId].name = newName + } + return true + } + return false + }) + .catch((error) => { + console.error('Failed to rename file:', error) + return false + }) + }, + 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, axiosConfig) + data = response.data + } + + const fileData = data.ocs.data + this.addFile(fileData) + return fileData.id }, async getAllFiles(filter) { if (this.loading || this.loadedAll) { @@ -369,6 +487,12 @@ export const useFilesStore = function(...args) { identificationDocumentStore.setWaitingApproval(response.data.ocs.data.settings.identificationDocumentsWaitingApproval) } + if (this.selectedId && !this.files[this.selectedId]) { + const sidebarStore = useSidebarStore() + sidebarStore.hideSidebar() + this.selectedId = 0 + } + this.loading = false emit('libresign:files:updated') return this.files @@ -381,7 +505,7 @@ export const useFilesStore = function(...args) { filesSorted() { return this.ordered.map(key => this.files[key]) }, - async saveWithVisibleElements({ visibleElements = [], signers = null, uuid = null, nodeId = null, signatureFlow = null }) { + async saveWithVisibleElements({ visibleElements = [], signers = null, uuid = null, fileId = null, signatureFlow = null }) { const file = this.getFile() let flowValue = signatureFlow || file.signatureFlow @@ -403,27 +527,33 @@ export const useFilesStore = function(...args) { } - if (uuid || file.uuid) { + if (uuid || file.uuid) { config.data.uuid = uuid || file.uuid } else { config.data.file = { - fileId: nodeId || this.selectedNodeId, + fileId: fileId || this.selectedId, } } const { data } = await axios(config) - this.addFile(data.ocs.data.data) + const responseFile = data.ocs.data.data + if (responseFile.id && this.files[responseFile.id]) { + set(this.files, responseFile.id, responseFile) + this.addUniqueIdentifierToAllSigners(this.files[responseFile.id].signers) + } else { + this.addFile(responseFile) + } return data.ocs.data }, - async updateSignatureRequest({ visibleElements = [], signers = null, uuid = null, nodeId = null, status = 1, signatureFlow = null }) { + async updateSignatureRequest({ visibleElements = [], signers = null, uuid = null, fileId = 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', @@ -440,11 +570,20 @@ export const useFilesStore = function(...args) { config.data.uuid = uuid || file.uuid } else { config.data.file = { - fileId: nodeId || this.selectedNodeId, + fileId: fileId || this.selectedId, } } const { data } = await axios(config) - this.addFile(data.ocs.data.data) + // Only update the existing file, don't trigger full reload via addFile + const responseFile = data.ocs.data.data + if (responseFile.id && this.files[responseFile.id]) { + // Update existing file in-place to avoid triggering side effects + set(this.files, responseFile.id, responseFile) + this.addUniqueIdentifierToAllSigners(this.files[responseFile.id].signers) + } else { + // Only add to store if it's a new file + this.addFile(responseFile) + } return data.ocs.data }, }, diff --git a/src/store/sign.js b/src/store/sign.js index 1f786dd981..bb71976287 100644 --- a/src/store/sign.js +++ b/src/store/sign.js @@ -15,12 +15,14 @@ import { useSignMethodsStore } from './signMethods.js' const defaultState = { errors: [], document: { + id: 0, name: '', description: '', status: '', statusText: '', url: '', nodeId: 0, + nodeType: 'file', uuid: '', signers: [], }, @@ -33,33 +35,36 @@ export const useSignStore = defineStore('sign', { actions: { initFromState() { this.errors = loadState('libresign', 'errors', []) - const pdf = loadState('libresign', 'pdf', []) + const file = { + id: loadState('libresign', 'id', 0), name: loadState('libresign', 'filename', ''), description: loadState('libresign', 'description', ''), status: loadState('libresign', 'status', ''), statusText: loadState('libresign', 'statusText', ''), - url: pdf.url, nodeId: loadState('libresign', 'nodeId', 0), + nodeType: loadState('libresign', 'nodeType', ''), 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 + filesStore.selectedId = file.id }, - 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) || {} - signMethodsStore.settings = signer.signatureMethods + const signer = file.signers.find(row => row.me) || {} + + signMethodsStore.settings = signer.signatureMethods || {} + return } this.reset() diff --git a/src/store/signMethods.js b/src/store/signMethods.js index 18b2694bb9..0562396dcf 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: { @@ -56,12 +56,7 @@ export const useSignMethodsStore = defineStore('signMethods', { set(this.settings.password, 'hasSignatureFile', hasSignatureFile) }, needCreatePassword() { - return this.needSignWithPassword() - && ( - !Object.hasOwn(this.settings, 'password') - || !Object.hasOwn(this.settings.password, 'hasSignatureFile') - || !this.settings.password.hasSignatureFile - ) + return this.needSignWithPassword() && !this.hasSignatureFile() }, needSignWithPassword() { return Object.hasOwn(this.settings, 'password') diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 7398f8c83f..34da00ccb3 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -463,6 +463,9 @@ export type components = { /** Format: double */ "signature-height": number; }; + envelope: { + "is-available": boolean; + }; }; version: string; }; diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 6416e21b9b..6ebf1dcb15 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -251,7 +251,7 @@ export type paths = { path?: never; cookie?: never; }; - /** Show signature page */ + /** Show signature page for identification document approval */ get: operations["page-sign-id-doc"]; put?: never; post?: never; @@ -261,23 +261,6 @@ export type paths = { patch?: never; trace?: never; }; - "/index.php/apps/libresign/p/id-docs/approve/{uuid}/{path}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Show signature page */ - get: operations["page-sign-id-doc-private"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/index.php/apps/libresign/p/pdf/{uuid}": { parameters: { query?: never; @@ -536,7 +519,7 @@ export type paths = { }; /** * Validate a file using Uuid - * @description Validate a file returning file data. + * @description Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ get: operations["file-validate-uuid"]; put?: never; @@ -556,7 +539,7 @@ export type paths = { }; /** * Validate a file using FileId - * @description Validate a file returning file data. + * @description Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ get: operations["file-validate-file-id"]; put?: never; @@ -578,7 +561,7 @@ export type paths = { put?: never; /** * Validate a binary file - * @description Validate a binary file returning file data. Use field 'file' for the file upload + * @description Validate a binary file returning file data. Use field 'file' for the file upload. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ post: operations["file-validate-binary"]; delete?: never; @@ -632,7 +615,7 @@ export type paths = { put?: never; /** * Send a file - * @description Send a new file to Nextcloud and return the fileId to request signature + * @description Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'. */ post: operations["file-save"]; delete?: never; @@ -641,6 +624,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/{uuid}/add-file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add file to envelope + * @description Add one or more files to an existing envelope that is in DRAFT status. Files must be uploaded as multipart/form-data with field name 'files[]'. + */ + post: operations["file-add-file-to-envelope"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/file_id/{fileId}": { parameters: { query?: never; @@ -839,7 +842,7 @@ export type paths = { put?: never; /** * Request signature - * @description Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. + * @description Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. When the created entity is an envelope (`nodeType` = `envelope`), the returned `data` includes `filesCount` and `files` as a list of envelope child files. */ post: operations["request_signature-request"]; delete?: never; @@ -1474,6 +1477,9 @@ export type components = { /** Format: double */ "signature-height": number; }; + envelope: { + "is-available": boolean; + }; }; version: string; }; @@ -1525,6 +1531,34 @@ export type components = { policySection: components["schemas"]["PolicySection"][]; rootCert: components["schemas"]["RootCertificate"]; }; + EnvelopeChildFile: { + /** Format: int64 */ + id: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + /** Format: int64 */ + nodeId: number; + /** Format: int64 */ + totalPages?: number; + /** Format: int64 */ + size?: number; + pdfVersion?: string; + signers: components["schemas"]["EnvelopeChildSignerSummary"][]; + metadata?: components["schemas"]["ValidateMetadata"]; + }; + EnvelopeChildSignerSummary: { + /** Format: int64 */ + signRequestId: number; + displayName: string; + email: string; + signed: string | null; + /** Format: int64 */ + status: number; + statusText: string; + }; File: { account: { userId: string; @@ -1558,6 +1592,43 @@ export type components = { signers: components["schemas"]["Signer"][]; }; }; + FileDetail: { + created_at: string; + file: string; + files: { + /** Format: int64 */ + fileId?: number; + /** Format: int64 */ + nodeId: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + }[]; + /** Format: int64 */ + filesCount: number; + /** Format: int64 */ + id: number; + metadata: { + [key: string]: Record; + }; + name: string; + /** Format: int64 */ + nodeId: number; + nodeType: string; + requested_by: { + userId: string; + displayName: string | null; + }; + signatureFlow: number | string; + signers: components["schemas"]["Signer"][]; + /** Format: int64 */ + status: number; + statusText: string; + uuid: string; + visibleElements: components["schemas"]["VisibleElement"][]; + }; FolderSettings: { folderName?: string; separator?: string; @@ -1617,9 +1688,35 @@ export type components = { /** Format: int64 */ id: number; /** Format: int64 */ + nodeId: number; + uuid: string; + /** Format: int64 */ status: number; statusText: string; + /** @enum {string} */ + nodeType: "file" | "envelope"; created_at: string; + file: string; + metadata: components["schemas"]["ValidateMetadata"]; + /** @enum {string} */ + signatureFlow: "none" | "parallel" | "ordered_numeric"; + visibleElements: components["schemas"]["VisibleElement"][]; + signers: components["schemas"]["Signer"][]; + requested_by: { + userId: string; + displayName: string; + }; + /** Format: int64 */ + filesCount: number; + files: { + /** Format: int64 */ + nodeId: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + }[]; }; Notify: { date: string; @@ -1671,10 +1768,10 @@ export type components = { canSign: boolean; canRequestSign: boolean; signerFileUuid: string | null; - hasSignatureFile?: boolean; phoneNumber: string; - needIdentificationDocuments?: boolean; - identificationDocumentsWaitingApproval?: boolean; + hasSignatureFile: boolean; + needIdentificationDocuments: boolean; + identificationDocumentsWaitingApproval: boolean; }; SignatureMethod: { enabled: boolean; @@ -1732,6 +1829,7 @@ export type components = { identifyMethods?: components["schemas"]["IdentifyMethod"][]; visibleElements?: components["schemas"]["VisibleElement"][]; signatureMethods?: components["schemas"]["SignatureMethods"]; + uid?: string; }; UserElement: { /** Format: int64 */ @@ -1751,6 +1849,8 @@ export type components = { createdAt: string; }; ValidateFile: { + /** Format: int64 */ + id: number; uuid: string; name: string; /** @@ -1761,11 +1861,16 @@ export type components = { statusText: string; /** Format: int64 */ nodeId: number; + /** @enum {string} */ + nodeType: "file" | "envelope"; /** Format: int64 */ signatureFlow: number; /** Format: int64 */ docmdpLevel: number; /** Format: int64 */ + filesCount?: number; + files?: components["schemas"]["EnvelopeChildFile"][]; + /** Format: int64 */ totalPages: number; /** Format: int64 */ size: number; @@ -1777,17 +1882,17 @@ export type components = { }; file: string; url?: string; - metadata?: { - extension: string; - /** Format: int64 */ - p: number; - d?: { + mime?: string; + pages?: { + url: string; + resolution: { /** Format: double */ w: number; /** Format: double */ h: number; - }[]; - }; + }; + }[]; + metadata?: components["schemas"]["ValidateMetadata"]; signers?: components["schemas"]["Signer"][]; settings?: components["schemas"]["Settings"]; messages?: { @@ -1797,11 +1902,25 @@ export type components = { }[]; visibleElements?: components["schemas"]["VisibleElement"][]; }; + ValidateMetadata: { + extension: string; + /** Format: int64 */ + p: number; + d?: { + /** Format: double */ + w: number; + /** Format: double */ + h: number; + }[]; + pdfVersion?: string; + }; VisibleElement: { /** Format: int64 */ elementId: number; /** Format: int64 */ signRequestId: number; + /** Format: int64 */ + fileId: number; type: string; coordinates: components["schemas"]["Coordinate"]; }; @@ -2154,32 +2273,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description Sign request uuid */ - uuid: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - "page-sign-id-doc-private": { - parameters: { - query?: never; - header?: never; - path: { - /** @description Sign request uuid */ + /** @description File UUID for the identification document approval */ uuid: string; - path: string; }; cookie?: never; }; @@ -3098,6 +3193,8 @@ export interface operations { sortBy?: string | null; /** @description Ascending or descending order */ sortDirection?: string | null; + /** @description Filter files by parent envelope file ID */ + parentFileId?: number | null; }; header: { /** @description Required to be true for the API request to pass */ @@ -3121,7 +3218,8 @@ export interface operations { meta: components["schemas"]["OCSMeta"]; data: { pagination: components["schemas"]["Pagination"]; - data: components["schemas"]["File"][] | null; + data: components["schemas"]["FileDetail"][]; + settings?: components["schemas"]["Settings"]; }; }; }; @@ -3231,11 +3329,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 +3347,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"][]; }; }; }; @@ -3282,6 +3388,86 @@ export interface operations { }; }; }; + "file-add-file-to-envelope": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description The UUID of the envelope */ + uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Files added successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["NextcloudFile"]; + }; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Envelope not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Cannot add files (envelope not in DRAFT status or validation failed) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; "file-delete-all-request-signature-using-file-id": { parameters: { query?: never; @@ -3291,7 +3477,7 @@ export interface operations { }; path: { apiVersion: "v1"; - /** @description Node id of a Nextcloud file */ + /** @description LibreSign file ID */ fileId: number; }; cookie?: never; @@ -3380,6 +3566,11 @@ export interface operations { * @description ID of visible element. Each element has an ID that is returned on validation endpoints. */ elementId?: number | null; + /** + * Format: int64 + * @description File ID when using node identifier instead of UUID + */ + fileId?: number | null; /** * @description The type of element to create, sginature, sinitial, date, datetime, text * @default @@ -3514,6 +3705,11 @@ export interface operations { * @description Id of sign request */ signRequestId: number; + /** + * Format: int64 + * @description File ID when using node identifier instead of UUID + */ + fileId?: number | null; /** * @description The type of element to create, sginature, sinitial, date, datetime, text * @default @@ -4052,7 +4248,7 @@ export interface operations { ocs: { meta: components["schemas"]["OCSMeta"]; data: { - data: components["schemas"]["ValidateFile"]; + data: components["schemas"]["FileDetail"]; message: string; }; }; @@ -4119,6 +4315,8 @@ export interface operations { status?: number | null; /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */ signatureFlow?: string | null; + /** @description The name of file to sign */ + name?: string | null; }; }; }; @@ -4134,7 +4332,7 @@ export interface operations { meta: components["schemas"]["OCSMeta"]; data: { message: string; - data: components["schemas"]["ValidateFile"]; + data: components["schemas"]["FileDetail"]; }; }; }; @@ -4173,7 +4371,7 @@ export interface operations { }; path: { apiVersion: "v1"; - /** @description Node id of a Nextcloud file */ + /** @description LibreSign file ID */ fileId: number; /** @description The sign request id */ signRequestId: number; diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2efb700ac5..85ec6810e0 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -251,7 +251,7 @@ export type paths = { path?: never; cookie?: never; }; - /** Show signature page */ + /** Show signature page for identification document approval */ get: operations["page-sign-id-doc"]; put?: never; post?: never; @@ -261,23 +261,6 @@ export type paths = { patch?: never; trace?: never; }; - "/index.php/apps/libresign/p/id-docs/approve/{uuid}/{path}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Show signature page */ - get: operations["page-sign-id-doc-private"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/index.php/apps/libresign/p/pdf/{uuid}": { parameters: { query?: never; @@ -536,7 +519,7 @@ export type paths = { }; /** * Validate a file using Uuid - * @description Validate a file returning file data. + * @description Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ get: operations["file-validate-uuid"]; put?: never; @@ -556,7 +539,7 @@ export type paths = { }; /** * Validate a file using FileId - * @description Validate a file returning file data. + * @description Validate a file returning file data. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ get: operations["file-validate-file-id"]; put?: never; @@ -578,7 +561,7 @@ export type paths = { put?: never; /** * Validate a binary file - * @description Validate a binary file returning file data. Use field 'file' for the file upload + * @description Validate a binary file returning file data. Use field 'file' for the file upload. When `nodeType` is `envelope`, the response includes `filesCount` and `files` as a list of envelope child files. */ post: operations["file-validate-binary"]; delete?: never; @@ -632,7 +615,7 @@ export type paths = { put?: never; /** * Send a file - * @description Send a new file to Nextcloud and return the fileId to request signature + * @description Send a new file to Nextcloud and return the fileId to request signature. Files must be uploaded as multipart/form-data with field name 'file[]' or 'files[]'. */ post: operations["file-save"]; delete?: never; @@ -641,6 +624,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/{uuid}/add-file": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add file to envelope + * @description Add one or more files to an existing envelope that is in DRAFT status. Files must be uploaded as multipart/form-data with field name 'files[]'. + */ + post: operations["file-add-file-to-envelope"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/file/file_id/{fileId}": { parameters: { query?: never; @@ -839,7 +842,7 @@ export type paths = { put?: never; /** * Request signature - * @description Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. + * @description Request that a file be signed by a group of people. Each user in the users array can optionally include a 'signing_order' field to control the order of signatures when ordered signing flow is enabled. When the created entity is an envelope (`nodeType` = `envelope`), the returned `data` includes `filesCount` and `files` as a list of envelope child files. */ post: operations["request_signature-request"]; delete?: never; @@ -1034,6 +1037,9 @@ export type components = { /** Format: double */ "signature-height": number; }; + envelope: { + "is-available": boolean; + }; }; version: string; }; @@ -1069,6 +1075,34 @@ export type components = { /** Format: int64 */ height?: number; }; + EnvelopeChildFile: { + /** Format: int64 */ + id: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + /** Format: int64 */ + nodeId: number; + /** Format: int64 */ + totalPages?: number; + /** Format: int64 */ + size?: number; + pdfVersion?: string; + signers: components["schemas"]["EnvelopeChildSignerSummary"][]; + metadata?: components["schemas"]["ValidateMetadata"]; + }; + EnvelopeChildSignerSummary: { + /** Format: int64 */ + signRequestId: number; + displayName: string; + email: string; + signed: string | null; + /** Format: int64 */ + status: number; + statusText: string; + }; File: { account: { userId: string; @@ -1102,6 +1136,43 @@ export type components = { signers: components["schemas"]["Signer"][]; }; }; + FileDetail: { + created_at: string; + file: string; + files: { + /** Format: int64 */ + fileId?: number; + /** Format: int64 */ + nodeId: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + }[]; + /** Format: int64 */ + filesCount: number; + /** Format: int64 */ + id: number; + metadata: { + [key: string]: Record; + }; + name: string; + /** Format: int64 */ + nodeId: number; + nodeType: string; + requested_by: { + userId: string; + displayName: string | null; + }; + signatureFlow: number | string; + signers: components["schemas"]["Signer"][]; + /** Format: int64 */ + status: number; + statusText: string; + uuid: string; + visibleElements: components["schemas"]["VisibleElement"][]; + }; FolderSettings: { folderName?: string; separator?: string; @@ -1161,9 +1232,35 @@ export type components = { /** Format: int64 */ id: number; /** Format: int64 */ + nodeId: number; + uuid: string; + /** Format: int64 */ status: number; statusText: string; + /** @enum {string} */ + nodeType: "file" | "envelope"; created_at: string; + file: string; + metadata: components["schemas"]["ValidateMetadata"]; + /** @enum {string} */ + signatureFlow: "none" | "parallel" | "ordered_numeric"; + visibleElements: components["schemas"]["VisibleElement"][]; + signers: components["schemas"]["Signer"][]; + requested_by: { + userId: string; + displayName: string; + }; + /** Format: int64 */ + filesCount: number; + files: { + /** Format: int64 */ + nodeId: number; + uuid: string; + name: string; + /** Format: int64 */ + status: number; + statusText: string; + }[]; }; Notify: { date: string; @@ -1193,10 +1290,10 @@ export type components = { canSign: boolean; canRequestSign: boolean; signerFileUuid: string | null; - hasSignatureFile?: boolean; phoneNumber: string; - needIdentificationDocuments?: boolean; - identificationDocumentsWaitingApproval?: boolean; + hasSignatureFile: boolean; + needIdentificationDocuments: boolean; + identificationDocumentsWaitingApproval: boolean; }; SignatureMethod: { enabled: boolean; @@ -1254,6 +1351,7 @@ export type components = { identifyMethods?: components["schemas"]["IdentifyMethod"][]; visibleElements?: components["schemas"]["VisibleElement"][]; signatureMethods?: components["schemas"]["SignatureMethods"]; + uid?: string; }; UserElement: { /** Format: int64 */ @@ -1273,6 +1371,8 @@ export type components = { createdAt: string; }; ValidateFile: { + /** Format: int64 */ + id: number; uuid: string; name: string; /** @@ -1283,11 +1383,16 @@ export type components = { statusText: string; /** Format: int64 */ nodeId: number; + /** @enum {string} */ + nodeType: "file" | "envelope"; /** Format: int64 */ signatureFlow: number; /** Format: int64 */ docmdpLevel: number; /** Format: int64 */ + filesCount?: number; + files?: components["schemas"]["EnvelopeChildFile"][]; + /** Format: int64 */ totalPages: number; /** Format: int64 */ size: number; @@ -1299,17 +1404,17 @@ export type components = { }; file: string; url?: string; - metadata?: { - extension: string; - /** Format: int64 */ - p: number; - d?: { + mime?: string; + pages?: { + url: string; + resolution: { /** Format: double */ w: number; /** Format: double */ h: number; - }[]; - }; + }; + }[]; + metadata?: components["schemas"]["ValidateMetadata"]; signers?: components["schemas"]["Signer"][]; settings?: components["schemas"]["Settings"]; messages?: { @@ -1319,11 +1424,25 @@ export type components = { }[]; visibleElements?: components["schemas"]["VisibleElement"][]; }; + ValidateMetadata: { + extension: string; + /** Format: int64 */ + p: number; + d?: { + /** Format: double */ + w: number; + /** Format: double */ + h: number; + }[]; + pdfVersion?: string; + }; VisibleElement: { /** Format: int64 */ elementId: number; /** Format: int64 */ signRequestId: number; + /** Format: int64 */ + fileId: number; type: string; coordinates: components["schemas"]["Coordinate"]; }; @@ -1676,32 +1795,8 @@ export interface operations { query?: never; header?: never; path: { - /** @description Sign request uuid */ - uuid: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - }; - }; - "page-sign-id-doc-private": { - parameters: { - query?: never; - header?: never; - path: { - /** @description Sign request uuid */ + /** @description File UUID for the identification document approval */ uuid: string; - path: string; }; cookie?: never; }; @@ -2620,6 +2715,8 @@ export interface operations { sortBy?: string | null; /** @description Ascending or descending order */ sortDirection?: string | null; + /** @description Filter files by parent envelope file ID */ + parentFileId?: number | null; }; header: { /** @description Required to be true for the API request to pass */ @@ -2643,7 +2740,8 @@ export interface operations { meta: components["schemas"]["OCSMeta"]; data: { pagination: components["schemas"]["Pagination"]; - data: components["schemas"]["File"][] | null; + data: components["schemas"]["FileDetail"][]; + settings?: components["schemas"]["Settings"]; }; }; }; @@ -2753,11 +2851,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 +2869,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"][]; }; }; }; @@ -2804,6 +2910,86 @@ export interface operations { }; }; }; + "file-add-file-to-envelope": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description The UUID of the envelope */ + uuid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Files added successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["NextcloudFile"]; + }; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Envelope not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + /** @description Cannot add files (envelope not in DRAFT status or validation failed) */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: { + message: string; + }; + }; + }; + }; + }; + }; + }; "file-delete-all-request-signature-using-file-id": { parameters: { query?: never; @@ -2813,7 +2999,7 @@ export interface operations { }; path: { apiVersion: "v1"; - /** @description Node id of a Nextcloud file */ + /** @description LibreSign file ID */ fileId: number; }; cookie?: never; @@ -2902,6 +3088,11 @@ export interface operations { * @description ID of visible element. Each element has an ID that is returned on validation endpoints. */ elementId?: number | null; + /** + * Format: int64 + * @description File ID when using node identifier instead of UUID + */ + fileId?: number | null; /** * @description The type of element to create, sginature, sinitial, date, datetime, text * @default @@ -3036,6 +3227,11 @@ export interface operations { * @description Id of sign request */ signRequestId: number; + /** + * Format: int64 + * @description File ID when using node identifier instead of UUID + */ + fileId?: number | null; /** * @description The type of element to create, sginature, sinitial, date, datetime, text * @default @@ -3574,7 +3770,7 @@ export interface operations { ocs: { meta: components["schemas"]["OCSMeta"]; data: { - data: components["schemas"]["ValidateFile"]; + data: components["schemas"]["FileDetail"]; message: string; }; }; @@ -3641,6 +3837,8 @@ export interface operations { status?: number | null; /** @description Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration */ signatureFlow?: string | null; + /** @description The name of file to sign */ + name?: string | null; }; }; }; @@ -3656,7 +3854,7 @@ export interface operations { meta: components["schemas"]["OCSMeta"]; data: { message: string; - data: components["schemas"]["ValidateFile"]; + data: components["schemas"]["FileDetail"]; }; }; }; @@ -3695,7 +3893,7 @@ export interface operations { }; path: { apiVersion: "v1"; - /** @description Node id of a Nextcloud file */ + /** @description LibreSign file ID */ fileId: number; /** @description The sign request id */ signRequestId: number; diff --git a/src/views/FilesList/FileEntry/FileEntry.vue b/src/views/FilesList/FileEntry/FileEntry.vue index 105076931e..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" /> + :loading="loading" + @rename="onRename" + @start-rename="onStartRename" /> diff --git a/src/views/FilesList/FileEntry/FileEntryActions.vue b/src/views/FilesList/FileEntry/FileEntryActions.vue index 6d74cd7611..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"> import svgDelete from '@mdi/svg/svg/delete.svg?raw' import svgFileDocument from '@mdi/svg/svg/file-document-outline.svg?raw' +import svgPencil from '@mdi/svg/svg/pencil-outline.svg?raw' import svgSignature from '@mdi/svg/svg/signature.svg?raw' import svgTextBoxCheck from '@mdi/svg/svg/text-box-check.svg?raw' @@ -123,6 +127,7 @@ export default { hasInfo: false, } }, + emits: ['rename', 'start-rename'], computed: { openedMenu: { get() { @@ -135,8 +140,19 @@ export default { visibleMenu() { return this.enabledMenuActions.filter(action => this.visibleIf(action)) }, + file() { + return this.filesStore.files[this.source.id] + }, + boundariesElement() { + return document.querySelector('.app-content > .files-list') + }, }, mounted() { + this.registerAction({ + id: 'rename', + title: t('libresign', 'Rename'), + iconSvgInline: svgPencil, + }) this.registerAction({ id: 'validate', title: t('libresign', 'Validate'), @@ -163,16 +179,17 @@ export default { }, methods: { visibleIf(action) { - const file = this.filesStore.files[this.source.nodeId] let visible = false - if (action.id === 'sign') { - visible = this.filesStore.canSign(file) + if (action.id === 'rename') { + visible = true + } else if (action.id === 'sign') { + 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 }, @@ -191,14 +208,14 @@ export default { signer_uuid: signUuid, force_fetch: true, }) - this.signStore.setDocumentToSign(files[this.source.nodeId]) + this.signStore.setFileToSign(files[this.source.id]) this.$router.push({ name: 'SignPDF', params: { uuid: signUuid, }, }) - this.filesStore.selectFile(this.source.nodeId) + this.filesStore.selectFile(this.source.id) } else if (action.id === 'validate') { this.$router.push({ name: 'ValidationFile', @@ -208,6 +225,8 @@ export default { }) } else if (action.id === 'delete') { this.confirmDelete = true + } else if (action.id === 'rename') { + this.$emit('start-rename') } else if (action.id === 'open') { this.openFile() } @@ -236,14 +255,25 @@ export default { window.open(`${this.source.file}?_t=${Date.now()}`) } }, + 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') + } + } + }, }, } diff --git a/src/views/FilesList/FileEntry/FileEntryPreview.vue b/src/views/FilesList/FileEntry/FileEntryPreview.vue index d9774d7820..766c697e6b 100644 --- a/src/views/FilesList/FileEntry/FileEntryPreview.vue +++ b/src/views/FilesList/FileEntry/FileEntryPreview.vue @@ -5,7 +5,7 @@