libresign/lib/Service/RequestSignatureService.php
Vitor Mattos a0a58a0d8b
feat: allow signature flow update on existing files
Enable users to change signature flow when updating file if admin
has not enforced a specific flow mode.

Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
2025-12-17 02:03:29 -03:00

505 lines
17 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Libresign\Service;
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Db\File as FileEntity;
use OCA\Libresign\Db\FileElementMapper;
use OCA\Libresign\Db\FileMapper;
use OCA\Libresign\Db\IdentifyMethodMapper;
use OCA\Libresign\Db\SignRequest as SignRequestEntity;
use OCA\Libresign\Db\SignRequestMapper;
use OCA\Libresign\Enum\SignatureFlow;
use OCA\Libresign\Events\SignRequestCanceledEvent;
use OCA\Libresign\Handler\DocMdpHandler;
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;
use OCP\Http\Client\IClientService;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\IUser;
use OCP\IUserManager;
use Psr\Log\LoggerInterface;
use Sabre\DAV\UUIDUtil;
class RequestSignatureService {
use TFile;
public function __construct(
protected IL10N $l10n,
protected IdentifyMethodService $identifyMethod,
protected SignRequestMapper $signRequestMapper,
protected IUserManager $userManager,
protected FileMapper $fileMapper,
protected IdentifyMethodMapper $identifyMethodMapper,
protected PdfParserService $pdfParserService,
protected FileElementService $fileElementService,
protected FileElementMapper $fileElementMapper,
protected FolderService $folderService,
protected IMimeTypeDetector $mimeTypeDetector,
protected ValidateHelper $validateHelper,
protected IClientService $client,
protected DocMdpHandler $docMdpHandler,
protected LoggerInterface $logger,
protected SequentialSigningService $sequentialSigningService,
protected IAppConfig $appConfig,
protected IEventDispatcher $eventDispatcher,
protected FileStatusService $fileStatusService,
protected SignRequestStatusService $signRequestStatusService,
protected DocMdpConfigService $docMdpConfigService,
) {
}
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());
return $file;
}
/**
* Save file data
*
* @param array{?userManager: IUser, ?signRequest: SignRequest, name: string, callback: string, uuid?: ?string, status: int, file?: array{fileId?: int, fileNode?: Node}} $data
*/
public function saveFile(array $data): FileEntity {
if (!empty($data['uuid'])) {
$file = $this->fileMapper->getByUuid($data['uuid']);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
}
$fileId = null;
if (isset($data['file']['fileNode']) && $data['file']['fileNode'] instanceof Node) {
$fileId = $data['file']['fileNode']->getId();
} elseif (!empty($data['file']['fileId'])) {
$fileId = $data['file']['fileId'];
}
if (!is_null($fileId)) {
try {
$file = $this->fileMapper->getByFileId($fileId);
$this->updateSignatureFlowIfAllowed($file, $data);
return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0);
} catch (\Throwable) {
}
}
$node = $this->getNodeFromData($data);
$file = new FileEntity();
$file->setNodeId($node->getId());
if ($data['userManager'] instanceof IUser) {
$file->setUserId($data['userManager']->getUID());
} elseif ($data['signRequest'] instanceof SignRequestEntity) {
$file->setSignRequestId($data['signRequest']->getId());
}
$file->setUuid(UUIDUtil::getUUID());
$file->setCreatedAt(new \DateTime('now', new \DateTimeZone('UTC')));
$metadata = $this->getFileMetadata($node);
$file->setName($this->removeExtensionFromName($data['name'], $metadata));
$file->setMetadata($metadata);
if (!empty($data['callback'])) {
$file->setCallback($data['callback']);
}
if (isset($data['status'])) {
$file->setStatus($data['status']);
} else {
$file->setStatus(FileEntity::STATUS_ABLE_TO_SIGN);
}
$this->setSignatureFlow($file, $data);
$this->setDocMdpLevelFromGlobalConfig($file);
$this->fileMapper->insert($file);
return $file;
}
private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void {
$adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
$adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value;
if ($adminForcedConfig) {
$adminFlowEnum = SignatureFlow::from($adminFlow);
if ($file->getSignatureFlowEnum() !== $adminFlowEnum) {
$file->setSignatureFlowEnum($adminFlowEnum);
$this->fileMapper->update($file);
}
return;
}
if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
$newFlow = SignatureFlow::from($data['signatureFlow']);
if ($file->getSignatureFlowEnum() !== $newFlow) {
$file->setSignatureFlowEnum($newFlow);
$this->fileMapper->update($file);
}
}
}
private function setSignatureFlow(FileEntity $file, array $data): void {
$adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value);
if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) {
$file->setSignatureFlowEnum(SignatureFlow::from($data['signatureFlow']));
} elseif ($adminFlow !== SignatureFlow::NONE->value) {
$file->setSignatureFlowEnum(SignatureFlow::from($adminFlow));
} else {
$file->setSignatureFlowEnum(SignatureFlow::NONE);
}
}
private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void {
if ($this->docMdpConfigService->isEnabled()) {
$docmdpLevel = $this->docMdpConfigService->getLevel();
$file->setDocmdpLevelEnum($docmdpLevel);
}
}
private function getFileMetadata(\OCP\Files\Node $node): array {
$metadata = [];
if ($extension = strtolower($node->getExtension())) {
$metadata = [
'extension' => $extension,
];
if ($metadata['extension'] === 'pdf') {
$metadata = array_merge(
$metadata,
$this->pdfParserService
->setFile($node)
->getPageDimensions()
);
}
}
return $metadata;
}
private function removeExtensionFromName(string $name, array $metadata): string {
if (!isset($metadata['extension'])) {
return $name;
}
$extensionPattern = '/\.' . preg_quote($metadata['extension'], '/') . '$/i';
$result = preg_replace($extensionPattern, '', $name);
return $result ?? $name;
}
private function deleteIdentifyMethodIfNotExits(array $users, int $fileId): void {
$file = $this->fileMapper->getById($fileId);
$signRequests = $this->signRequestMapper->getByFileId($fileId);
foreach ($signRequests as $key => $signRequest) {
$identifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequest->getId());
if (empty($identifyMethods)) {
$this->unassociateToUser($file->getNodeId(), $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());
continue 3;
}
}
}
}
}
private function identifyMethodExists(array $users, IIdentifyMethod $identifyMethod): bool {
foreach ($users as $user) {
if (!empty($user['identifyMethods'])) {
foreach ($user['identifyMethods'] as $data) {
if ($identifyMethod->getEntity()->getIdentifierKey() !== $data['method']) {
continue;
}
if ($identifyMethod->getEntity()->getIdentifierValue() === $data['value']) {
return true;
}
}
} else {
foreach ($user['identify'] as $method => $value) {
if ($identifyMethod->getEntity()->getIdentifierKey() !== $method) {
continue;
}
if ($identifyMethod->getEntity()->getIdentifierValue() === $value) {
return true;
}
}
}
}
return false;
}
/**
* @return SignRequestEntity[]
*
* @psalm-return list<SignRequestEntity>
*/
private function associateToSigners(array $data, int $fileId): array {
$return = [];
if (!empty($data['users'])) {
$this->deleteIdentifyMethodIfNotExits($data['users'], $fileId);
$this->sequentialSigningService->resetOrderCounter();
$fileStatus = $data['status'] ?? null;
foreach ($data['users'] as $user) {
$userProvidedOrder = isset($user['signingOrder']) ? (int)$user['signingOrder'] : null;
$signingOrder = $this->sequentialSigningService->determineSigningOrder($userProvidedOrder);
$signerStatus = $user['status'] ?? null;
if (isset($user['identifyMethods'])) {
foreach ($user['identifyMethods'] as $identifyMethod) {
$return[] = $this->associateToSigner(
identifyMethods: [
$identifyMethod['method'] => $identifyMethod['value'],
],
displayName: $user['displayName'] ?? '',
description: $user['description'] ?? '',
notify: empty($user['notify']),
fileId: $fileId,
signingOrder: $signingOrder,
fileStatus: $fileStatus,
signerStatus: $signerStatus,
);
}
} else {
$return[] = $this->associateToSigner(
identifyMethods: $user['identify'],
displayName: $user['displayName'] ?? '',
description: $user['description'] ?? '',
notify: empty($user['notify']),
fileId: $fileId,
signingOrder: $signingOrder,
fileStatus: $fileStatus,
signerStatus: $signerStatus,
);
}
}
}
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);
}
return $elements;
}
public function validateNewRequestToFile(array $data): void {
$this->validateNewFile($data);
$this->validateUsers($data);
$this->validateHelper->validateFileStatus($data);
}
public function validateNewFile(array $data): void {
if (empty($data['name'])) {
throw new \Exception($this->l10n->t('Name is mandatory'));
}
$this->validateHelper->validateNewFile($data);
}
public function validateUsers(array $data): void {
if (empty($data['users'])) {
throw new \Exception($this->l10n->t('Empty users list'));
}
if (!is_array($data['users'])) {
// TRANSLATION This message will be displayed when the request to API with the key users has a value that is not an array
throw new \Exception($this->l10n->t('User list needs to be an array'));
}
foreach ($data['users'] as $user) {
if (!array_key_exists('identify', $user)) {
throw new \Exception('Identify key not found');
}
$this->identifyMethod->setAllEntityData($user);
}
}
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 {
$signRequest = $this->signRequestMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
$deletedOrder = $signRequest->getSigningOrder();
$groupedIdentifyMethods = $this->identifyMethod->getIdentifyMethodsFromSignRequestId($signRequestId);
$this->dispatchCancellationEventIfNeeded($signRequest, $fileId, $groupedIdentifyMethods);
try {
$this->signRequestMapper->delete($signRequest);
foreach ($groupedIdentifyMethods as $identifyMethods) {
foreach ($identifyMethods as $identifyMethod) {
$identifyMethod->delete();
}
}
$visibleElements = $this->fileElementMapper->getByFileIdAndSignRequestId($fileId, $signRequestId);
foreach ($visibleElements as $visibleElement) {
$this->fileElementMapper->delete($visibleElement);
}
$this->sequentialSigningService->reorderAfterDeletion($fileId, $deletedOrder);
} catch (\Throwable) {
}
}
private function dispatchCancellationEventIfNeeded(
SignRequestEntity $signRequest,
int $fileId,
array $groupedIdentifyMethods,
): void {
if ($signRequest->getStatus() !== \OCA\Libresign\Enum\SignRequestStatus::ABLE_TO_SIGN->value) {
return;
}
try {
$libreSignFile = $this->fileMapper->getByFileId($fileId);
foreach ($groupedIdentifyMethods as $identifyMethods) {
foreach ($identifyMethods as $identifyMethod) {
$event = new SignRequestCanceledEvent(
$signRequest,
$libreSignFile,
$identifyMethod,
);
$this->eventDispatcher->dispatchTyped($event);
}
}
} catch (\Throwable $e) {
$this->logger->error('Error dispatching SignRequestCanceledEvent: ' . $e->getMessage(), ['exception' => $e]);
}
}
public function deleteRequestSignature(array $data): void {
if (!empty($data['uuid'])) {
$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']);
} else {
throw new \Exception($this->l10n->t('Please provide either UUID or File object'));
}
foreach ($signatures as $signRequest) {
$this->signRequestMapper->delete($signRequest);
}
$this->fileMapper->delete($fileData);
$this->fileElementService->deleteVisibleElements($fileData->getId());
}
}